#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════ # Fajl: routers/crm_router.py | v1.0.0 | 05.05.2026 # Autor: Damir Radulić / damir@rinet.one # Lokacija: /opt/pgz-sport/routers/crm_router.py # Svrha: Salesforce-Lite CRM (Accounts/Contacts/Leads/Opportunities/Activities/Cases) # + pipeline kanban view, /convert za leads, /complete za activities. # ═══════════════════════════════════════════════════════════════════ """CRM v2 router (Salesforce-style). Prefix: /api/v2/crm """ from __future__ import annotations import hashlib from datetime import date, datetime from decimal import Decimal from typing import Optional, List, Dict, Any import psycopg2 from psycopg2.extras import RealDictCursor from fastapi import APIRouter, HTTPException, Query, Body, Depends, Header from pydantic import BaseModel, Field router = APIRouter(prefix="/api/v2/crm", tags=["crm-v2"]) DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost'] LEAD_STATUSES = ['new', 'contacted', 'qualified', 'lost', 'converted'] CASE_STATUSES = ['open', 'in_progress', 'waiting', 'resolved', 'closed'] CASE_PRIORITIES = ['low', 'normal', 'high', 'urgent'] ACT_TYPES = ['call', 'meeting', 'email', 'task', 'note'] # ─────────── DB helpers ─────────── def _conn(): return psycopg2.connect(DSN, cursor_factory=RealDictCursor) def _conv(v): if isinstance(v, (date, datetime)): return v.isoformat() if isinstance(v, Decimal): return float(v) return v def _row(d): if d is None: return None return {k: _conv(v) for k, v in dict(d).items()} def _rows(seq): return [_row(r) for r in (seq or [])] # ─────────── Auth helper (lightweight, mirrors v2 router) ─────────── def _current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict[str, Any]]: """Resolve session user from Bearer token (token_hash = sha256(token)).""" if not authorization: return None tok = authorization.replace("Bearer ", "").strip() if not tok: return None th = hashlib.sha256(tok.encode()).hexdigest() try: with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT s.user_id, u.email, u.user_type, u.klub_id, u.savez_id, u.aktivan, u.ime, u.prezime FROM pgz_sport.user_sessions s JOIN pgz_sport.users u ON u.id = s.user_id WHERE s.token_hash = %s AND s.revoked = false AND s.expires_at > now() LIMIT 1 """, (th,)) r = cur.fetchone() if not r: return None d = _row(r) if d.get("aktivan") is False: return None return d except Exception: return None def _require_user(user=Depends(_current_user)): if not user: raise HTTPException(status_code=401, detail="Authentication required") return user def _is_admin(user: Dict) -> bool: return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin") def _check_owner_or_admin(user: Dict, owner_user_id: Optional[int]): if _is_admin(user): return if owner_user_id is not None and user.get("user_id") == owner_user_id: return raise HTTPException(status_code=403, detail="Insufficient privileges") # ─────────── PIPELINE / DASHBOARD ─────────── @router.get("/pipeline") def pipeline(user=Depends(_require_user)): """Aggregate opportunities by stage (count, sum, weighted, items).""" with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT o.id, o.naziv, o.stage, o.amount_eur, o.probability, o.close_date, o.account_id, o.type, a.naziv AS account_naziv FROM pgz_sport.crm_opportunities o LEFT JOIN pgz_sport.crm_accounts a ON a.id = o.account_id ORDER BY o.close_date NULLS LAST, o.id """) opps = _rows(cur.fetchall()) buckets = {s: {"stage": s, "count": 0, "amount_total": 0.0, "weighted_total": 0.0, "items": []} for s in STAGES} for o in opps: s = o.get("stage") if s not in buckets: continue amt = float(o.get("amount_eur") or 0) prob = float(o.get("probability") or 0) buckets[s]["count"] += 1 buckets[s]["amount_total"] += amt buckets[s]["weighted_total"] += amt * prob / 100.0 buckets[s]["items"].append(o) return {"stages": [buckets[s] for s in STAGES]} @router.get("/dashboard") def dashboard(user=Depends(_require_user)): out = {} with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT COUNT(*) FILTER (WHERE stage NOT IN ('closed_won','closed_lost')) AS open_opps, COALESCE(SUM(amount_eur) FILTER (WHERE stage NOT IN ('closed_won','closed_lost')), 0) AS open_amount, COALESCE(SUM(amount_eur * probability / 100.0) FILTER (WHERE stage NOT IN ('closed_won','closed_lost')), 0) AS weighted_amount, COUNT(*) FILTER (WHERE stage = 'closed_won' AND close_date >= date_trunc('quarter', current_date)) AS won_q, COALESCE(SUM(amount_eur) FILTER (WHERE stage = 'closed_won' AND close_date >= date_trunc('quarter', current_date)), 0) AS won_q_amount FROM pgz_sport.crm_opportunities """) out["opportunities"] = _row(cur.fetchone()) cur.execute(""" SELECT status, COUNT(*) AS n FROM pgz_sport.crm_leads GROUP BY status """) out["leads_by_status"] = _rows(cur.fetchall()) cur.execute(""" SELECT COUNT(*) FILTER (WHERE completed_at IS NULL AND due_at < now()) AS overdue, COUNT(*) FILTER (WHERE completed_at IS NULL AND due_at >= now()) AS upcoming, COUNT(*) FILTER (WHERE completed_at IS NOT NULL) AS done FROM pgz_sport.crm_activities """) out["activities"] = _row(cur.fetchone()) cur.execute(""" SELECT status, COUNT(*) AS n FROM pgz_sport.crm_cases GROUP BY status """) out["cases_by_status"] = _rows(cur.fetchall()) cur.execute("SELECT COUNT(*) AS n FROM pgz_sport.crm_accounts") out["accounts_total"] = _row(cur.fetchone())["n"] cur.execute("SELECT COUNT(*) AS n FROM pgz_sport.crm_contacts") out["contacts_total"] = _row(cur.fetchone())["n"] return out # ─────────── ACCOUNTS ─────────── class AccountIn(BaseModel): naziv: str type: str = 'klub' klub_id: Optional[int] = None savez_id: Optional[int] = None oib: Optional[str] = None email: Optional[str] = None telefon: Optional[str] = None web: Optional[str] = None adresa: Optional[str] = None grad: Optional[str] = None industry: Optional[str] = None napomene: Optional[str] = None @router.get("/accounts") def list_accounts(q: Optional[str] = None, type: Optional[str] = None, owner_id: Optional[int] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if q: where.append("(a.naziv ILIKE %s OR a.oib ILIKE %s OR a.email ILIKE %s OR a.grad ILIKE %s)") like = f"%{q}%" params += [like, like, like, like] if type: where.append("a.type = %s") params.append(type) if owner_id: where.append("a.owner_user_id = %s") params.append(owner_id) sql = f""" SELECT a.*, (SELECT COUNT(*) FROM pgz_sport.crm_contacts c WHERE c.account_id = a.id) AS contacts_n, (SELECT COUNT(*) FROM pgz_sport.crm_opportunities o WHERE o.account_id = a.id) AS opps_n, u.email AS owner_email FROM pgz_sport.crm_accounts a LEFT JOIN pgz_sport.users u ON u.id = a.owner_user_id WHERE {' AND '.join(where)} ORDER BY a.updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/accounts") def create_account(req: AccountIn, user=Depends(_require_user)): if req.type not in ('klub', 'savez', 'sponzor', 'drzava', 'drugo'): raise HTTPException(400, "invalid type") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_accounts (naziv, type, klub_id, savez_id, oib, email, telefon, web, adresa, grad, industry, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.naziv, req.type, req.klub_id, req.savez_id, req.oib, req.email, req.telefon, req.web, req.adresa, req.grad, req.industry, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/accounts/{aid}") def get_account(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_accounts WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") out = _row(row) cur.execute("SELECT * FROM pgz_sport.crm_contacts WHERE account_id=%s ORDER BY updated_at DESC", (aid,)) out["contacts"] = _rows(cur.fetchall()) cur.execute("SELECT * FROM pgz_sport.crm_opportunities WHERE account_id=%s ORDER BY updated_at DESC", (aid,)) out["opportunities"] = _rows(cur.fetchall()) cur.execute("""SELECT * FROM pgz_sport.crm_activities WHERE account_id=%s ORDER BY COALESCE(due_at, created_at) DESC LIMIT 50""", (aid,)) out["activities"] = _rows(cur.fetchall()) cur.execute("SELECT * FROM pgz_sport.crm_cases WHERE account_id=%s ORDER BY updated_at DESC", (aid,)) out["cases"] = _rows(cur.fetchall()) return out @router.put("/accounts/{aid}") def update_account(aid: int, req: AccountIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_accounts WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_accounts SET naziv=%s, type=%s, klub_id=%s, savez_id=%s, oib=%s, email=%s, telefon=%s, web=%s, adresa=%s, grad=%s, industry=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.naziv, req.type, req.klub_id, req.savez_id, req.oib, req.email, req.telefon, req.web, req.adresa, req.grad, req.industry, req.napomene, aid)) cn.commit() return _row(cur.fetchone()) @router.delete("/accounts/{aid}") def delete_account(aid: int, user=Depends(_require_user)): if not _is_admin(user): raise HTTPException(403, "admin only") with _conn() as cn, cn.cursor() as cur: cur.execute("DELETE FROM pgz_sport.crm_accounts WHERE id=%s RETURNING id", (aid,)) if not cur.fetchone(): raise HTTPException(404, "not found") cn.commit() return {"ok": True, "id": aid} # ─────────── CONTACTS ─────────── class ContactIn(BaseModel): account_id: Optional[int] = None clan_id: Optional[int] = None ime: str prezime: str funkcija: Optional[str] = None email: Optional[str] = None telefon: Optional[str] = None mobitel: Optional[str] = None napomene: Optional[str] = None @router.get("/contacts") def list_contacts(account_id: Optional[int] = None, q: Optional[str] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if account_id: where.append("c.account_id = %s") params.append(account_id) if q: where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.email ILIKE %s OR c.funkcija ILIKE %s)") like = f"%{q}%" params += [like, like, like, like] sql = f""" SELECT c.*, a.naziv AS account_naziv FROM pgz_sport.crm_contacts c LEFT JOIN pgz_sport.crm_accounts a ON a.id = c.account_id WHERE {' AND '.join(where)} ORDER BY c.updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/contacts") def create_contact(req: ContactIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_contacts (account_id, clan_id, ime, prezime, funkcija, email, telefon, mobitel, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.account_id, req.clan_id, req.ime, req.prezime, req.funkcija, req.email, req.telefon, req.mobitel, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/contacts/{cid}") def get_contact(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT c.*, a.naziv AS account_naziv FROM pgz_sport.crm_contacts c LEFT JOIN pgz_sport.crm_accounts a ON a.id = c.account_id WHERE c.id=%s """, (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/contacts/{cid}") def update_contact(cid: int, req: ContactIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_contacts WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_contacts SET account_id=%s, clan_id=%s, ime=%s, prezime=%s, funkcija=%s, email=%s, telefon=%s, mobitel=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.account_id, req.clan_id, req.ime, req.prezime, req.funkcija, req.email, req.telefon, req.mobitel, req.napomene, cid)) cn.commit() return _row(cur.fetchone()) @router.delete("/contacts/{cid}") def delete_contact(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_contacts WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_contacts WHERE id=%s", (cid,)) cn.commit() return {"ok": True, "id": cid} # ─────────── LEADS ─────────── class LeadIn(BaseModel): ime: Optional[str] = None prezime: Optional[str] = None organizacija: Optional[str] = None email: Optional[str] = None telefon: Optional[str] = None izvor: Optional[str] = None status: str = 'new' napomene: Optional[str] = None class ConvertReq(BaseModel): account: Optional[Dict[str, Any]] = None contact: Optional[Dict[str, Any]] = None opportunity: Optional[Dict[str, Any]] = None @router.get("/leads") def list_leads(status: Optional[str] = None, q: Optional[str] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if status: where.append("status = %s") params.append(status) if q: where.append("(ime ILIKE %s OR prezime ILIKE %s OR organizacija ILIKE %s OR email ILIKE %s)") like = f"%{q}%" params += [like, like, like, like] sql = f""" SELECT * FROM pgz_sport.crm_leads WHERE {' AND '.join(where)} ORDER BY updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/leads") def create_lead(req: LeadIn, user=Depends(_require_user)): if req.status not in LEAD_STATUSES: raise HTTPException(400, "invalid status") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_leads (ime, prezime, organizacija, email, telefon, izvor, status, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.ime, req.prezime, req.organizacija, req.email, req.telefon, req.izvor, req.status, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/leads/{lid}") def get_lead(lid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/leads/{lid}") def update_lead(lid: int, req: LeadIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_leads SET ime=%s, prezime=%s, organizacija=%s, email=%s, telefon=%s, izvor=%s, status=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.ime, req.prezime, req.organizacija, req.email, req.telefon, req.izvor, req.status, req.napomene, lid)) cn.commit() return _row(cur.fetchone()) @router.delete("/leads/{lid}") def delete_lead(lid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) cn.commit() return {"ok": True, "id": lid} @router.post("/leads/{lid}/convert") def convert_lead(lid: int, req: ConvertReq, user=Depends(_require_user)): """Lead → Account + Contact (+ optional Opportunity). Sets lead status=converted.""" with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) lead = cur.fetchone() if not lead: raise HTTPException(404, "lead not found") if lead["status"] == "converted": raise HTTPException(409, "already converted") a_payload = req.account or {} a_naziv = a_payload.get("naziv") or lead["organizacija"] or f'{lead.get("ime","")} {lead.get("prezime","")}'.strip() or "Lead" a_type = a_payload.get("type") or "drugo" cur.execute(""" INSERT INTO pgz_sport.crm_accounts (naziv, type, email, telefon, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s) RETURNING * """, (a_naziv, a_type, a_payload.get("email") or lead["email"], a_payload.get("telefon") or lead["telefon"], a_payload.get("napomene"), user["user_id"])) new_account = cur.fetchone() new_contact = None if (lead["ime"] or lead["prezime"]) or req.contact: c_payload = req.contact or {} cur.execute(""" INSERT INTO pgz_sport.crm_contacts (account_id, ime, prezime, email, telefon, funkcija, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (new_account["id"], c_payload.get("ime") or lead["ime"] or "", c_payload.get("prezime") or lead["prezime"] or "", c_payload.get("email") or lead["email"], c_payload.get("telefon") or lead["telefon"], c_payload.get("funkcija"), user["user_id"])) new_contact = cur.fetchone() new_opp = None if req.opportunity: o = req.opportunity cur.execute(""" INSERT INTO pgz_sport.crm_opportunities (account_id, contact_id, naziv, type, stage, amount_eur, probability, close_date, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (new_account["id"], new_contact["id"] if new_contact else None, o.get("naziv") or f"Opportunity from lead {lid}", o.get("type") or "financiranje", o.get("stage") or "prospecting", o.get("amount_eur"), o.get("probability") or 20, o.get("close_date"), o.get("napomene"), user["user_id"])) new_opp = cur.fetchone() cur.execute(""" UPDATE pgz_sport.crm_leads SET status='converted', converted_account_id=%s, converted_contact_id=%s, converted_at=now(), updated_at=now() WHERE id=%s RETURNING * """, (new_account["id"], new_contact["id"] if new_contact else None, lid)) updated_lead = cur.fetchone() cn.commit() return { "lead": _row(updated_lead), "account": _row(new_account), "contact": _row(new_contact), "opportunity": _row(new_opp), } # ─────────── OPPORTUNITIES ─────────── class OppIn(BaseModel): account_id: int contact_id: Optional[int] = None naziv: str type: str = 'financiranje' stage: str = 'prospecting' amount_eur: Optional[float] = None probability: int = 20 close_date: Optional[str] = None napomene: Optional[str] = None class StageReq(BaseModel): stage: str @router.get("/opportunities") def list_opps(stage: Optional[str] = None, account_id: Optional[int] = None, q: Optional[str] = None, limit: int = 500, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if stage: where.append("o.stage = %s") params.append(stage) if account_id: where.append("o.account_id = %s") params.append(account_id) if q: where.append("(o.naziv ILIKE %s OR a.naziv ILIKE %s)") like = f"%{q}%" params += [like, like] sql = f""" SELECT o.*, a.naziv AS account_naziv FROM pgz_sport.crm_opportunities o LEFT JOIN pgz_sport.crm_accounts a ON a.id = o.account_id WHERE {' AND '.join(where)} ORDER BY o.close_date NULLS LAST, o.id DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/opportunities") def create_opp(req: OppIn, user=Depends(_require_user)): if req.stage not in STAGES: raise HTTPException(400, "invalid stage") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_opportunities (account_id, contact_id, naziv, type, stage, amount_eur, probability, close_date, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.account_id, req.contact_id, req.naziv, req.type, req.stage, req.amount_eur, req.probability, req.close_date, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/opportunities/{oid}") def get_opp(oid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT o.*, a.naziv AS account_naziv FROM pgz_sport.crm_opportunities o LEFT JOIN pgz_sport.crm_accounts a ON a.id = o.account_id WHERE o.id=%s """, (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/opportunities/{oid}") def update_opp(oid: int, req: OppIn, user=Depends(_require_user)): if req.stage not in STAGES: raise HTTPException(400, "invalid stage") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_opportunities SET account_id=%s, contact_id=%s, naziv=%s, type=%s, stage=%s, amount_eur=%s, probability=%s, close_date=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.account_id, req.contact_id, req.naziv, req.type, req.stage, req.amount_eur, req.probability, req.close_date, req.napomene, oid)) cn.commit() return _row(cur.fetchone()) @router.patch("/opportunities/{oid}/stage") def patch_stage(oid: int, req: StageReq, user=Depends(_require_user)): if req.stage not in STAGES: raise HTTPException(400, "invalid stage") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_opportunities SET stage=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.stage, oid)) cn.commit() return _row(cur.fetchone()) @router.delete("/opportunities/{oid}") def delete_opp(oid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) cn.commit() return {"ok": True, "id": oid} # ─────────── ACTIVITIES ─────────── class ActivityIn(BaseModel): type: str subject: str body: Optional[str] = None account_id: Optional[int] = None contact_id: Optional[int] = None opportunity_id: Optional[int] = None lead_id: Optional[int] = None due_at: Optional[str] = None @router.get("/activities") def list_activities(account_id: Optional[int] = None, contact_id: Optional[int] = None, opp_id: Optional[int] = None, lead_id: Optional[int] = None, type: Optional[str] = None, open_only: Optional[bool] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if account_id: where.append("ac.account_id = %s"); params.append(account_id) if contact_id: where.append("ac.contact_id = %s"); params.append(contact_id) if opp_id: where.append("ac.opportunity_id = %s"); params.append(opp_id) if lead_id: where.append("ac.lead_id = %s"); params.append(lead_id) if type: where.append("ac.type = %s"); params.append(type) if open_only is True: where.append("ac.completed_at IS NULL") elif open_only is False: where.append("ac.completed_at IS NOT NULL") sql = f""" SELECT ac.*, a.naziv AS account_naziv, c.ime || ' ' || c.prezime AS contact_naziv, o.naziv AS opp_naziv FROM pgz_sport.crm_activities ac LEFT JOIN pgz_sport.crm_accounts a ON a.id = ac.account_id LEFT JOIN pgz_sport.crm_contacts c ON c.id = ac.contact_id LEFT JOIN pgz_sport.crm_opportunities o ON o.id = ac.opportunity_id WHERE {' AND '.join(where)} ORDER BY COALESCE(ac.due_at, ac.created_at) DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/activities") def create_activity(req: ActivityIn, user=Depends(_require_user)): if req.type not in ACT_TYPES: raise HTTPException(400, "invalid type") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_activities (type, subject, body, account_id, contact_id, opportunity_id, lead_id, due_at, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.type, req.subject, req.body, req.account_id, req.contact_id, req.opportunity_id, req.lead_id, req.due_at, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/activities/{aid}") def get_activity(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/activities/{aid}") def update_activity(aid: int, req: ActivityIn, user=Depends(_require_user)): if req.type not in ACT_TYPES: raise HTTPException(400, "invalid type") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_activities SET type=%s, subject=%s, body=%s, account_id=%s, contact_id=%s, opportunity_id=%s, lead_id=%s, due_at=%s WHERE id=%s RETURNING * """, (req.type, req.subject, req.body, req.account_id, req.contact_id, req.opportunity_id, req.lead_id, req.due_at, aid)) cn.commit() return _row(cur.fetchone()) @router.patch("/activities/{aid}/complete") def complete_activity(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" UPDATE pgz_sport.crm_activities SET completed_at=now() WHERE id=%s RETURNING * """, (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") cn.commit() return _row(row) @router.delete("/activities/{aid}") def delete_activity(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) cn.commit() return {"ok": True, "id": aid} # ─────────── CASES ─────────── class CaseIn(BaseModel): account_id: Optional[int] = None contact_id: Optional[int] = None subject: str description: Optional[str] = None status: str = 'open' priority: str = 'normal' @router.get("/cases") def list_cases(status: Optional[str] = None, priority: Optional[str] = None, account_id: Optional[int] = None, q: Optional[str] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if status: where.append("k.status = %s"); params.append(status) if priority: where.append("k.priority = %s"); params.append(priority) if account_id: where.append("k.account_id = %s"); params.append(account_id) if q: where.append("(k.subject ILIKE %s OR k.description ILIKE %s)") like = f"%{q}%" params += [like, like] sql = f""" SELECT k.*, a.naziv AS account_naziv FROM pgz_sport.crm_cases k LEFT JOIN pgz_sport.crm_accounts a ON a.id = k.account_id WHERE {' AND '.join(where)} ORDER BY (CASE k.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END), k.updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/cases") def create_case(req: CaseIn, user=Depends(_require_user)): if req.status not in CASE_STATUSES: raise HTTPException(400, "invalid status") if req.priority not in CASE_PRIORITIES: raise HTTPException(400, "invalid priority") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_cases (account_id, contact_id, subject, description, status, priority, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.account_id, req.contact_id, req.subject, req.description, req.status, req.priority, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/cases/{cid}") def get_case(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT k.*, a.naziv AS account_naziv FROM pgz_sport.crm_cases k LEFT JOIN pgz_sport.crm_accounts a ON a.id = k.account_id WHERE k.id=%s """, (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/cases/{cid}") def update_case(cid: int, req: CaseIn, user=Depends(_require_user)): if req.status not in CASE_STATUSES: raise HTTPException(400, "invalid status") if req.priority not in CASE_PRIORITIES: raise HTTPException(400, "invalid priority") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_cases WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) resolved_at_clause = ", resolved_at=now()" if req.status in ('resolved', 'closed') else "" cur.execute(f""" UPDATE pgz_sport.crm_cases SET account_id=%s, contact_id=%s, subject=%s, description=%s, status=%s, priority=%s, updated_at=now() {resolved_at_clause} WHERE id=%s RETURNING * """, (req.account_id, req.contact_id, req.subject, req.description, req.status, req.priority, cid)) cn.commit() return _row(cur.fetchone()) @router.delete("/cases/{cid}") def delete_case(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_cases WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_cases WHERE id=%s", (cid,)) cn.commit() return {"ok": True, "id": cid} # ═══════════════════════════════════════════════════════════════════ # AGENT F — Salesforce-Lite extra tabs (Članarine / Liječnički / Obrasci) # Added: 2026-05-05 by dradulic@outlook.com / damir@rinet.one # Tables: pgz_sport.clanarine, pgz_sport.lijecnicki_pregledi, # pgz_sport.form_templates, pgz_sport.form_submissions # ═══════════════════════════════════════════════════════════════════ import json as _json CLANARINA_STATUSES = ['nepodmireno', 'djelomicno', 'podmireno', 'storno'] SUBMISSION_STATUSES = ['draft', 'submitted', 'approved', 'rejected'] # ─────────── ČLANARINE ─────────── class ClanarinaIn(BaseModel): clan_id: Optional[int] = None klub_id: Optional[int] = None godina: int razdoblje: Optional[str] = None iznos_propisan: float iznos_placen: Optional[float] = 0 datum_uplate: Optional[str] = None nacin_uplate: Optional[str] = None referenca: Optional[str] = None racun_broj: Optional[str] = None status: str = 'nepodmireno' napomena: Optional[str] = None @router.get("/clanarine") def list_clanarine(klub_id: Optional[int] = None, clan_id: Optional[int] = None, godina: Optional[int] = None, status: Optional[str] = None, limit: int = 500, user=Depends(_require_user)): where, args = ["1=1"], [] if klub_id: where.append("c.klub_id=%s"); args.append(klub_id) if clan_id: where.append("c.clan_id=%s"); args.append(clan_id) if godina: where.append("c.godina=%s"); args.append(godina) if status: where.append("c.status=%s"); args.append(status) args.append(limit) sql = f""" SELECT c.*, (cl.ime||' '||cl.prezime) AS clan_naziv, k.naziv AS klub_naziv FROM pgz_sport.clanarine c LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id WHERE {' AND '.join(where)} ORDER BY c.godina DESC, c.id DESC LIMIT %s """ with _conn() as cn, cn.cursor() as cur: cur.execute(sql, args) items = _rows(cur.fetchall()) return {"items": items, "count": len(items)} @router.get("/clanarine/{cid}") def get_clanarina(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT c.*, (cl.ime||' '||cl.prezime) AS clan_naziv, k.naziv AS klub_naziv FROM pgz_sport.clanarine c LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id WHERE c.id=%s """, (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.post("/clanarine") def create_clanarina(req: ClanarinaIn, user=Depends(_require_user)): if req.status not in CLANARINA_STATUSES: raise HTTPException(400, "invalid status") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, referenca, racun_broj, status, napomena) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.clan_id, req.klub_id, req.godina, req.razdoblje, req.iznos_propisan, req.iznos_placen or 0, req.datum_uplate, req.nacin_uplate, req.referenca, req.racun_broj, req.status, req.napomena)) cn.commit() return _row(cur.fetchone()) @router.put("/clanarine/{cid}") def update_clanarina(cid: int, req: ClanarinaIn, user=Depends(_require_user)): if req.status not in CLANARINA_STATUSES: raise HTTPException(400, "invalid status") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT id FROM pgz_sport.clanarine WHERE id=%s", (cid,)) if not cur.fetchone(): raise HTTPException(404, "not found") cur.execute(""" UPDATE pgz_sport.clanarine SET clan_id=%s, klub_id=%s, godina=%s, razdoblje=%s, iznos_propisan=%s, iznos_placen=%s, datum_uplate=%s, nacin_uplate=%s, referenca=%s, racun_broj=%s, status=%s, napomena=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.clan_id, req.klub_id, req.godina, req.razdoblje, req.iznos_propisan, req.iznos_placen or 0, req.datum_uplate, req.nacin_uplate, req.referenca, req.racun_broj, req.status, req.napomena, cid)) cn.commit() return _row(cur.fetchone()) @router.delete("/clanarine/{cid}") def delete_clanarina(cid: int, user=Depends(_require_user)): if not _is_admin(user): raise HTTPException(403, "admin only") with _conn() as cn, cn.cursor() as cur: cur.execute("DELETE FROM pgz_sport.clanarine WHERE id=%s", (cid,)) cn.commit() return {"ok": True, "id": cid} # ─────────── LIJEČNIČKI PREGLEDI ─────────── class LijecnickiIn(BaseModel): clan_id: int klub_id: Optional[int] = None datum_pregleda: str vrijedi_do: Optional[str] = None vrsta_pregleda: Optional[str] = None ustanova: Optional[str] = None lijecnik: Optional[str] = None spreman_za_natjecanje: Optional[bool] = True ekg: Optional[bool] = False krv: Optional[bool] = False spirometrija: Optional[bool] = False nalaz: Optional[str] = None komentar_lijecnika: Optional[str] = None preporuke: Optional[str] = None iznos: Optional[float] = None iznos_zzjz: Optional[float] = 0 iznos_klub: Optional[float] = 0 iznos_clan: Optional[float] = 0 datum_placanja: Optional[str] = None placeno: Optional[bool] = False racun_broj: Optional[str] = None nacin_placanja: Optional[str] = None napomena: Optional[str] = None @router.get("/lijecnicki") def list_lijecnicki(klub_id: Optional[int] = None, clan_id: Optional[int] = None, expiring: Optional[bool] = None, limit: int = 500, user=Depends(_require_user)): """expiring=true → vrijedi_do u idućih 30d ili manje (ili prošlo).""" where, args = ["1=1"], [] if klub_id: where.append("l.klub_id=%s"); args.append(klub_id) if clan_id: where.append("l.clan_id=%s"); args.append(clan_id) if expiring: where.append("(l.vrijedi_do IS NULL OR l.vrijedi_do <= current_date + interval '30 days')") args.append(limit) sql = f""" SELECT l.*, (cl.ime||' '||cl.prezime) AS clan_naziv, k.naziv AS klub_naziv FROM pgz_sport.lijecnicki_pregledi l LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id WHERE {' AND '.join(where)} ORDER BY l.datum_pregleda DESC, l.id DESC LIMIT %s """ with _conn() as cn, cn.cursor() as cur: cur.execute(sql, args) items = _rows(cur.fetchall()) return {"items": items, "count": len(items)} @router.get("/lijecnicki/{lid}") def get_lijecnicki(lid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT l.*, (cl.ime||' '||cl.prezime) AS clan_naziv, k.naziv AS klub_naziv FROM pgz_sport.lijecnicki_pregledi l LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id WHERE l.id=%s """, (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.post("/lijecnicki") def create_lijecnicki(req: LijecnickiIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, racun_broj, nacin_placanja, napomena) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, %s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.clan_id, req.klub_id, req.datum_pregleda, req.vrijedi_do, req.vrsta_pregleda, req.ustanova, req.lijecnik, req.spreman_za_natjecanje, req.ekg, req.krv, req.spirometrija, req.nalaz, req.komentar_lijecnika, req.preporuke, req.iznos, req.iznos_zzjz or 0, req.iznos_klub or 0, req.iznos_clan or 0, req.datum_placanja, req.placeno, req.racun_broj, req.nacin_placanja, req.napomena)) cn.commit() return _row(cur.fetchone()) @router.put("/lijecnicki/{lid}") def update_lijecnicki(lid: int, req: LijecnickiIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT id FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,)) if not cur.fetchone(): raise HTTPException(404, "not found") cur.execute(""" UPDATE pgz_sport.lijecnicki_pregledi SET clan_id=%s, klub_id=%s, datum_pregleda=%s, vrijedi_do=%s, vrsta_pregleda=%s, ustanova=%s, lijecnik=%s, spreman_za_natjecanje=%s, ekg=%s, krv=%s, spirometrija=%s, nalaz=%s, komentar_lijecnika=%s, preporuke=%s, iznos=%s, iznos_zzjz=%s, iznos_klub=%s, iznos_clan=%s, datum_placanja=%s, placeno=%s, racun_broj=%s, nacin_placanja=%s, napomena=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.clan_id, req.klub_id, req.datum_pregleda, req.vrijedi_do, req.vrsta_pregleda, req.ustanova, req.lijecnik, req.spreman_za_natjecanje, req.ekg, req.krv, req.spirometrija, req.nalaz, req.komentar_lijecnika, req.preporuke, req.iznos, req.iznos_zzjz or 0, req.iznos_klub or 0, req.iznos_clan or 0, req.datum_placanja, req.placeno, req.racun_broj, req.nacin_placanja, req.napomena, lid)) cn.commit() return _row(cur.fetchone()) @router.delete("/lijecnicki/{lid}") def delete_lijecnicki(lid: int, user=Depends(_require_user)): if not _is_admin(user): raise HTTPException(403, "admin only") with _conn() as cn, cn.cursor() as cur: cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,)) cn.commit() return {"ok": True, "id": lid} # ─────────── OBRASCI (templates + submissions) ─────────── class SubmissionIn(BaseModel): template_id: Optional[int] = None template_code: Optional[str] = None klub_id: Optional[int] = None clan_id: Optional[int] = None data: Dict[str, Any] = Field(default_factory=dict) status: str = 'draft' # draft / submitted reference_no: Optional[str] = None class SubmissionStatusIn(BaseModel): status: str # submitted / approved / rejected rejected_reason: Optional[str] = None @router.get("/obrasci") def list_obrasci_templates(kategorija: Optional[str] = None, active: bool = True, user=Depends(_require_user)): where, args = [], [] if active: where.append("active=true") if kategorija: where.append("kategorija=%s"); args.append(kategorija) sql = "SELECT id, code, naziv, kategorija, opis, schema_json, required_role, active FROM pgz_sport.form_templates" if where: sql += " WHERE " + " AND ".join(where) sql += " ORDER BY kategorija NULLS LAST, naziv" with _conn() as cn, cn.cursor() as cur: cur.execute(sql, args) items = _rows(cur.fetchall()) return {"items": items, "count": len(items)} @router.get("/obrasci/submission") def list_obrasci_submissions(status: Optional[str] = None, klub_id: Optional[int] = None, template_code: Optional[str] = None, clan_id: Optional[int] = None, limit: int = 500, user=Depends(_require_user)): where, args = ["1=1"], [] if status: where.append("s.status=%s"); args.append(status) if klub_id: where.append("s.klub_id=%s"); args.append(klub_id) if template_code: where.append("s.template_code=%s"); args.append(template_code) if clan_id: where.append("s.clan_id=%s"); args.append(clan_id) args.append(limit) sql = f""" SELECT s.id, s.template_id, s.template_code, s.klub_id, s.clan_id, s.user_id, s.data, s.status, s.reference_no, s.submitted_at, s.reviewed_at, s.approved_at, s.rejected_reason, s.created_at, s.updated_at, t.naziv AS template_naziv, t.kategorija, k.naziv AS klub_naziv, (cl.ime||' '||cl.prezime) AS clan_naziv, u.email AS submitter_email FROM pgz_sport.form_submissions s LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id LEFT JOIN pgz_sport.users u ON u.id = s.user_id WHERE {' AND '.join(where)} ORDER BY s.created_at DESC LIMIT %s """ with _conn() as cn, cn.cursor() as cur: cur.execute(sql, args) items = _rows(cur.fetchall()) return {"items": items, "count": len(items)} @router.get("/obrasci/submission/{sid}") def get_obrazac_submission(sid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json, k.naziv AS klub_naziv, (cl.ime||' '||cl.prezime) AS clan_naziv, u.email AS submitter_email FROM pgz_sport.form_submissions s LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id LEFT JOIN pgz_sport.users u ON u.id = s.user_id WHERE s.id=%s """, (sid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.post("/obrasci/submission") def create_obrazac_submission(req: SubmissionIn, user=Depends(_require_user)): if req.status not in ('draft', 'submitted'): raise HTTPException(400, "invalid status (draft|submitted)") tid = req.template_id tcode = req.template_code with _conn() as cn, cn.cursor() as cur: if tid and not tcode: cur.execute("SELECT code FROM pgz_sport.form_templates WHERE id=%s", (tid,)) r = cur.fetchone() if not r: raise HTTPException(400, "unknown template_id") tcode = r["code"] elif tcode and not tid: cur.execute("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (tcode,)) r = cur.fetchone() if not r: raise HTTPException(400, "unknown template_code") tid = r["id"] elif not tid and not tcode: raise HTTPException(400, "template_id or template_code required") submitted_at = "now()" if req.status == 'submitted' else "NULL" cur.execute(f""" INSERT INTO pgz_sport.form_submissions (template_id, template_code, klub_id, user_id, clan_id, data, status, reference_no, submitted_at) VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s,{submitted_at}) RETURNING * """, (tid, tcode, req.klub_id, user["user_id"], req.clan_id, _json.dumps(req.data or {}), req.status, req.reference_no)) cn.commit() return _row(cur.fetchone()) @router.put("/obrasci/submission/{sid}") def update_obrazac_submission(sid: int, req: SubmissionIn, user=Depends(_require_user)): """Submitter can update their own draft.""" with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") if not _is_admin(user) and row["user_id"] != user["user_id"]: raise HTTPException(403, "not your submission") if row["status"] not in ('draft', 'submitted') and not _is_admin(user): raise HTTPException(400, "submission already finalized") cur.execute(""" UPDATE pgz_sport.form_submissions SET klub_id=%s, clan_id=%s, data=%s::jsonb, status=%s, reference_no=%s, submitted_at = CASE WHEN %s='submitted' AND submitted_at IS NULL THEN now() ELSE submitted_at END, updated_at=now() WHERE id=%s RETURNING * """, (req.klub_id, req.clan_id, _json.dumps(req.data or {}), req.status, req.reference_no, req.status, sid)) cn.commit() return _row(cur.fetchone()) @router.put("/obrasci/submission/{sid}/status") def set_submission_status(sid: int, req: SubmissionStatusIn, user=Depends(_require_user)): """Approve/reject (admin) or self-submit a draft (owner).""" if req.status not in SUBMISSION_STATUSES: raise HTTPException(400, "invalid status") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") if req.status in ('approved', 'rejected'): if not _is_admin(user): raise HTTPException(403, "admin only") elif req.status == 'submitted': if row["user_id"] != user["user_id"] and not _is_admin(user): raise HTTPException(403, "not your submission") if req.status == 'approved': cur.execute(""" UPDATE pgz_sport.form_submissions SET status='approved', approved_by=%s, approved_at=now(), reviewed_by=%s, reviewed_at=now(), updated_at=now() WHERE id=%s RETURNING * """, (user["user_id"], user["user_id"], sid)) elif req.status == 'rejected': cur.execute(""" UPDATE pgz_sport.form_submissions SET status='rejected', rejected_reason=%s, reviewed_by=%s, reviewed_at=now(), updated_at=now() WHERE id=%s RETURNING * """, (req.rejected_reason, user["user_id"], sid)) elif req.status == 'submitted': cur.execute(""" UPDATE pgz_sport.form_submissions SET status='submitted', submitted_at = COALESCE(submitted_at, now()), updated_at=now() WHERE id=%s RETURNING * """, (sid,)) else: # draft cur.execute(""" UPDATE pgz_sport.form_submissions SET status='draft', updated_at=now() WHERE id=%s RETURNING * """, (sid,)) cn.commit() return _row(cur.fetchone()) @router.delete("/obrasci/submission/{sid}") def delete_obrazac_submission(sid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") if not _is_admin(user) and row["user_id"] != user["user_id"]: raise HTTPException(403, "not your submission") cur.execute("DELETE FROM pgz_sport.form_submissions WHERE id=%s", (sid,)) cn.commit() return {"ok": True, "id": sid} @router.get("/obrasci/{tid}") def get_obrazac_template(tid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (tid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row)