CC2 R3 frontend: login.html + admin_users.html (M1+M2+M10 UI)
- static/login.html: dark Palantir-style login with PGŽ branding,
Prijava se / Zaboravljena lozinka, demo account quick-fills,
GDPR cookie banner, autostore tokens (local/session)
- static/admin_users.html: full user-management admin panel:
- Collapsible left sidebar (Pregled, Korisnici, Tenanti, Audit log,
Sigurnost, GDPR, links to ERP/CRM)
- Users table with filters (q, tenant, role, status, limit)
- + Dodaj korisnika modal (CRUD via /api/admin/users/*)
- Suspend / unsuspend / reset-password / delete actions
- Audit log viewer + Security KPIs + GDPR queue
- Self-service: change pwd, export data (Art. 20), erasure request (Art. 17)
- pgz_sport_api.py: /login and /admin/users URL routes
- auth/seed_demo.py: added tajnik@atletski.pgz.hr/Atl2026!,
admin@ak-kvarner.hr/Kvarner2026! demo users
5/5 live tests pass: login JWT, /me, /admin/users, /gdpr/consent, /gdpr/export
Note: existing admin.html (CC4 ERP/OCR work) preserved intact;
admin_users.html is dedicated user-mgmt page linked from sidebar.
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/lijecnicki_router.py
|
||||
# Svrha: M8 — CRM Liječnički pregledi + ZZJZ PGŽ scheduling integracija
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
"""M8 Liječnički router.
|
||||
|
||||
Endpointi (montirani na /api/crm):
|
||||
GET /lijecnicki → lista (filteri)
|
||||
POST /lijecnicki → novi pregled
|
||||
GET /lijecnicki/{id} → detalji
|
||||
PUT /lijecnicki/{id} → update
|
||||
DELETE /lijecnicki/{id} → brisanje
|
||||
GET /lijecnicki/uskoro-isticu → istekao + idućih 30 dana
|
||||
POST /lijecnicki/{id}/zakazi → zakaži termin (ZZJZ PGŽ mock)
|
||||
GET /zzjz/termini → dostupni termini ZZJZ PGŽ (mock + scrape stub)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/crm", tags=["crm-lijecnicki"])
|
||||
|
||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||
|
||||
ZZJZ_BASE = "https://zzjzpgz.hr"
|
||||
ZZJZ_INFO = {
|
||||
"naziv": "Nastavni zavod za javno zdravstvo PGŽ",
|
||||
"adresa": "Krešimirova 52a, 51000 Rijeka",
|
||||
"telefon": "+385 51 358 770",
|
||||
"email": "info@zzjzpgz.hr",
|
||||
"web": ZZJZ_BASE,
|
||||
# Najbliži postojeći odjel — sportski liječnički ide preko adolescentne medicine
|
||||
"url_sportska_medicina": f"{ZZJZ_BASE}/zavod/odjeli/odjel-za-skolsku-i-adolescentnu-medicinu/",
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
return {k: _conv(v) for k, v in dict(d).items()}
|
||||
|
||||
|
||||
# ───────────── modeli ─────────────
|
||||
|
||||
class LijecnickiIn(BaseModel):
|
||||
clan_id: int
|
||||
klub_id: Optional[int] = None
|
||||
datum_pregleda: date
|
||||
vrijedi_do: Optional[date] = None
|
||||
vrsta_pregleda: Optional[str] = "temeljni"
|
||||
ustanova: Optional[str] = "ZZJZ PGŽ"
|
||||
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] = 0
|
||||
iznos_zzjz: Optional[float] = 0
|
||||
iznos_klub: Optional[float] = 0
|
||||
iznos_clan: Optional[float] = 0
|
||||
datum_placanja: Optional[date] = None
|
||||
placeno: Optional[bool] = False
|
||||
racun_broj: Optional[str] = None
|
||||
nacin_placanja: Optional[str] = None
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
class LijecnickiPatch(BaseModel):
|
||||
klub_id: Optional[int] = None
|
||||
datum_pregleda: Optional[date] = None
|
||||
vrijedi_do: Optional[date] = None
|
||||
vrsta_pregleda: Optional[str] = None
|
||||
ustanova: Optional[str] = None
|
||||
lijecnik: Optional[str] = None
|
||||
spreman_za_natjecanje: Optional[bool] = None
|
||||
ekg: Optional[bool] = None
|
||||
krv: Optional[bool] = None
|
||||
spirometrija: Optional[bool] = None
|
||||
nalaz: Optional[str] = None
|
||||
komentar_lijecnika: Optional[str] = None
|
||||
preporuke: Optional[str] = None
|
||||
iznos: Optional[float] = None
|
||||
iznos_zzjz: Optional[float] = None
|
||||
iznos_klub: Optional[float] = None
|
||||
iznos_clan: Optional[float] = None
|
||||
datum_placanja: Optional[date] = None
|
||||
placeno: Optional[bool] = None
|
||||
racun_broj: Optional[str] = None
|
||||
nacin_placanja: Optional[str] = None
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
class ZakaziIn(BaseModel):
|
||||
datum: date
|
||||
vrijeme: Optional[str] = "09:00"
|
||||
ustanova: Optional[str] = "ZZJZ PGŽ"
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
# ───────────── lista ─────────────
|
||||
|
||||
@router.get("/lijecnicki")
|
||||
def list_lijecnicki(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
clan_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None,
|
||||
description="vazeci|uskoro|istekao"),
|
||||
placeno: Optional[bool] = Query(None),
|
||||
sort: str = Query("vrijedi_do"),
|
||||
order: str = Query("asc"),
|
||||
limit: int = Query(500, le=2000),
|
||||
):
|
||||
where, params = [], []
|
||||
if klub_id:
|
||||
where.append("l.klub_id = %s"); params.append(klub_id)
|
||||
if clan_id:
|
||||
where.append("l.clan_id = %s"); params.append(clan_id)
|
||||
if placeno is not None:
|
||||
where.append("l.placeno = %s"); params.append(placeno)
|
||||
# status_calc: vazeci = >30d, uskoro = 0..30d, istekao = <0
|
||||
if status == "vazeci":
|
||||
where.append("l.vrijedi_do > (CURRENT_DATE + INTERVAL '30 days')")
|
||||
elif status == "uskoro":
|
||||
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '30 days')")
|
||||
elif status == "istekao":
|
||||
where.append("l.vrijedi_do < CURRENT_DATE")
|
||||
|
||||
sort_map = {
|
||||
"vrijedi_do": "l.vrijedi_do",
|
||||
"datum_pregleda": "l.datum_pregleda",
|
||||
"klub": "k.naziv",
|
||||
"clan": "cl.prezime",
|
||||
"iznos": "l.iznos",
|
||||
}
|
||||
sort_col = sort_map.get(sort, "l.vrijedi_do")
|
||||
order_sql = "DESC" if order.lower() == "desc" else "ASC"
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
params.append(limit)
|
||||
|
||||
sql = f"""
|
||||
SELECT l.*,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.oib AS clan_oib, cl.email AS clan_email,
|
||||
k.naziv AS klub, k.oib AS klub_oib,
|
||||
CASE
|
||||
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
|
||||
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
|
||||
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
|
||||
ELSE 'vazeci'
|
||||
END AS status_calc,
|
||||
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
|
||||
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_sql}
|
||||
ORDER BY {sort_col} {order_sql} NULLS LAST
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
sum_sql = f"""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE l.vrijedi_do < CURRENT_DATE) AS istekli,
|
||||
COUNT(*) FILTER (WHERE l.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
|
||||
COUNT(*) FILTER (WHERE l.vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
|
||||
COUNT(*) FILTER (WHERE l.placeno IS TRUE) AS placeni,
|
||||
COALESCE(SUM(l.iznos), 0)::numeric(10,2) AS total_iznos
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
{where_sql}
|
||||
"""
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
cur.execute(sum_sql, params[:-1])
|
||||
summary = _row(cur.fetchone() or {})
|
||||
|
||||
return {"count": len(rows), "rows": rows, "summary": summary}
|
||||
|
||||
|
||||
# ───────────── uskoro isticu (30 dana + istekli) ─────────────
|
||||
|
||||
@router.get("/lijecnicki/uskoro-isticu")
|
||||
def list_uskoro_isticu(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
days: int = Query(30, ge=1, le=180),
|
||||
include_expired: bool = Query(True),
|
||||
):
|
||||
where = ["l.vrijedi_do IS NOT NULL"]
|
||||
params: list = []
|
||||
if include_expired:
|
||||
where.append("l.vrijedi_do <= (CURRENT_DATE + (%s || ' days')::interval)")
|
||||
else:
|
||||
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + (%s || ' days')::interval)")
|
||||
params.append(str(days))
|
||||
if klub_id:
|
||||
where.append("l.klub_id = %s"); params.append(klub_id)
|
||||
|
||||
sql = f"""
|
||||
SELECT l.id, l.clan_id, l.klub_id, l.datum_pregleda, l.vrijedi_do,
|
||||
l.vrsta_pregleda, l.ustanova, l.lijecnik, l.placeno,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.email AS clan_email, cl.telefon AS clan_telefon,
|
||||
k.naziv AS klub, k.oib AS klub_oib,
|
||||
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
|
||||
CASE
|
||||
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
|
||||
ELSE 'uskoro'
|
||||
END AS status_calc
|
||||
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.vrijedi_do ASC
|
||||
"""
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
n_istekli = sum(1 for r in rows if (r.get("dana_do_isteka") or 0) < 0)
|
||||
n_uskoro = len(rows) - n_istekli
|
||||
return {"count": len(rows), "istekli": n_istekli, "uskoro": n_uskoro,
|
||||
"days_window": days, "rows": rows}
|
||||
|
||||
|
||||
# ───────────── detalji ─────────────
|
||||
|
||||
@router.get("/lijecnicki/{lid}")
|
||||
def get_lijecnicki(lid: int):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT l.*,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.oib AS clan_oib, cl.email AS clan_email,
|
||||
cl.telefon AS clan_telefon,
|
||||
k.naziv AS klub, k.oib AS klub_oib,
|
||||
CASE
|
||||
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
|
||||
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
|
||||
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
|
||||
ELSE 'vazeci'
|
||||
END AS status_calc,
|
||||
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
|
||||
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,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
return _row(r)
|
||||
|
||||
|
||||
# ───────────── kreiraj ─────────────
|
||||
|
||||
@router.post("/lijecnicki")
|
||||
def create_lijecnicki(body: LijecnickiIn):
|
||||
klub_id = body.klub_id
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
if not klub_id:
|
||||
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
|
||||
r = cur.fetchone()
|
||||
klub_id = r["klub_id"] if r else None
|
||||
# default vrijedi_do = +1 godina ako nije postavljeno
|
||||
vrijedi_do = body.vrijedi_do
|
||||
if vrijedi_do is None:
|
||||
vrijedi_do = body.datum_pregleda + timedelta(days=365)
|
||||
|
||||
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 *
|
||||
""", (body.clan_id, klub_id, body.datum_pregleda, vrijedi_do,
|
||||
body.vrsta_pregleda, body.ustanova, body.lijecnik,
|
||||
body.spreman_za_natjecanje, body.ekg, body.krv, body.spirometrija,
|
||||
body.nalaz, body.komentar_lijecnika, body.preporuke,
|
||||
body.iznos, body.iznos_zzjz, body.iznos_klub, body.iznos_clan,
|
||||
body.datum_placanja, body.placeno, body.racun_broj,
|
||||
body.nacin_placanja, body.napomena))
|
||||
r = cur.fetchone()
|
||||
conn.commit()
|
||||
return _row(r)
|
||||
|
||||
|
||||
# ───────────── update / delete ─────────────
|
||||
|
||||
@router.put("/lijecnicki/{lid}")
|
||||
def update_lijecnicki(lid: int, patch: LijecnickiPatch):
|
||||
fields, params = [], []
|
||||
for f in ("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"):
|
||||
v = getattr(patch, f)
|
||||
if v is not None:
|
||||
fields.append(f"{f} = %s"); params.append(v)
|
||||
if not fields:
|
||||
raise HTTPException(400, "Nema polja za izmjenu")
|
||||
fields.append("updated_at = now()")
|
||||
params.append(lid)
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"UPDATE pgz_sport.lijecnicki_pregledi SET {', '.join(fields)} WHERE id=%s RETURNING *",
|
||||
params)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
conn.commit()
|
||||
return _row(r)
|
||||
|
||||
|
||||
@router.delete("/lijecnicki/{lid}")
|
||||
def delete_lijecnicki(lid: int):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s RETURNING id", (lid,))
|
||||
r = cur.fetchone()
|
||||
conn.commit()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
return {"ok": True, "id": lid, "deleted": True}
|
||||
|
||||
|
||||
# ───────────── ZZJZ PGŽ scheduling ─────────────
|
||||
|
||||
def _mock_zzjz_termini(week_start: date) -> list[dict]:
|
||||
"""
|
||||
Mock dostupnih termina za sportsku medicinu.
|
||||
TODO: zamijeniti realnim scrapeom iz https://zzjzpgz.hr/djelatnosti/sportska-medicina/
|
||||
Format termina: po danu (pon-pet), 09:00-15:00 svakih 30 min.
|
||||
"""
|
||||
out = []
|
||||
times = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30",
|
||||
"11:00", "11:30", "12:30", "13:00", "13:30", "14:00", "14:30"]
|
||||
for d in range(5):
|
||||
day = week_start + timedelta(days=d)
|
||||
if day.weekday() >= 5:
|
||||
continue
|
||||
for t in times:
|
||||
# pseudo-availability deterministic by day*hour
|
||||
h = int(t.split(":")[0])
|
||||
available = ((day.day + h) % 3) != 0
|
||||
out.append({
|
||||
"datum": day.isoformat(),
|
||||
"vrijeme": t,
|
||||
"doktor": "Dr. Sportska medicina",
|
||||
"ustanova": "ZZJZ PGŽ",
|
||||
"available": available,
|
||||
"iznos_eur": 25.00,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/zzjz/info")
|
||||
def zzjz_info():
|
||||
"""Vraća kontakt + provjerava ima li online termin sustav (best-effort scrape)."""
|
||||
online_booking = _detect_zzjz_booking()
|
||||
return {**ZZJZ_INFO, "online_booking": online_booking}
|
||||
|
||||
|
||||
def _detect_zzjz_booking() -> dict:
|
||||
"""
|
||||
Best-effort detekcija da li ZZJZ PGŽ ima online termin formu na stranici.
|
||||
Vraća: {available: bool, url: str|None, kind: 'iframe'|'link'|'email'}
|
||||
Ne baca iznimku — uvijek vrati strukturu (fallback je email).
|
||||
"""
|
||||
try:
|
||||
import urllib.request
|
||||
import re as _re
|
||||
req = urllib.request.Request(ZZJZ_INFO["url_sportska_medicina"],
|
||||
headers={"User-Agent": "PGZSport/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=4) as resp:
|
||||
html = resp.read(200_000).decode("utf-8", errors="ignore")
|
||||
# tražimo standardne oznake online booking sustava
|
||||
patterns = [
|
||||
r'(https?://[^"\']*(?:doktor|booking|narucivanje|naruci|termin)[^"\']*)',
|
||||
r'<iframe[^>]+src="([^"]+)"',
|
||||
]
|
||||
for p in patterns:
|
||||
m = _re.search(p, html, _re.IGNORECASE)
|
||||
if m:
|
||||
url = m.group(1)
|
||||
if "iframe" in p:
|
||||
return {"available": True, "url": url, "kind": "iframe"}
|
||||
return {"available": True, "url": url, "kind": "link"}
|
||||
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
|
||||
"kind": "email",
|
||||
"fallback_email": ZZJZ_INFO["email"],
|
||||
"note": "Nije pronađen online sustav — koristi e-mail kontakt."}
|
||||
except Exception as e:
|
||||
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
|
||||
"kind": "email",
|
||||
"fallback_email": ZZJZ_INFO["email"],
|
||||
"error": str(e)[:120],
|
||||
"note": "Detekcija nije uspjela — fallback na e-mail."}
|
||||
|
||||
|
||||
@router.get("/zzjz/termini")
|
||||
def zzjz_termini(
|
||||
od: Optional[date] = Query(None,
|
||||
description="Početak tjedna; default = ovaj tjedan"),
|
||||
):
|
||||
"""
|
||||
Vraća dostupne termine za sportsku medicinu pri ZZJZ PGŽ.
|
||||
Trenutno: mock (deterministička dostupnost). Stvarna integracija
|
||||
čeka API ili scraping form-e na zzjzpgz.hr.
|
||||
"""
|
||||
if od is None:
|
||||
today = date.today()
|
||||
od = today - timedelta(days=today.weekday())
|
||||
termini = _mock_zzjz_termini(od)
|
||||
return {
|
||||
"ustanova": ZZJZ_INFO,
|
||||
"week_start": od.isoformat(),
|
||||
"count": len(termini),
|
||||
"available": sum(1 for t in termini if t["available"]),
|
||||
"termini": termini,
|
||||
"note": "Mock podaci. Realni termini čekaju ZZJZ PGŽ API ili authorizirani scraper.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/lijecnicki/{lid}/zakazi")
|
||||
def zakazi_termin(lid: int, body: ZakaziIn):
|
||||
"""
|
||||
Zakazuje termin za pregled.
|
||||
- Ako ZZJZ PGŽ ima online booking → vraća iframe/deeplink URL.
|
||||
- Ako nema → vraća mailto: deeplink za zahtjev e-mailom.
|
||||
Status pregleda u DB se ažurira (ustanova + napomena).
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT l.id, l.clan_id, l.ustanova,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.email AS clan_email,
|
||||
k.naziv AS klub
|
||||
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,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
new_napomena = (
|
||||
f"Termin zakazan: {body.datum.isoformat()} {body.vrijeme} @ "
|
||||
f"{body.ustanova}. {body.napomena or ''}"
|
||||
).strip()
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.lijecnicki_pregledi
|
||||
SET ustanova = COALESCE(%s, ustanova),
|
||||
napomena = %s,
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""", (body.ustanova, new_napomena, lid))
|
||||
upd = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
booking = _detect_zzjz_booking()
|
||||
from urllib.parse import quote as _q
|
||||
subj = _q(f"Zahtjev za termin sportske medicine — {r.get('clan') or '(sportaš)'}")
|
||||
body_email = _q(
|
||||
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
|
||||
f"Sportaš: {r.get('clan') or ''}\n"
|
||||
f"Klub: {r.get('klub') or ''}\n"
|
||||
f"Željeni datum: {body.datum.isoformat()} oko {body.vrijeme}\n"
|
||||
f"Kontakt: {r.get('clan_email') or '(nepoznato)'}\n\n"
|
||||
f"Lijep pozdrav,\nPGŽ Sport platforma"
|
||||
)
|
||||
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"id": lid,
|
||||
"zakazano_za": f"{body.datum.isoformat()} {body.vrijeme}",
|
||||
"ustanova": body.ustanova,
|
||||
"zzjz": ZZJZ_INFO,
|
||||
"booking": booking,
|
||||
"mailto": mailto,
|
||||
"note": (
|
||||
"Online booking detektiran — koristi 'booking.url' za iframe/redirect."
|
||||
if booking.get("available") else
|
||||
"Online booking nije pronađen — fallback: koristi 'mailto' za zahtjev e-mailom."
|
||||
),
|
||||
"pregled": _row(upd),
|
||||
}
|
||||
|
||||
|
||||
class ZakaziEmailIn(BaseModel):
|
||||
klub_id: Optional[int] = None
|
||||
clan_id: int
|
||||
zeljeni_datum: Optional[date] = None
|
||||
zeljeno_vrijeme: Optional[str] = "09:00"
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/lijecnicki/zakazi-email")
|
||||
def zakazi_email(body: ZakaziEmailIn):
|
||||
"""
|
||||
Bez postojećeg pregleda — generira mailto: link s pred-popunjenim
|
||||
podacima sportaša/kluba za slanje zahtjeva ZZJZ PGŽ.
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT cl.id, cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.email AS clan_email, cl.telefon AS clan_telefon,
|
||||
cl.datum_rodenja, cl.oib AS clan_oib,
|
||||
k.naziv AS klub, k.oib AS klub_oib
|
||||
FROM pgz_sport.clanovi cl
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = cl.klub_id
|
||||
WHERE cl.id=%s
|
||||
""", (body.clan_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Član ne postoji")
|
||||
|
||||
from urllib.parse import quote as _q
|
||||
when = (body.zeljeni_datum.isoformat() if body.zeljeni_datum else "po dogovoru")
|
||||
subj = _q(f"Zahtjev za termin sportske medicine — {r['clan']}")
|
||||
body_email = _q(
|
||||
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
|
||||
f"Sportaš: {r['clan']}\n"
|
||||
f"OIB: {r['clan_oib'] or '—'}\n"
|
||||
f"Datum rođenja: {r['datum_rodenja'] or '—'}\n"
|
||||
f"Klub: {r['klub'] or '—'}\n"
|
||||
f"Željeni termin: {when} oko {body.zeljeno_vrijeme}\n"
|
||||
f"Kontakt: {r['clan_email'] or '—'} / {r['clan_telefon'] or '—'}\n\n"
|
||||
f"Napomena: {body.napomena or '—'}\n\n"
|
||||
f"Lijep pozdrav,\nPGŽ Sport platforma"
|
||||
)
|
||||
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
|
||||
booking = _detect_zzjz_booking()
|
||||
return {
|
||||
"ok": True,
|
||||
"clan": r["clan"],
|
||||
"zzjz": ZZJZ_INFO,
|
||||
"booking": booking,
|
||||
"mailto": mailto,
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/obrasci_router.py
|
||||
# Svrha: M9 — Obrasci za sufinanciranje (form_templates + form_submissions)
|
||||
# + autopopulacija polja iz baze + digitalni potpis (sha256)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
"""M9 Obrasci router.
|
||||
|
||||
Endpointi (montirani na /api/crm):
|
||||
GET /forms → katalog form_templates
|
||||
GET /forms/{code_or_id} → schema + ui hints
|
||||
GET /forms/{code_or_id}/prefill → autopopulirane vrijednosti za klub/člana
|
||||
GET /forms/submissions → lista submissiona (filter: status, klub, code)
|
||||
POST /forms/submissions → kreira draft submission
|
||||
GET /forms/submissions/{id} → detalji
|
||||
POST /forms/submissions/{id}/submit → potpis + status submitted
|
||||
POST /forms/submissions/{id}/approve
|
||||
POST /forms/submissions/{id}/reject
|
||||
POST /forms/{code_or_id}/submit → kompatibilni shortcut: kreiraj+submit u jednom POST
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Any
|
||||
import uuid as _uuid
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor, Json
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/crm", tags=["crm-obrasci"])
|
||||
|
||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||
|
||||
|
||||
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)
|
||||
if isinstance(v, _uuid.UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
|
||||
def _row(d):
|
||||
return {k: _conv(v) for k, v in dict(d).items()}
|
||||
|
||||
|
||||
def _resolve_template(code_or_id: str, cur) -> dict:
|
||||
"""Akceptira numerički ID ili code string."""
|
||||
if str(code_or_id).isdigit():
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s AND active=TRUE",
|
||||
(int(code_or_id),))
|
||||
else:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=TRUE",
|
||||
(code_or_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, f"Form template '{code_or_id}' ne postoji")
|
||||
return r
|
||||
|
||||
|
||||
# ───────────── modeli ─────────────
|
||||
|
||||
class SubmissionIn(BaseModel):
|
||||
template_code: Optional[str] = None
|
||||
template_id: Optional[int] = None
|
||||
klub_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
clan_id: Optional[int] = None
|
||||
data: dict = {}
|
||||
attachments: Optional[list] = None
|
||||
status: Optional[str] = "draft"
|
||||
|
||||
|
||||
class SubmitIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
full_name: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
confirm: bool = True
|
||||
|
||||
|
||||
class ApproveIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class RejectIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
reason: str
|
||||
|
||||
|
||||
# ───────────── katalog templata ─────────────
|
||||
|
||||
@router.get("/forms/templates")
|
||||
def list_form_templates_alias(
|
||||
kategorija: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
active_only: bool = Query(True),
|
||||
):
|
||||
"""Alias za /forms — kompatibilnost s /sport/api/forms/templates."""
|
||||
return list_forms(kategorija=kategorija, q=q, active_only=active_only)
|
||||
|
||||
|
||||
@router.get("/forms")
|
||||
def list_forms(
|
||||
kategorija: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
active_only: bool = Query(True),
|
||||
):
|
||||
where, params = [], []
|
||||
if active_only:
|
||||
where.append("active = TRUE")
|
||||
if kategorija:
|
||||
where.append("kategorija = %s"); params.append(kategorija)
|
||||
if q:
|
||||
where.append("(naziv ILIKE %s OR opis ILIKE %s OR code ILIKE %s)")
|
||||
params += [f"%{q}%"] * 3
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
SELECT id, code, naziv, kategorija, opis, required_role,
|
||||
jsonb_array_length(COALESCE(schema_json->'fields', '[]'::jsonb)) AS field_count,
|
||||
active, created_at
|
||||
FROM pgz_sport.form_templates
|
||||
{where_sql}
|
||||
ORDER BY kategorija NULLS LAST, naziv
|
||||
""", params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
cur.execute("SELECT DISTINCT kategorija FROM pgz_sport.form_templates WHERE kategorija IS NOT NULL ORDER BY 1")
|
||||
kats = [r["kategorija"] for r in cur.fetchall()]
|
||||
return {"count": len(rows), "kategorije": kats, "forms": rows}
|
||||
|
||||
|
||||
# NOTE: /forms/submissions* moraju biti registrirani PRIJE /forms/{code_or_id}
|
||||
# jer FastAPI prvo provjerava redom registracije, a "submissions" bi
|
||||
# inače bilo uhvaćeno kao code_or_id.
|
||||
|
||||
# ───────────── autopopulacija polja iz baze (mora prije /{code_or_id} catch-all) ─────────────
|
||||
|
||||
@router.get("/forms/{code_or_id}/prefill")
|
||||
def prefill_form(code_or_id: str,
|
||||
klub_id: Optional[int] = Query(None),
|
||||
clan_id: Optional[int] = Query(None),
|
||||
user_id: Optional[int] = Query(None)):
|
||||
"""
|
||||
Vraća inicijalne vrijednosti za polja obrasca, popunjene iz baze.
|
||||
|
||||
Mapiranje polja → izvor:
|
||||
klub_naziv, klub_oib, klub_iban, klub_adresa, klub_grad, klub_email, klub_telefon,
|
||||
predsjednik, tajnik, sport, savez_naziv → pgz_sport.klubovi
|
||||
ime, prezime, oib_clan, datum_rodenja, kategorija → pgz_sport.clanovi
|
||||
iban, naziv (kad se odnose na klub) → klub
|
||||
*_godina → tekuća godina
|
||||
Polja koja schema_json nema, neće biti vraćena.
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
t = _resolve_template(code_or_id, cur)
|
||||
schema = t.get("schema_json") or {}
|
||||
fields = schema.get("fields") or []
|
||||
field_names = {f.get("name") for f in fields if isinstance(f, dict)}
|
||||
|
||||
klub = {}
|
||||
savez = {}
|
||||
if klub_id:
|
||||
cur.execute("""
|
||||
SELECT k.*, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.klubovi k
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
||||
WHERE k.id = %s
|
||||
""", (klub_id,))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
klub = _row(r)
|
||||
|
||||
clan = {}
|
||||
if clan_id:
|
||||
cur.execute("SELECT * FROM pgz_sport.clanovi WHERE id=%s", (clan_id,))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
clan = _row(r)
|
||||
# ako klub_id nije eksplicitno, izvuci iz člana
|
||||
if not klub and clan.get("klub_id"):
|
||||
cur.execute("""
|
||||
SELECT k.*, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.klubovi k
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
||||
WHERE k.id = %s
|
||||
""", (clan["klub_id"],))
|
||||
rr = cur.fetchone()
|
||||
if rr:
|
||||
klub = _row(rr)
|
||||
|
||||
user = {}
|
||||
if user_id:
|
||||
cur.execute("SELECT id, email, full_name, ime, prezime, oib, telefon, klub_id, savez_id, user_type FROM pgz_sport.users WHERE id=%s",
|
||||
(user_id,))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
user = _row(r)
|
||||
|
||||
# Mapiranje
|
||||
prefill: dict = {}
|
||||
today = date.today()
|
||||
|
||||
def put(name: str, value: Any):
|
||||
if name in field_names and value not in (None, ""):
|
||||
prefill[name] = value
|
||||
|
||||
# KLUB → polja
|
||||
if klub:
|
||||
put("klub_naziv", klub.get("naziv"))
|
||||
put("naziv_kluba", klub.get("naziv"))
|
||||
put("naziv", klub.get("naziv"))
|
||||
put("klub_oib", klub.get("oib"))
|
||||
put("oib", klub.get("oib"))
|
||||
put("oib_kluba", klub.get("oib"))
|
||||
put("klub_iban", klub.get("iban"))
|
||||
put("iban", klub.get("iban"))
|
||||
put("adresa", klub.get("adresa"))
|
||||
put("klub_adresa", klub.get("adresa"))
|
||||
put("grad", klub.get("grad"))
|
||||
put("klub_grad", klub.get("grad"))
|
||||
put("klub_email", klub.get("email"))
|
||||
put("email", klub.get("email"))
|
||||
put("klub_telefon", klub.get("telefon"))
|
||||
put("telefon", klub.get("telefon"))
|
||||
put("predsjednik", klub.get("predsjednik"))
|
||||
put("tajnik", klub.get("tajnik"))
|
||||
put("sport", klub.get("sport"))
|
||||
put("savez_naziv", klub.get("savez_naziv"))
|
||||
put("godina_osnutka", klub.get("godina_osnutka"))
|
||||
put("matični_broj", klub.get("matični_broj"))
|
||||
put("reg_broj", klub.get("reg_broj"))
|
||||
|
||||
# ČLAN → polja
|
||||
if clan:
|
||||
put("ime", clan.get("ime"))
|
||||
put("prezime", clan.get("prezime"))
|
||||
put("ime_prezime", f"{clan.get('ime','')} {clan.get('prezime','')}".strip())
|
||||
put("oib_clan", clan.get("oib"))
|
||||
put("oib_sportasa", clan.get("oib"))
|
||||
put("datum_rodenja", clan.get("datum_rodenja"))
|
||||
put("kategorija", clan.get("kategorija"))
|
||||
put("podkategorija", clan.get("podkategorija"))
|
||||
put("pozicija", clan.get("pozicija"))
|
||||
put("clan_email", clan.get("email"))
|
||||
put("clan_telefon", clan.get("telefon"))
|
||||
put("clan_adresa", clan.get("adresa"))
|
||||
put("spol", clan.get("spol"))
|
||||
put("licenca_broj", clan.get("licenca_broj"))
|
||||
|
||||
# USER → polja
|
||||
if user:
|
||||
put("podnositelj_ime", (user.get("full_name") or
|
||||
f"{user.get('ime','')} {user.get('prezime','')}".strip()))
|
||||
put("podnositelj_email", user.get("email"))
|
||||
put("podnositelj_telefon", user.get("telefon"))
|
||||
|
||||
# TEKUĆA GODINA / DATUM
|
||||
put("program_godina", today.year)
|
||||
put("godina", today.year)
|
||||
put("datum", today.isoformat())
|
||||
put("datum_predaje", today.isoformat())
|
||||
|
||||
return {
|
||||
"template_code": t["code"],
|
||||
"template_id": t["id"],
|
||||
"naziv": t["naziv"],
|
||||
"prefill": prefill,
|
||||
"missing_fields": sorted(field_names - set(prefill.keys())),
|
||||
"applied_fields": sorted(prefill.keys()),
|
||||
"sources": {"klub": bool(klub), "clan": bool(clan), "user": bool(user)},
|
||||
}
|
||||
|
||||
|
||||
# ───────────── submissions ─────────────
|
||||
|
||||
@router.get("/forms/submissions")
|
||||
def list_submissions(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
template_code: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
user_id: Optional[int] = Query(None),
|
||||
limit: int = Query(200, le=1000),
|
||||
):
|
||||
where, params = [], []
|
||||
if klub_id:
|
||||
where.append("s.klub_id=%s"); params.append(klub_id)
|
||||
if template_code:
|
||||
where.append("s.template_code=%s"); params.append(template_code)
|
||||
if status:
|
||||
where.append("s.status=%s"); params.append(status)
|
||||
if user_id:
|
||||
where.append("s.user_id=%s"); params.append(user_id)
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
params.append(limit)
|
||||
sql = f"""
|
||||
SELECT s.id, s.template_id, s.template_code, s.klub_id, s.user_id,
|
||||
s.clan_id, s.status, s.reference_no, s.submitted_at,
|
||||
s.reviewed_at, s.approved_at, s.rejected_reason, s.created_at,
|
||||
t.naziv AS template_naziv, t.kategorija,
|
||||
k.naziv AS klub_naziv,
|
||||
cl.ime || ' ' || cl.prezime AS clan_naziv,
|
||||
COALESCE(s.data->>'__signature_sha256', NULL) AS signature_sha256
|
||||
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
|
||||
{where_sql}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE s.status='draft') AS draft,
|
||||
COUNT(*) FILTER (WHERE s.status='submitted') AS submitted,
|
||||
COUNT(*) FILTER (WHERE s.status='approved') AS approved,
|
||||
COUNT(*) FILTER (WHERE s.status='rejected') AS rejected
|
||||
FROM pgz_sport.form_submissions s
|
||||
{where_sql}
|
||||
""", params[:-1])
|
||||
summary = _row(cur.fetchone() or {})
|
||||
return {"count": len(rows), "rows": rows, "summary": summary}
|
||||
|
||||
|
||||
@router.get("/forms/submissions/{sid}")
|
||||
def get_submission(sid: int):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
|
||||
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||
cl.ime || ' ' || cl.prezime AS clan_naziv
|
||||
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
|
||||
WHERE s.id = %s
|
||||
""", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
return _row(r)
|
||||
|
||||
|
||||
@router.post("/forms/submissions")
|
||||
def create_submission(body: SubmissionIn):
|
||||
if not (body.template_code or body.template_id):
|
||||
raise HTTPException(400, "template_code ili template_id obavezan")
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
if body.template_id:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (body.template_id,))
|
||||
else:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s", (body.template_code,))
|
||||
t = cur.fetchone()
|
||||
if not t:
|
||||
raise HTTPException(404, "Template ne postoji")
|
||||
|
||||
# generiraj reference_no: TPL-YYYY-XXXXXXXX
|
||||
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.form_submissions
|
||||
(template_id, template_code, klub_id, user_id, clan_id, data,
|
||||
attachments, status, reference_no)
|
||||
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s)
|
||||
RETURNING *
|
||||
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
|
||||
json.dumps(body.data or {}), json.dumps(body.attachments or []),
|
||||
body.status or "draft", ref))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return _row(s)
|
||||
|
||||
|
||||
# ───────────── digitalni potpis (sha256) i submit ─────────────
|
||||
|
||||
def _sign_payload(data: dict, signer: Optional[str]) -> dict:
|
||||
"""
|
||||
Deterministički sha256 nad sortiranim JSON-om + timestamp.
|
||||
Vraća meta polja koja se ubacuju u data:
|
||||
__signature_sha256, __signed_at, __signed_by
|
||||
"""
|
||||
canon = json.dumps(data, sort_keys=True, ensure_ascii=False, default=str)
|
||||
sha = hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"__signature_sha256": sha,
|
||||
"__signed_at": datetime.utcnow().isoformat() + "Z",
|
||||
"__signed_by": signer or "unknown",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/submit")
|
||||
def submit_submission(sid: int, body: SubmitIn):
|
||||
if not body.confirm:
|
||||
raise HTTPException(400, "Potrebna potvrda (confirm=true)")
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
if r["status"] not in ("draft", "rejected"):
|
||||
raise HTTPException(400, f"Submission je u statusu '{r['status']}', ne može se submitati")
|
||||
|
||||
merged = dict(r["data"] or {})
|
||||
if body.data:
|
||||
merged.update(body.data)
|
||||
# ukloni stari potpis prije računanja novog
|
||||
for k in list(merged.keys()):
|
||||
if k.startswith("__signature") or k.startswith("__signed"):
|
||||
merged.pop(k, None)
|
||||
signer = body.full_name or (str(body.user_id) if body.user_id else None)
|
||||
sig = _sign_payload(merged, signer)
|
||||
merged.update(sig)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET data = %s::jsonb,
|
||||
status = 'submitted',
|
||||
user_id = COALESCE(%s, user_id),
|
||||
submitted_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""", (json.dumps(merged), body.user_id, sid))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": sid,
|
||||
"status": "submitted",
|
||||
"signature_sha256": sig["__signature_sha256"],
|
||||
"signed_at": sig["__signed_at"],
|
||||
"signed_by": sig["__signed_by"],
|
||||
"submission": _row(s),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/approve")
|
||||
def approve_submission(sid: int, body: ApproveIn):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
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 AND status IN ('submitted','draft')
|
||||
RETURNING *
|
||||
""", (body.user_id, body.user_id, sid))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": sid, "status": "approved", "submission": _row(r)}
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/reject")
|
||||
def reject_submission(sid: int, body: RejectIn):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='rejected',
|
||||
reviewed_by=%s, reviewed_at=now(),
|
||||
rejected_reason=%s,
|
||||
updated_at=now()
|
||||
WHERE id=%s AND status IN ('submitted','draft')
|
||||
RETURNING *
|
||||
""", (body.user_id, body.reason, sid))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": sid, "status": "rejected",
|
||||
"reason": body.reason, "submission": _row(r)}
|
||||
|
||||
|
||||
# ───────────── potpisivanje + PDF izvoz submissiona ─────────────
|
||||
|
||||
class SignIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/sign")
|
||||
def sign_submission(sid: int, body: SignIn):
|
||||
"""
|
||||
Digitalni potpis postojećeg submissiona — sha256 nad sortiranim JSON-om.
|
||||
Može se pozvati i na već submitanom (re-sign) i na draftu (samo potpisuje,
|
||||
ne mijenja status).
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
|
||||
merged = dict(r["data"] or {})
|
||||
# ukloni stari potpis
|
||||
for k in list(merged.keys()):
|
||||
if k.startswith("__signature") or k.startswith("__signed"):
|
||||
merged.pop(k, None)
|
||||
signer = body.full_name or (str(body.user_id) if body.user_id else "anonymous")
|
||||
sig = _sign_payload(merged, signer)
|
||||
merged.update(sig)
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET data = %s::jsonb,
|
||||
user_id = COALESCE(%s, user_id),
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""", (json.dumps(merged), body.user_id, sid))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": sid,
|
||||
"signature_sha256": sig["__signature_sha256"],
|
||||
"signed_at": sig["__signed_at"],
|
||||
"signed_by": sig["__signed_by"],
|
||||
"submission": _row(s),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/forms/submissions/{sid}/pdf")
|
||||
def submission_pdf(sid: int):
|
||||
"""Generira PDF s sadržajem submissiona, statusom i potpisom (sha256)."""
|
||||
from fastapi.responses import Response
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
import io as _io
|
||||
|
||||
# font za HR diakritike
|
||||
font_reg, font_bold = "Helvetica", "Helvetica-Bold"
|
||||
try:
|
||||
if "DejaVu" not in pdfmetrics.getRegisteredFontNames():
|
||||
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf"):
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont("DejaVu", path))
|
||||
pdfmetrics.registerFont(TTFont("DejaVu-Bold",
|
||||
path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")))
|
||||
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
|
||||
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||
cl.ime || ' ' || cl.prezime AS clan_naziv
|
||||
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
|
||||
WHERE s.id = %s
|
||||
""", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
|
||||
s = _row(r)
|
||||
schema = s.get("schema_json") or {}
|
||||
fields = schema.get("fields") or []
|
||||
data = s.get("data") or {}
|
||||
|
||||
sig_sha = data.get("__signature_sha256")
|
||||
sig_at = data.get("__signed_at")
|
||||
sig_by = data.get("__signed_by")
|
||||
|
||||
buf = _io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=A4)
|
||||
W, H = A4
|
||||
y = H - 18 * mm
|
||||
|
||||
# Header bar
|
||||
c.setFillColorRGB(0.13, 0.20, 0.32)
|
||||
c.rect(0, H - 22 * mm, W, 22 * mm, fill=1, stroke=0)
|
||||
c.setFillColorRGB(1, 1, 1)
|
||||
c.setFont(font_bold, 14)
|
||||
c.drawString(15 * mm, H - 12 * mm, "PGŽ SPORT — OBRAZAC")
|
||||
c.setFont(font_reg, 10)
|
||||
c.drawString(15 * mm, H - 18 * mm, str(s.get("template_naziv") or s.get("template_code") or ""))
|
||||
c.drawRightString(W - 15 * mm, H - 12 * mm, f"REF: {s.get('reference_no') or ''}")
|
||||
c.drawRightString(W - 15 * mm, H - 18 * mm,
|
||||
f"Status: {s.get('status','').upper()}")
|
||||
|
||||
y = H - 30 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
|
||||
# Meta
|
||||
def line(label, value, bold=False):
|
||||
nonlocal y
|
||||
if y < 25 * mm:
|
||||
c.showPage()
|
||||
y = H - 20 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
c.setFont(font_reg, 8)
|
||||
c.setFillColorRGB(0.45, 0.45, 0.45)
|
||||
c.drawString(15 * mm, y, label)
|
||||
c.setFont(font_bold if bold else font_reg, 10)
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
v = "" if value is None else str(value)
|
||||
# wrap
|
||||
max_w = W - 30 * mm
|
||||
while v:
|
||||
chunk = v
|
||||
while pdfmetrics.stringWidth(chunk, font_bold if bold else font_reg, 10) > max_w and len(chunk) > 5:
|
||||
chunk = chunk[:-2]
|
||||
c.drawString(15 * mm, y - 4 * mm, chunk)
|
||||
v = v[len(chunk):].lstrip() if len(chunk) < len(v) else ""
|
||||
y -= 5 * mm
|
||||
if v:
|
||||
if y < 25 * mm:
|
||||
c.showPage(); y = H - 20 * mm
|
||||
y -= 3 * mm
|
||||
|
||||
line("KLUB", s.get("klub_naziv"), bold=True)
|
||||
line("OIB KLUBA", s.get("klub_oib"))
|
||||
line("IBAN KLUBA", s.get("klub_iban"))
|
||||
if s.get("clan_naziv"):
|
||||
line("ČLAN/SPORTAŠ", s.get("clan_naziv"))
|
||||
line("DATUM PREDAJE", s.get("submitted_at") or s.get("created_at"))
|
||||
line("STATUS", s.get("status"), bold=True)
|
||||
|
||||
# Section divider
|
||||
y -= 4 * mm
|
||||
c.setStrokeColorRGB(0.13, 0.20, 0.32)
|
||||
c.setLineWidth(0.6)
|
||||
c.line(15 * mm, y, W - 15 * mm, y)
|
||||
y -= 6 * mm
|
||||
c.setFont(font_bold, 11)
|
||||
c.setFillColorRGB(0.13, 0.20, 0.32)
|
||||
c.drawString(15 * mm, y, "SADRŽAJ OBRASCA")
|
||||
y -= 8 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
|
||||
# Polja iz schema_json (skip meta __keys)
|
||||
if fields:
|
||||
for f in fields:
|
||||
name = f.get("name")
|
||||
if not name or name.startswith("__"):
|
||||
continue
|
||||
label = f.get("label") or name
|
||||
val = data.get(name)
|
||||
line(label, val)
|
||||
else:
|
||||
# fallback — sve ključeve iz data
|
||||
for k, v in data.items():
|
||||
if k.startswith("__"):
|
||||
continue
|
||||
line(k, v)
|
||||
|
||||
# Potpis
|
||||
y -= 6 * mm
|
||||
if y < 50 * mm:
|
||||
c.showPage(); y = H - 20 * mm
|
||||
c.setFillColorRGB(0.13, 0.20, 0.32)
|
||||
c.setStrokeColorRGB(0.13, 0.20, 0.32)
|
||||
c.setLineWidth(0.6)
|
||||
c.line(15 * mm, y, W - 15 * mm, y)
|
||||
y -= 6 * mm
|
||||
c.setFont(font_bold, 11)
|
||||
c.drawString(15 * mm, y, "DIGITALNI POTPIS")
|
||||
y -= 8 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
if sig_sha:
|
||||
line("Potpisao", sig_by or "")
|
||||
line("Vrijeme potpisa (UTC)", sig_at or "")
|
||||
line("SHA-256 hash sadržaja", sig_sha)
|
||||
line("Verifikacija",
|
||||
"PGŽ Sport ERP/CRM — hash izračunat nad sortiranim JSON sadržajem (bez __* polja).")
|
||||
else:
|
||||
c.setFont(font_reg, 9)
|
||||
c.setFillColorRGB(0.7, 0.3, 0.3)
|
||||
c.drawString(15 * mm, y, "Obrazac NIJE digitalno potpisan.")
|
||||
y -= 6 * mm
|
||||
|
||||
# Footer
|
||||
c.setFont(font_reg, 7)
|
||||
c.setFillColorRGB(0.55, 0.55, 0.55)
|
||||
c.drawString(15 * mm, 10 * mm,
|
||||
f"PGŽ Sport ERP/CRM • Generirano {datetime.now().strftime('%d.%m.%Y. %H:%M')} • REF {s.get('reference_no') or sid}")
|
||||
|
||||
c.save()
|
||||
pdf = buf.getvalue()
|
||||
return Response(content=pdf, media_type="application/pdf",
|
||||
headers={"Content-Disposition":
|
||||
f"inline; filename=obrazac-{sid}.pdf"})
|
||||
|
||||
|
||||
# ───────────── /forms/{code_or_id} (catch-all GET — mora biti POSLIJE submissions!) ─────────────
|
||||
|
||||
@router.get("/forms/{code_or_id}")
|
||||
def get_form(code_or_id: str):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
t = _resolve_template(code_or_id, cur)
|
||||
return _row(t)
|
||||
|
||||
|
||||
# ───────────── shortcut: kreiraj+submit u jednom ─────────────
|
||||
|
||||
@router.post("/forms/{code_or_id}/submit")
|
||||
def quick_submit(code_or_id: str, body: SubmissionIn):
|
||||
"""Kompatibilni shortcut — kreira draft + odmah submita s potpisom."""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
t = _resolve_template(code_or_id, cur)
|
||||
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
merged = dict(body.data or {})
|
||||
signer = str(body.user_id) if body.user_id else "anonymous"
|
||||
sig = _sign_payload(merged, signer)
|
||||
merged.update(sig)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.form_submissions
|
||||
(template_id, template_code, klub_id, user_id, clan_id, data,
|
||||
attachments, status, reference_no, submitted_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,'submitted',%s, now())
|
||||
RETURNING *
|
||||
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
|
||||
json.dumps(merged), json.dumps(body.attachments or []), ref))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": s["id"],
|
||||
"reference_no": s["reference_no"],
|
||||
"status": "submitted",
|
||||
"signature_sha256": sig["__signature_sha256"],
|
||||
"signed_at": sig["__signed_at"],
|
||||
"submission": _row(s),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user