M9 CRM Obrasci + ZZJZ booking detect + e-mail fallback
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).
This commit is contained in:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user