1d02c0897d
- 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
956 lines
36 KiB
Python
956 lines
36 KiB
Python
#!/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}
|