From b93ca9a8bfdb39d0365948c9be16200595c17d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 00:14:59 +0200 Subject: [PATCH] M9 CRM Obrasci + ZZJZ booking detect + e-mail fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obrasci (M9): - /api/crm/forms — katalog form_templates (15 templata već seedan) - /api/crm/forms/templates — alias (kompatibilnost) - /api/crm/forms/{code|id} — detalji + schema_json - /api/crm/forms/{code|id}/prefill — autopopulacija polja iz baze (klub_id/clan_id/user_id → polja na obrascu mapirana po imenima) - /api/crm/forms/submissions [GET/POST] — lista + create draft - /api/crm/forms/submissions/{id} — detalji s schema + klub/clan - /api/crm/forms/submissions/{id}/submit — submit + sha256 potpis sadržaja - /api/crm/forms/submissions/{id}/sign — re-sign / potpis bez statusa change - /api/crm/forms/submissions/{id}/approve|reject — workflow - /api/crm/forms/submissions/{id}/pdf — generirani PDF s metapodacima i potpisom - /api/crm/forms/{code|id}/submit — shortcut: kreiraj+submit u jednom POST ZZJZ PGŽ (M8 dopuna): - /api/crm/zzjz/info — dodan online_booking probe (HTTP scrape best-effort) - /api/crm/lijecnicki/{id}/zakazi — vraća booking URL ako postoji, inače mailto: - /api/crm/lijecnicki/zakazi-email — generira mailto: deeplink s pred-popunjenim podacima sportaša/kluba (fallback kad nema online termina) - URL sportske medicine ispravljen na školska/adolescentna medicina (jedini stvarni odjel ZZJZ PGŽ koji obavlja sportske preglede). --- routers/lijecnicki_router.py | 138 ++++++- routers/obrasci_router.py | 757 +++++++++++++++++++++++++++++++++++ 2 files changed, 888 insertions(+), 7 deletions(-) create mode 100644 routers/obrasci_router.py diff --git a/routers/lijecnicki_router.py b/routers/lijecnicki_router.py index e957156..c1d3236 100644 --- a/routers/lijecnicki_router.py +++ b/routers/lijecnicki_router.py @@ -40,7 +40,8 @@ ZZJZ_INFO = { "telefon": "+385 51 358 770", "email": "info@zzjzpgz.hr", "web": ZZJZ_BASE, - "url_sportska_medicina": f"{ZZJZ_BASE}/djelatnosti/sportska-medicina/", + # 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/", } @@ -382,7 +383,46 @@ def _mock_zzjz_termini(week_start: date) -> list[dict]: @router.get("/zzjz/info") def zzjz_info(): - return 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']+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") @@ -412,11 +452,22 @@ def zzjz_termini( @router.post("/lijecnicki/{lid}/zakazi") def zakazi_termin(lid: int, body: ZakaziIn): """ - Stvara zakazani termin (mock) za pregled koji još nije obavljen. - Realna integracija: POST na ZZJZ PGŽ booking endpoint kad bude dostupan. + 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 id, clan_id, ustanova FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,)) + 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") @@ -434,12 +485,85 @@ def zakazi_termin(lid: int, body: ZakaziIn): """, (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_url": ZZJZ_INFO["url_sportska_medicina"], - "note": "Mock booking — realna ZZJZ PGŽ integracija čeka API/scraper.", + "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, + } diff --git a/routers/obrasci_router.py b/routers/obrasci_router.py new file mode 100644 index 0000000..a054d10 --- /dev/null +++ b/routers/obrasci_router.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════ +# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026 +# Autor: Damir Radulić / 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), + }