Sidebar: +ERP +CRM +Dokumenti, godišnjaci import (18 PDFs), filter helpers

- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
This commit is contained in:
2026-05-05 13:08:11 +02:00
parent 9fb512932a
commit 1d02c0897d
970 changed files with 268354 additions and 434 deletions
+955
View File
@@ -0,0 +1,955 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/crm_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / 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}
File diff suppressed because it is too large Load Diff