3e5b98a935
Sub1 (commit eb1b49f): 4 v2 listing/discovery endpoints + SQL fix
Sub2: CRM 4 modula PASS (M7 članarine, M8 liječnički, M9 obrasci, dokumenti partial)
Sub3: ERP 4 modula GREEN — racuni/putni/placanja/xlsx, E2E demo flow (7 steps) PASS
Critical fix this commit:
- erp/audit_helper.py (centralni helper za audit_log writer)
- routers/clanarine_router.py: audit hook na POST /clanarine
- routers/lijecnicki_router.py: audit hook na POST /lijecnicki
- routers/obrasci_router.py: audit hook na POST /submissions + /submit
Verify: prije 0 / poslije 1 audit entry za POST /api/crm/clanarine
"33|create|api|clan=4946 klub=2320 300.0€"
Outstanding (next round):
- /api/v2/dokumenti plain route shadowing with RAG
- /api/v2/dokumenti/upload missing
- SQL alias bug u pgz_sport_v2_router.py:3099
Reports:
_audit/audit_CC4_FINAL.md (konsolidirani)
_audit/audit_CRM_VERIFIED.md
_audit/audit_ERP_VERIFIED.md
_audit/audit_ENDPOINTS_ADDED.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
764 lines
28 KiB
Python
764 lines
28 KiB
Python
#!/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()
|
|
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),
|
|
}
|