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:
@@ -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
Reference in New Issue
Block a user