#!/usr/bin/env python3 from dotenv import load_dotenv load_dotenv('/opt/rinet-gpu/.env.master') # auto-added by patch_scrapers_with_dotenv.sh import os # ═══════════════════════════════════════════════════════════════════ # 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 = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}" 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() try: from erp.audit_helper import audit as _audit _audit("pgz_sport.form_submissions", "submit", s["id"], korisnik=str(body.user_id or "anonymous"), field="signature_sha256", new=sig["__signature_sha256"][:64]) except Exception: pass 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), }