PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)
nginx (sport.rinet.one): - proxy_read_timeout 60s → 300s - proxy_send_timeout 300s - proxy_buffering off (PDF stream) - client_max_body_size 50M → 100M Endpoints: - /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS) - /api/v2/sportasi/filtered: +samo_priority +samo_s_hns Frontend: - PDF link target=_blank rel=noopener - window._klub_only_priority = true (default) - window._sportas_only_priority = true (default) DB View: - pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
This commit is contained in:
@@ -953,3 +953,496 @@ def delete_case(cid: int, user=Depends(_require_user)):
|
||||
cur.execute("DELETE FROM pgz_sport.crm_cases WHERE id=%s", (cid,))
|
||||
cn.commit()
|
||||
return {"ok": True, "id": cid}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# AGENT F — Salesforce-Lite extra tabs (Članarine / Liječnički / Obrasci)
|
||||
# Added: 2026-05-05 by dradulic@outlook.com / damir@rinet.one
|
||||
# Tables: pgz_sport.clanarine, pgz_sport.lijecnicki_pregledi,
|
||||
# pgz_sport.form_templates, pgz_sport.form_submissions
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
import json as _json
|
||||
|
||||
CLANARINA_STATUSES = ['nepodmireno', 'djelomicno', 'podmireno', 'storno']
|
||||
SUBMISSION_STATUSES = ['draft', 'submitted', 'approved', 'rejected']
|
||||
|
||||
|
||||
# ─────────── ČLANARINE ───────────
|
||||
|
||||
class ClanarinaIn(BaseModel):
|
||||
clan_id: Optional[int] = None
|
||||
klub_id: Optional[int] = None
|
||||
godina: int
|
||||
razdoblje: Optional[str] = None
|
||||
iznos_propisan: float
|
||||
iznos_placen: Optional[float] = 0
|
||||
datum_uplate: Optional[str] = None
|
||||
nacin_uplate: Optional[str] = None
|
||||
referenca: Optional[str] = None
|
||||
racun_broj: Optional[str] = None
|
||||
status: str = 'nepodmireno'
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/clanarine")
|
||||
def list_clanarine(klub_id: Optional[int] = None, clan_id: Optional[int] = None,
|
||||
godina: Optional[int] = None, status: Optional[str] = None,
|
||||
limit: int = 500, user=Depends(_require_user)):
|
||||
where, args = ["1=1"], []
|
||||
if klub_id: where.append("c.klub_id=%s"); args.append(klub_id)
|
||||
if clan_id: where.append("c.clan_id=%s"); args.append(clan_id)
|
||||
if godina: where.append("c.godina=%s"); args.append(godina)
|
||||
if status: where.append("c.status=%s"); args.append(status)
|
||||
args.append(limit)
|
||||
sql = f"""
|
||||
SELECT c.*,
|
||||
(cl.ime||' '||cl.prezime) AS clan_naziv,
|
||||
k.naziv AS klub_naziv
|
||||
FROM pgz_sport.clanarine c
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY c.godina DESC, c.id DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(sql, args)
|
||||
items = _rows(cur.fetchall())
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.get("/clanarine/{cid}")
|
||||
def get_clanarina(cid: int, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT c.*,
|
||||
(cl.ime||' '||cl.prezime) AS clan_naziv,
|
||||
k.naziv AS klub_naziv
|
||||
FROM pgz_sport.clanarine c
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||
WHERE c.id=%s
|
||||
""", (cid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
return _row(row)
|
||||
|
||||
|
||||
@router.post("/clanarine")
|
||||
def create_clanarina(req: ClanarinaIn, user=Depends(_require_user)):
|
||||
if req.status not in CLANARINA_STATUSES:
|
||||
raise HTTPException(400, "invalid status")
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.clanarine
|
||||
(clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen,
|
||||
datum_uplate, nacin_uplate, referenca, racun_broj, status, napomena)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""", (req.clan_id, req.klub_id, req.godina, req.razdoblje,
|
||||
req.iznos_propisan, req.iznos_placen or 0,
|
||||
req.datum_uplate, req.nacin_uplate, req.referenca,
|
||||
req.racun_broj, req.status, req.napomena))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.put("/clanarine/{cid}")
|
||||
def update_clanarina(cid: int, req: ClanarinaIn, user=Depends(_require_user)):
|
||||
if req.status not in CLANARINA_STATUSES:
|
||||
raise HTTPException(400, "invalid status")
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM pgz_sport.clanarine WHERE id=%s", (cid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "not found")
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.clanarine
|
||||
SET clan_id=%s, klub_id=%s, godina=%s, razdoblje=%s,
|
||||
iznos_propisan=%s, iznos_placen=%s, datum_uplate=%s,
|
||||
nacin_uplate=%s, referenca=%s, racun_broj=%s,
|
||||
status=%s, napomena=%s, updated_at=now()
|
||||
WHERE id=%s
|
||||
RETURNING *
|
||||
""", (req.clan_id, req.klub_id, req.godina, req.razdoblje,
|
||||
req.iznos_propisan, req.iznos_placen or 0,
|
||||
req.datum_uplate, req.nacin_uplate, req.referenca,
|
||||
req.racun_broj, req.status, req.napomena, cid))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.delete("/clanarine/{cid}")
|
||||
def delete_clanarina(cid: int, user=Depends(_require_user)):
|
||||
if not _is_admin(user):
|
||||
raise HTTPException(403, "admin only")
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("DELETE FROM pgz_sport.clanarine WHERE id=%s", (cid,))
|
||||
cn.commit()
|
||||
return {"ok": True, "id": cid}
|
||||
|
||||
|
||||
# ─────────── LIJEČNIČKI PREGLEDI ───────────
|
||||
|
||||
class LijecnickiIn(BaseModel):
|
||||
clan_id: int
|
||||
klub_id: Optional[int] = None
|
||||
datum_pregleda: str
|
||||
vrijedi_do: Optional[str] = None
|
||||
vrsta_pregleda: Optional[str] = None
|
||||
ustanova: Optional[str] = None
|
||||
lijecnik: Optional[str] = None
|
||||
spreman_za_natjecanje: Optional[bool] = True
|
||||
ekg: Optional[bool] = False
|
||||
krv: Optional[bool] = False
|
||||
spirometrija: Optional[bool] = False
|
||||
nalaz: Optional[str] = None
|
||||
komentar_lijecnika: Optional[str] = None
|
||||
preporuke: Optional[str] = None
|
||||
iznos: Optional[float] = None
|
||||
iznos_zzjz: Optional[float] = 0
|
||||
iznos_klub: Optional[float] = 0
|
||||
iznos_clan: Optional[float] = 0
|
||||
datum_placanja: Optional[str] = None
|
||||
placeno: Optional[bool] = False
|
||||
racun_broj: Optional[str] = None
|
||||
nacin_placanja: Optional[str] = None
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/lijecnicki")
|
||||
def list_lijecnicki(klub_id: Optional[int] = None, clan_id: Optional[int] = None,
|
||||
expiring: Optional[bool] = None, limit: int = 500,
|
||||
user=Depends(_require_user)):
|
||||
"""expiring=true → vrijedi_do u idućih 30d ili manje (ili prošlo)."""
|
||||
where, args = ["1=1"], []
|
||||
if klub_id: where.append("l.klub_id=%s"); args.append(klub_id)
|
||||
if clan_id: where.append("l.clan_id=%s"); args.append(clan_id)
|
||||
if expiring:
|
||||
where.append("(l.vrijedi_do IS NULL OR l.vrijedi_do <= current_date + interval '30 days')")
|
||||
args.append(limit)
|
||||
sql = f"""
|
||||
SELECT l.*,
|
||||
(cl.ime||' '||cl.prezime) AS clan_naziv,
|
||||
k.naziv AS klub_naziv
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY l.datum_pregleda DESC, l.id DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(sql, args)
|
||||
items = _rows(cur.fetchall())
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.get("/lijecnicki/{lid}")
|
||||
def get_lijecnicki(lid: int, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT l.*,
|
||||
(cl.ime||' '||cl.prezime) AS clan_naziv,
|
||||
k.naziv AS klub_naziv
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
WHERE l.id=%s
|
||||
""", (lid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
return _row(row)
|
||||
|
||||
|
||||
@router.post("/lijecnicki")
|
||||
def create_lijecnicki(req: LijecnickiIn, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.lijecnicki_pregledi
|
||||
(clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda,
|
||||
ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija,
|
||||
nalaz, komentar_lijecnika, preporuke,
|
||||
iznos, iznos_zzjz, iznos_klub, iznos_clan,
|
||||
datum_placanja, placeno, racun_broj, nacin_placanja, napomena)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,
|
||||
%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""", (req.clan_id, req.klub_id, req.datum_pregleda, req.vrijedi_do,
|
||||
req.vrsta_pregleda, req.ustanova, req.lijecnik,
|
||||
req.spreman_za_natjecanje, req.ekg, req.krv, req.spirometrija,
|
||||
req.nalaz, req.komentar_lijecnika, req.preporuke,
|
||||
req.iznos, req.iznos_zzjz or 0, req.iznos_klub or 0, req.iznos_clan or 0,
|
||||
req.datum_placanja, req.placeno, req.racun_broj,
|
||||
req.nacin_placanja, req.napomena))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.put("/lijecnicki/{lid}")
|
||||
def update_lijecnicki(lid: int, req: LijecnickiIn, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "not found")
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.lijecnicki_pregledi
|
||||
SET clan_id=%s, klub_id=%s, datum_pregleda=%s, vrijedi_do=%s,
|
||||
vrsta_pregleda=%s, ustanova=%s, lijecnik=%s,
|
||||
spreman_za_natjecanje=%s, ekg=%s, krv=%s, spirometrija=%s,
|
||||
nalaz=%s, komentar_lijecnika=%s, preporuke=%s,
|
||||
iznos=%s, iznos_zzjz=%s, iznos_klub=%s, iznos_clan=%s,
|
||||
datum_placanja=%s, placeno=%s, racun_broj=%s,
|
||||
nacin_placanja=%s, napomena=%s, updated_at=now()
|
||||
WHERE id=%s
|
||||
RETURNING *
|
||||
""", (req.clan_id, req.klub_id, req.datum_pregleda, req.vrijedi_do,
|
||||
req.vrsta_pregleda, req.ustanova, req.lijecnik,
|
||||
req.spreman_za_natjecanje, req.ekg, req.krv, req.spirometrija,
|
||||
req.nalaz, req.komentar_lijecnika, req.preporuke,
|
||||
req.iznos, req.iznos_zzjz or 0, req.iznos_klub or 0, req.iznos_clan or 0,
|
||||
req.datum_placanja, req.placeno, req.racun_broj,
|
||||
req.nacin_placanja, req.napomena, lid))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.delete("/lijecnicki/{lid}")
|
||||
def delete_lijecnicki(lid: int, user=Depends(_require_user)):
|
||||
if not _is_admin(user):
|
||||
raise HTTPException(403, "admin only")
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,))
|
||||
cn.commit()
|
||||
return {"ok": True, "id": lid}
|
||||
|
||||
|
||||
# ─────────── OBRASCI (templates + submissions) ───────────
|
||||
|
||||
class SubmissionIn(BaseModel):
|
||||
template_id: Optional[int] = None
|
||||
template_code: Optional[str] = None
|
||||
klub_id: Optional[int] = None
|
||||
clan_id: Optional[int] = None
|
||||
data: Dict[str, Any] = Field(default_factory=dict)
|
||||
status: str = 'draft' # draft / submitted
|
||||
reference_no: Optional[str] = None
|
||||
|
||||
|
||||
class SubmissionStatusIn(BaseModel):
|
||||
status: str # submitted / approved / rejected
|
||||
rejected_reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/obrasci")
|
||||
def list_obrasci_templates(kategorija: Optional[str] = None,
|
||||
active: bool = True, user=Depends(_require_user)):
|
||||
where, args = [], []
|
||||
if active:
|
||||
where.append("active=true")
|
||||
if kategorija:
|
||||
where.append("kategorija=%s"); args.append(kategorija)
|
||||
sql = "SELECT id, code, naziv, kategorija, opis, schema_json, required_role, active FROM pgz_sport.form_templates"
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY kategorija NULLS LAST, naziv"
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(sql, args)
|
||||
items = _rows(cur.fetchall())
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.get("/obrasci/submission")
|
||||
def list_obrasci_submissions(status: Optional[str] = None,
|
||||
klub_id: Optional[int] = None,
|
||||
template_code: Optional[str] = None,
|
||||
clan_id: Optional[int] = None,
|
||||
limit: int = 500,
|
||||
user=Depends(_require_user)):
|
||||
where, args = ["1=1"], []
|
||||
if status: where.append("s.status=%s"); args.append(status)
|
||||
if klub_id: where.append("s.klub_id=%s"); args.append(klub_id)
|
||||
if template_code: where.append("s.template_code=%s"); args.append(template_code)
|
||||
if clan_id: where.append("s.clan_id=%s"); args.append(clan_id)
|
||||
args.append(limit)
|
||||
sql = f"""
|
||||
SELECT s.id, s.template_id, s.template_code, s.klub_id, s.clan_id,
|
||||
s.user_id, s.data, s.status, s.reference_no,
|
||||
s.submitted_at, s.reviewed_at, s.approved_at, s.rejected_reason,
|
||||
s.created_at, s.updated_at,
|
||||
t.naziv AS template_naziv, t.kategorija,
|
||||
k.naziv AS klub_naziv,
|
||||
(cl.ime||' '||cl.prezime) AS clan_naziv,
|
||||
u.email AS submitter_email
|
||||
FROM pgz_sport.form_submissions s
|
||||
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
|
||||
LEFT JOIN pgz_sport.users u ON u.id = s.user_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(sql, args)
|
||||
items = _rows(cur.fetchall())
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.get("/obrasci/submission/{sid}")
|
||||
def get_obrazac_submission(sid: int, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT s.*,
|
||||
t.naziv AS template_naziv, t.kategorija, t.schema_json,
|
||||
k.naziv AS klub_naziv,
|
||||
(cl.ime||' '||cl.prezime) AS clan_naziv,
|
||||
u.email AS submitter_email
|
||||
FROM pgz_sport.form_submissions s
|
||||
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
|
||||
LEFT JOIN pgz_sport.users u ON u.id = s.user_id
|
||||
WHERE s.id=%s
|
||||
""", (sid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
return _row(row)
|
||||
|
||||
|
||||
@router.post("/obrasci/submission")
|
||||
def create_obrazac_submission(req: SubmissionIn, user=Depends(_require_user)):
|
||||
if req.status not in ('draft', 'submitted'):
|
||||
raise HTTPException(400, "invalid status (draft|submitted)")
|
||||
tid = req.template_id
|
||||
tcode = req.template_code
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
if tid and not tcode:
|
||||
cur.execute("SELECT code FROM pgz_sport.form_templates WHERE id=%s", (tid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(400, "unknown template_id")
|
||||
tcode = r["code"]
|
||||
elif tcode and not tid:
|
||||
cur.execute("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (tcode,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(400, "unknown template_code")
|
||||
tid = r["id"]
|
||||
elif not tid and not tcode:
|
||||
raise HTTPException(400, "template_id or template_code required")
|
||||
|
||||
submitted_at = "now()" if req.status == 'submitted' else "NULL"
|
||||
cur.execute(f"""
|
||||
INSERT INTO pgz_sport.form_submissions
|
||||
(template_id, template_code, klub_id, user_id, clan_id,
|
||||
data, status, reference_no, submitted_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s,{submitted_at})
|
||||
RETURNING *
|
||||
""", (tid, tcode, req.klub_id, user["user_id"], req.clan_id,
|
||||
_json.dumps(req.data or {}), req.status, req.reference_no))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.put("/obrasci/submission/{sid}")
|
||||
def update_obrazac_submission(sid: int, req: SubmissionIn, user=Depends(_require_user)):
|
||||
"""Submitter can update their own draft."""
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
if not _is_admin(user) and row["user_id"] != user["user_id"]:
|
||||
raise HTTPException(403, "not your submission")
|
||||
if row["status"] not in ('draft', 'submitted') and not _is_admin(user):
|
||||
raise HTTPException(400, "submission already finalized")
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET klub_id=%s, clan_id=%s, data=%s::jsonb,
|
||||
status=%s, reference_no=%s,
|
||||
submitted_at = CASE WHEN %s='submitted' AND submitted_at IS NULL THEN now() ELSE submitted_at END,
|
||||
updated_at=now()
|
||||
WHERE id=%s
|
||||
RETURNING *
|
||||
""", (req.klub_id, req.clan_id, _json.dumps(req.data or {}),
|
||||
req.status, req.reference_no, req.status, sid))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.put("/obrasci/submission/{sid}/status")
|
||||
def set_submission_status(sid: int, req: SubmissionStatusIn, user=Depends(_require_user)):
|
||||
"""Approve/reject (admin) or self-submit a draft (owner)."""
|
||||
if req.status not in SUBMISSION_STATUSES:
|
||||
raise HTTPException(400, "invalid status")
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
|
||||
if req.status in ('approved', 'rejected'):
|
||||
if not _is_admin(user):
|
||||
raise HTTPException(403, "admin only")
|
||||
elif req.status == 'submitted':
|
||||
if row["user_id"] != user["user_id"] and not _is_admin(user):
|
||||
raise HTTPException(403, "not your submission")
|
||||
|
||||
if req.status == 'approved':
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='approved', approved_by=%s, approved_at=now(),
|
||||
reviewed_by=%s, reviewed_at=now(), updated_at=now()
|
||||
WHERE id=%s RETURNING *
|
||||
""", (user["user_id"], user["user_id"], sid))
|
||||
elif req.status == 'rejected':
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='rejected', rejected_reason=%s,
|
||||
reviewed_by=%s, reviewed_at=now(), updated_at=now()
|
||||
WHERE id=%s RETURNING *
|
||||
""", (req.rejected_reason, user["user_id"], sid))
|
||||
elif req.status == 'submitted':
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='submitted',
|
||||
submitted_at = COALESCE(submitted_at, now()),
|
||||
updated_at=now()
|
||||
WHERE id=%s RETURNING *
|
||||
""", (sid,))
|
||||
else: # draft
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='draft', updated_at=now()
|
||||
WHERE id=%s RETURNING *
|
||||
""", (sid,))
|
||||
cn.commit()
|
||||
return _row(cur.fetchone())
|
||||
|
||||
|
||||
@router.delete("/obrasci/submission/{sid}")
|
||||
def delete_obrazac_submission(sid: int, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
if not _is_admin(user) and row["user_id"] != user["user_id"]:
|
||||
raise HTTPException(403, "not your submission")
|
||||
cur.execute("DELETE FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
cn.commit()
|
||||
return {"ok": True, "id": sid}
|
||||
|
||||
|
||||
@router.get("/obrasci/{tid}")
|
||||
def get_obrazac_template(tid: int, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (tid,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
return _row(row)
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: routers/erp_full_router.py | v1.0.0 | 05.05.2026
|
||||
# Fajl: routers/erp_full_router.py | v1.1.0 | 05.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/erp_full_router.py
|
||||
# Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga,
|
||||
# partneri, ulazni/izlazni računi (+ FINA e-Račun XML import),
|
||||
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export.
|
||||
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export,
|
||||
# invoice_uploads (OCR), expense_reports (Putni nalozi), payments.
|
||||
# v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E).
|
||||
# Mount: /api/v2/erp/*
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ── Upload destination (relative to web root /uploads/...) ──────────
|
||||
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
|
||||
INVOICE_UPLOAD_DIR = UPLOAD_BASE / "invoices"
|
||||
INVOICE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
router = APIRouter(prefix="/api/v2/erp", tags=["erp_full"])
|
||||
|
||||
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
@@ -1172,6 +1183,43 @@ def racuni_ulazni_uploads(rid: int):
|
||||
return {"count": len(rows), "rows": rows}
|
||||
|
||||
|
||||
# ── Upload new invoice file (multipart) ─────────────────────────────
|
||||
@router.post("/invoice-uploads")
|
||||
async def invoice_uploads_create(
|
||||
file: UploadFile = File(...),
|
||||
klub_id: Optional[int] = Form(None),
|
||||
invoice_id: Optional[int] = Form(None),
|
||||
):
|
||||
"""Accepts PDF/JPG/PNG of an invoice. Stores file under /uploads/invoices/
|
||||
and inserts a row into invoice_uploads with ocr_status='pending'.
|
||||
Returns the new id; OCR/AI extraction runs separately."""
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(400, "Empty file")
|
||||
if len(raw) > 25 * 1024 * 1024:
|
||||
raise HTTPException(413, "File > 25 MB")
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", file.filename or "upload.bin")[:120]
|
||||
sha = hashlib.sha256(raw).hexdigest()
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
rel = f"invoices/{ts}_{sha[:10]}_{safe}"
|
||||
abs_path = UPLOAD_BASE / rel
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(abs_path, "wb") as f:
|
||||
f.write(raw)
|
||||
new_id = db_exec(
|
||||
"INSERT INTO pgz_sport.invoice_uploads "
|
||||
"(klub_id, file_name, file_path, file_size, mime, sha256, "
|
||||
" ocr_status, invoice_id, uploaded_at) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,'pending',%s, now()) RETURNING id",
|
||||
(klub_id, file.filename, rel, len(raw),
|
||||
file.content_type or "application/octet-stream",
|
||||
sha, invoice_id),
|
||||
returning=True,
|
||||
)
|
||||
return {"ok": True, "id": new_id, "file_path": rel,
|
||||
"file_size": len(raw), "sha256": sha}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 12) PUTNI NALOZI / EXPENSE REPORTS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
# ===================================================================
|
||||
# Fajl: routers/kalendar_router.py | v1.0.0 | 05.05.2026
|
||||
# Autor: Damir Radulic <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/kalendar_router.py
|
||||
# Svrha: Kalendar CRUD (eventi/manifestacije/sastanci/termini) za /app#kalendar.
|
||||
# Endpoints under /api/v2/kalendar:
|
||||
# GET /events?from=&to=&klub_id=&savez_id=
|
||||
# POST /events
|
||||
# GET /events/{id}
|
||||
# PUT /events/{id}
|
||||
# DELETE /events/{id}
|
||||
# ===================================================================
|
||||
"""Kalendar v2 router.
|
||||
|
||||
Prefix: /api/v2/kalendar
|
||||
Backed by table pgz_sport.kalendar_events.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import date, datetime
|
||||
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/kalendar", tags=["kalendar-v2"])
|
||||
|
||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||
|
||||
ALLOWED_TYPES = {"event", "meeting", "manif", "training", "medical", "other"}
|
||||
ALLOWED_COLORS = {"a", "b", "g", "r"}
|
||||
|
||||
|
||||
# --------- DB helpers ---------
|
||||
|
||||
def _conn():
|
||||
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
||||
|
||||
|
||||
def _conv(v):
|
||||
if isinstance(v, (date, datetime)):
|
||||
return v.isoformat()
|
||||
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 ---------
|
||||
# Supports both auth styles found in this codebase:
|
||||
# 1) JWT (auth.auth_v2): token decoded, jti checked in user_sessions
|
||||
# 2) Legacy opaque token: sha256(token) lookup in user_sessions
|
||||
# Tries JWT first (canonical), falls back to legacy.
|
||||
|
||||
try:
|
||||
from auth.auth_v2 import get_current_user as _jwt_current_user # type: ignore
|
||||
except Exception:
|
||||
_jwt_current_user = None # type: ignore
|
||||
|
||||
|
||||
def _current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict[str, Any]]:
|
||||
# Prefer JWT path (auth_v2)
|
||||
if _jwt_current_user is not None:
|
||||
try:
|
||||
u = _jwt_current_user(authorization)
|
||||
if u:
|
||||
# Normalize key name used downstream
|
||||
if "user_id" not in u and "id" in u:
|
||||
u = dict(u)
|
||||
u["user_id"] = u.get("id")
|
||||
return u
|
||||
except Exception:
|
||||
pass
|
||||
# Legacy opaque-token fallback
|
||||
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 _is_admin(user: Dict) -> bool:
|
||||
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
|
||||
|
||||
|
||||
def _require_user_optional(user=Depends(_current_user)):
|
||||
"""Auth optional for GET (so /app dashboard works even pre-login).
|
||||
Returns user dict or None."""
|
||||
return user
|
||||
|
||||
|
||||
def _require_user(user=Depends(_current_user)):
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# --------- Models ---------
|
||||
|
||||
class EventIn(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=400)
|
||||
start_at: str # ISO timestamp
|
||||
end_at: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
event_type: Optional[str] = "event"
|
||||
color: Optional[str] = "b"
|
||||
klub_id: Optional[int] = None
|
||||
savez_id: Optional[int] = None
|
||||
|
||||
|
||||
class EventPatch(BaseModel):
|
||||
title: Optional[str] = None
|
||||
start_at: Optional[str] = None
|
||||
end_at: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
event_type: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
klub_id: Optional[int] = None
|
||||
savez_id: Optional[int] = None
|
||||
|
||||
|
||||
def _validate_payload(p: Dict[str, Any]) -> None:
|
||||
et = p.get("event_type")
|
||||
if et is not None and et not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, f"event_type must be one of {sorted(ALLOWED_TYPES)}")
|
||||
co = p.get("color")
|
||||
if co is not None and co not in ALLOWED_COLORS:
|
||||
raise HTTPException(400, f"color must be one of {sorted(ALLOWED_COLORS)}")
|
||||
|
||||
|
||||
# --------- Endpoints ---------
|
||||
|
||||
@router.get("/events")
|
||||
def list_events(
|
||||
from_: Optional[str] = Query(None, alias="from"),
|
||||
to: Optional[str] = None,
|
||||
klub_id: Optional[int] = None,
|
||||
savez_id: Optional[int] = None,
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
user=Depends(_require_user_optional),
|
||||
):
|
||||
"""List events in [from, to). Defaults: no bound."""
|
||||
where = ["1=1"]
|
||||
params: List[Any] = []
|
||||
if from_:
|
||||
where.append("start_at >= %s")
|
||||
params.append(from_)
|
||||
if to:
|
||||
where.append("start_at < %s")
|
||||
params.append(to)
|
||||
if klub_id is not None:
|
||||
where.append("klub_id = %s")
|
||||
params.append(klub_id)
|
||||
if savez_id is not None:
|
||||
where.append("savez_id = %s")
|
||||
params.append(savez_id)
|
||||
sql = (
|
||||
"SELECT id, title, start_at, end_at, location, description, event_type, color, "
|
||||
"klub_id, savez_id, created_by, created_at, updated_at "
|
||||
"FROM pgz_sport.kalendar_events "
|
||||
"WHERE " + " AND ".join(where) + " "
|
||||
"ORDER BY start_at ASC LIMIT %s"
|
||||
)
|
||||
params.append(limit)
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = _rows(cur.fetchall())
|
||||
return {"rows": rows, "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/events/{eid}")
|
||||
def get_event(eid: int, user=Depends(_require_user_optional)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM pgz_sport.kalendar_events WHERE id=%s",
|
||||
(eid,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Event not found")
|
||||
return _row(r)
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
def create_event(payload: EventIn, user=Depends(_require_user)):
|
||||
p = payload.dict()
|
||||
_validate_payload(p)
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pgz_sport.kalendar_events
|
||||
(title, start_at, end_at, location, description, event_type, color,
|
||||
klub_id, savez_id, created_by)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id, title, start_at, end_at, location, description, event_type, color,
|
||||
klub_id, savez_id, created_by, created_at, updated_at
|
||||
""",
|
||||
(
|
||||
p["title"],
|
||||
p["start_at"],
|
||||
p.get("end_at"),
|
||||
p.get("location"),
|
||||
p.get("description"),
|
||||
p.get("event_type") or "event",
|
||||
p.get("color") or "b",
|
||||
p.get("klub_id"),
|
||||
p.get("savez_id"),
|
||||
user.get("user_id"),
|
||||
),
|
||||
)
|
||||
new_row = cur.fetchone()
|
||||
cn.commit()
|
||||
return _row(new_row)
|
||||
|
||||
|
||||
@router.put("/events/{eid}")
|
||||
def update_event(eid: int, payload: EventPatch, user=Depends(_require_user)):
|
||||
p = {k: v for k, v in payload.dict().items() if v is not None}
|
||||
if not p:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
_validate_payload(p)
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
# Permission: admins can edit anything, others only their own
|
||||
cur.execute(
|
||||
"SELECT created_by FROM pgz_sport.kalendar_events WHERE id=%s",
|
||||
(eid,),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Event not found")
|
||||
if not _is_admin(user) and existing["created_by"] not in (user.get("user_id"), None):
|
||||
raise HTTPException(403, "Insufficient privileges")
|
||||
sets = ", ".join(f"{k}=%s" for k in p.keys())
|
||||
params = list(p.values()) + [eid]
|
||||
cur.execute(
|
||||
f"UPDATE pgz_sport.kalendar_events SET {sets} WHERE id=%s "
|
||||
f"RETURNING id, title, start_at, end_at, location, description, event_type, color, "
|
||||
f"klub_id, savez_id, created_by, created_at, updated_at",
|
||||
params,
|
||||
)
|
||||
out = cur.fetchone()
|
||||
cn.commit()
|
||||
return _row(out)
|
||||
|
||||
|
||||
@router.delete("/events/{eid}")
|
||||
def delete_event(eid: int, user=Depends(_require_user)):
|
||||
with _conn() as cn, cn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT created_by FROM pgz_sport.kalendar_events WHERE id=%s",
|
||||
(eid,),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Event not found")
|
||||
if not _is_admin(user) and existing["created_by"] not in (user.get("user_id"), None):
|
||||
raise HTTPException(403, "Insufficient privileges")
|
||||
cur.execute("DELETE FROM pgz_sport.kalendar_events WHERE id=%s", (eid,))
|
||||
cn.commit()
|
||||
return {"deleted": eid}
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# notif_router.py — Notification center API for /app#notif
|
||||
# Author : Damir Radulić — dradulic@outlook.com / damir@rinet.one
|
||||
# Version: 1.0.0
|
||||
# Date : 2026-05-05
|
||||
# Purpose: REST endpoints under /api/v2/notif/* powering the in-app
|
||||
# notification center. Operates on pgz_sport.notifications table
|
||||
# (extended by migrations/notifications_20260505.sql).
|
||||
# Routes : GET /api/v2/notif/list?unread_only=&limit=&user_id=
|
||||
# GET /api/v2/notif/count?user_id=
|
||||
# POST /api/v2/notif/{id}/read
|
||||
# POST /api/v2/notif/mark-all-read
|
||||
# DELETE /api/v2/notif/{id}
|
||||
# ==============================================================================
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
# Reuse the v2 DB config (same as pgz_sport_v2_router)
|
||||
DB = dict(
|
||||
host="10.10.0.2",
|
||||
port=6432,
|
||||
dbname="rinet_v3",
|
||||
user="rinet",
|
||||
password="R1net2026!SecureDB#v7",
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v2/notif", tags=["pgz_sport_notif"])
|
||||
|
||||
|
||||
# ----------------------------- helpers ---------------------------------------
|
||||
def _conn():
|
||||
return psycopg2.connect(**DB)
|
||||
|
||||
|
||||
def _row(r) -> Dict[str, Any]:
|
||||
if r is None:
|
||||
return {}
|
||||
if isinstance(r, dict):
|
||||
d = dict(r)
|
||||
else:
|
||||
d = dict(r)
|
||||
# JSON-friendly conversions
|
||||
for k, v in list(d.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
d[k] = v.isoformat()
|
||||
return d
|
||||
|
||||
|
||||
def _resolve_user_id(authorization: Optional[str]) -> Optional[int]:
|
||||
"""Best-effort: pull user_id from Bearer token. Tries JWT (auth_v2) first
|
||||
then falls back to legacy session-token-hash lookup."""
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
if not token:
|
||||
return None
|
||||
# 1) Try JWT (preferred — current auth)
|
||||
try:
|
||||
from auth.auth_v2 import decode_token # type: ignore
|
||||
payload = decode_token(token)
|
||||
sub = payload.get("sub")
|
||||
if sub is not None:
|
||||
try:
|
||||
return int(sub)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Legacy: SHA-256 hash lookup in user_sessions
|
||||
import hashlib
|
||||
th = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
with _conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"""SELECT user_id FROM pgz_sport.user_sessions
|
||||
WHERE token_hash=%s AND revoked=false AND expires_at>now()
|
||||
LIMIT 1""",
|
||||
(th,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
return r[0] if r else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ----------------------------- LIST ------------------------------------------
|
||||
@router.get("/list")
|
||||
def notif_list(
|
||||
unread_only: bool = Query(False),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
user_id: Optional[int] = Query(None, description="Override (admin); else taken from JWT"),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
"""
|
||||
Returns InApp notifications visible to the user. System-wide (user_id IS NULL)
|
||||
rows are always included. If no auth + no user_id, returns only system-wide.
|
||||
"""
|
||||
uid = user_id if user_id is not None else _resolve_user_id(authorization)
|
||||
|
||||
where = ["channel = 'inapp'"]
|
||||
params: List[Any] = []
|
||||
if uid is not None:
|
||||
where.append("(user_id = %s OR user_id IS NULL)")
|
||||
params.append(uid)
|
||||
else:
|
||||
where.append("user_id IS NULL")
|
||||
|
||||
if unread_only:
|
||||
where.append("COALESCE(is_read, false) = false")
|
||||
|
||||
sql = f"""
|
||||
SELECT id, user_id, kind, title, COALESCE(subject, title) AS subject, body,
|
||||
link, COALESCE(is_read, read_at IS NOT NULL) AS is_read,
|
||||
status, created_at, read_at
|
||||
FROM pgz_sport.notifications
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY created_at DESC NULLS LAST, id DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
params.append(limit)
|
||||
|
||||
with _conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
return {"count": len(rows), "rows": rows, "user_id": uid}
|
||||
|
||||
|
||||
# ----------------------------- COUNT -----------------------------------------
|
||||
@router.get("/count")
|
||||
def notif_count(
|
||||
user_id: Optional[int] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
uid = user_id if user_id is not None else _resolve_user_id(authorization)
|
||||
|
||||
where = ["channel = 'inapp'"]
|
||||
params: List[Any] = []
|
||||
if uid is not None:
|
||||
where.append("(user_id = %s OR user_id IS NULL)")
|
||||
params.append(uid)
|
||||
else:
|
||||
where.append("user_id IS NULL")
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE COALESCE(is_read, read_at IS NOT NULL) = false) AS unread,
|
||||
COUNT(*) AS total
|
||||
FROM pgz_sport.notifications
|
||||
WHERE {' AND '.join(where)}
|
||||
"""
|
||||
with _conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql, params)
|
||||
r = cur.fetchone() or {"unread": 0, "total": 0}
|
||||
return {
|
||||
"unread": int(r["unread"] or 0),
|
||||
"total": int(r["total"] or 0),
|
||||
"user_id": uid,
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------- MARK ONE READ ---------------------------------
|
||||
@router.post("/{nid}/read")
|
||||
def notif_mark_read(nid: int):
|
||||
with _conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""UPDATE pgz_sport.notifications
|
||||
SET is_read = true,
|
||||
read_at = COALESCE(read_at, now()),
|
||||
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
|
||||
WHERE id = %s
|
||||
RETURNING id""",
|
||||
(nid,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Notifikacija ne postoji")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": nid, "is_read": True}
|
||||
|
||||
|
||||
# ----------------------------- MARK ALL READ ---------------------------------
|
||||
class MarkAllIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
include_system: bool = True
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
def notif_mark_all_read(
|
||||
body: Optional[MarkAllIn] = None,
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
body = body or MarkAllIn()
|
||||
uid = body.user_id if body.user_id is not None else _resolve_user_id(authorization)
|
||||
|
||||
where = ["channel = 'inapp'", "COALESCE(is_read, false) = false"]
|
||||
params: List[Any] = []
|
||||
if uid is not None:
|
||||
if body.include_system:
|
||||
where.append("(user_id = %s OR user_id IS NULL)")
|
||||
else:
|
||||
where.append("user_id = %s")
|
||||
params.append(uid)
|
||||
else:
|
||||
where.append("user_id IS NULL")
|
||||
|
||||
with _conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
f"""UPDATE pgz_sport.notifications
|
||||
SET is_read = true,
|
||||
read_at = COALESCE(read_at, now()),
|
||||
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
|
||||
WHERE {' AND '.join(where)}
|
||||
RETURNING id""",
|
||||
params,
|
||||
)
|
||||
ids = [r[0] for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
return {"ok": True, "marked_read": len(ids), "ids": ids[:500], "user_id": uid}
|
||||
|
||||
|
||||
# ----------------------------- DELETE ----------------------------------------
|
||||
@router.delete("/{nid}")
|
||||
def notif_delete(nid: int):
|
||||
with _conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM pgz_sport.notifications WHERE id = %s RETURNING id",
|
||||
(nid,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Notifikacija ne postoji")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": nid, "deleted": True}
|
||||
Reference in New Issue
Block a user