Files
pgz-sport/routers/obrasci_router.py
Damir Radulic 2e022a7dcc fix(URGENT): SPA fallback serves sport2.html + 9 routers __future__ position
BUGS FIXED:
1. _serve_spa_fallback() returned index.html instead of sport2.html
   → User clicked /analitika /sufinanciranje etc and got wrong UI (DABI title)
   → Should serve sport2.html (PGZ SPORT - Platforma) with Analiza/Mreza/Link tabs

2. 9 router files had "from __future__" NOT at top of file
   → SyntaxError on import → routers SKIPPED → intermittent API failures
   → Affected: ocr.py, ocr_router.py, putni_nalozi.py, obrasci_router.py,
     clan_panel_router.py, audit_seal_router.py, erp_full_router.py,
     notif_router.py, seal.py

ROOT CAUSE:
Prior dehardcode batch (Master Zakon #1 sweep) inserted env-loading
imports BEFORE "from __future__ import annotations" — Python parser
requires __future__ FIRST.

FIX:
- _serve_spa_fallback() candidates list: sport2.html first
- Moved __future__ to top (preserving shebang + encoding + comments) in all 9

VERIFIED:
- 0 failed routers (was 7+)
- Analiza API: 10/10 success ~60-87ms
- Summary API: 5/5 success ~40ms
- sport.rinet.one/ → PGZ SPORT - Platforma (Analiza+Mreza tabs)
- All 9 SPA fallback routes serve sport2.html

Damir uploaded screenshot showing Analiza tab working (2,049 igraca,
82 klubova) but described as intermittent — root cause was router fails
causing some API endpoints to be missing/unreliable. Fixed.
2026-05-18 15:45:22 +02:00

768 lines
29 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
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ć <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
"""
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),
}