2e022a7dcc
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.
552 lines
21 KiB
Python
552 lines
21 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
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Fajl: routers/clan_panel_router.py | v1.0.0 | 05.05.2026
|
|
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
|
# Lokacija: /opt/pgz-sport/routers/clan_panel_router.py
|
|
# Svrha: CRM Dashboard člana — full panel (sve), edit s permission gating,
|
|
# avatar upload.
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
"""CRM Član Panel router.
|
|
|
|
Endpointi (montirani na /api/crm):
|
|
GET /clanovi/{id}/full → SVI podaci o članu + povijest svega
|
|
PUT /clanovi/{id} → edit (permission gating po roli)
|
|
POST /clanovi/{id}/avatar → upload slike
|
|
GET /clanovi/search?q=... → quick search za panel
|
|
"""
|
|
|
|
import os
|
|
import io
|
|
import shutil
|
|
import uuid as _uuid
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Optional, Any
|
|
from pathlib import Path
|
|
|
|
import psycopg2
|
|
from psycopg2.extras import RealDictCursor
|
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Header
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/api/crm", tags=["crm-clan-panel"])
|
|
|
|
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
|
|
|
|
UPLOADS_DIR = Path("/opt/pgz-sport/static/uploads/avatars")
|
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
PUBLIC_AVATAR_PREFIX = "/sport/static/uploads/avatars"
|
|
|
|
# Polja koja smiju editirati pojedine role.
|
|
# Hard rules iz briefa:
|
|
# sportas (sebe): kontakt + slike
|
|
# klub_admin: sve osim OIB
|
|
# savez_admin: pregled + napomene
|
|
# pgz_admin: full
|
|
# super_admin: full
|
|
EDITABLE_BY_ROLE = {
|
|
"sportas": {
|
|
"email", "telefon", "adresa", "grad", "postanski_broj",
|
|
"biografija", "slika_url",
|
|
},
|
|
"klub_admin": {
|
|
# sve osim "oib"
|
|
"ime", "prezime", "datum_rodenja", "spol", "adresa", "grad",
|
|
"postanski_broj", "email", "telefon", "kategorija", "podkategorija",
|
|
"pozicija", "licenca_broj", "licenca_vrijedi_do", "reprezentativac",
|
|
"kategoriziran", "kategorija_hoo", "stipendiran", "stipendija_iznos",
|
|
"radno_pravni_status", "aktivan", "datum_pristupa", "datum_napustanja",
|
|
"napomena", "dominantna_noga", "visina_cm", "tezina_kg", "broj_dresa",
|
|
"reprezentacija_kategorija", "biografija", "mjesto_rodenja",
|
|
"sport", "uloga", "uloga_detalj", "klub_id", "slika_url",
|
|
},
|
|
"savez_admin": {
|
|
"napomena",
|
|
},
|
|
"pgz_admin": "ALL",
|
|
"super_admin": "ALL",
|
|
"klub_trener": {
|
|
"kategorija", "podkategorija", "pozicija", "broj_dresa",
|
|
"dominantna_noga", "visina_cm", "tezina_kg", "napomena", "biografija",
|
|
},
|
|
}
|
|
|
|
|
|
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 None if d is None else {k: _conv(v) for k, v in dict(d).items()}
|
|
|
|
|
|
def _resolve_role(authorization: Optional[str]) -> str:
|
|
"""
|
|
Vrlo pojednostavljeno: dok puni JWT M1 ne propagira context, čitamo
|
|
'X-Role' header (postavi UI). Inače: ako je authorization == admin token
|
|
→ pgz_admin, inače → viewer.
|
|
"""
|
|
if not authorization:
|
|
return "viewer"
|
|
tok = authorization.replace("Bearer ", "").strip()
|
|
if tok == "admin-pgz-2026":
|
|
return "pgz_admin"
|
|
# decode JWT (best-effort)
|
|
try:
|
|
import jwt as _jwt # type: ignore
|
|
# JWT secret iz auth_v2 — bez tvrde ovisnosti
|
|
for secret in (os.environ.get("JWT_SECRET"), "rinet-jwt-secret-2026"):
|
|
if not secret:
|
|
continue
|
|
try:
|
|
payload = _jwt.decode(tok, secret, algorithms=["HS256"])
|
|
return payload.get("role") or payload.get("user_type") or "viewer"
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
return "viewer"
|
|
|
|
|
|
def _check_field_perm(role: str, fields: set[str]) -> set[str]:
|
|
"""Vrati SAMO polja koja role smije editirati."""
|
|
allowed = EDITABLE_BY_ROLE.get(role, set())
|
|
if allowed == "ALL":
|
|
return fields
|
|
return fields & allowed
|
|
|
|
|
|
# ───────────── search ─────────────
|
|
|
|
@router.get("/clanovi/search")
|
|
def clanovi_search(q: Optional[str] = Query(None, min_length=2),
|
|
klub_id: Optional[int] = Query(None),
|
|
limit: int = Query(20, le=100)):
|
|
where, params = ["c.aktivan = TRUE"], []
|
|
if q:
|
|
where.append("(c.ime || ' ' || c.prezime) ILIKE %s OR c.oib ILIKE %s")
|
|
params += [f"%{q}%", f"%{q}%"]
|
|
if klub_id:
|
|
where.append("c.klub_id = %s"); params.append(klub_id)
|
|
params.append(limit)
|
|
sql = f"""
|
|
SELECT c.id, c.ime, c.prezime, c.oib, c.kategorija, c.pozicija,
|
|
c.slika_url, c.broj_dresa,
|
|
k.id AS klub_id, k.naziv AS klub
|
|
FROM pgz_sport.clanovi c
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY c.prezime, c.ime
|
|
LIMIT %s
|
|
"""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
# ───────────── full panel ─────────────
|
|
|
|
@router.get("/clanovi/{cid}/full")
|
|
def clan_full(cid: int):
|
|
"""
|
|
Vraća SVE podatke o članu + sve povezane pod-tablice:
|
|
- personal, kontakt, sport, status, reprezentacija, stipendija
|
|
- klub (trenutni + povijest preko clan_sezona.klub_naziv)
|
|
- sezone (clan_sezona)
|
|
- utakmice (zadnjih 20 — clan_utakmica)
|
|
- lijecnicki (lijecnicki_pregledi po clan_id)
|
|
- clanarine (clanarine po clan_id) + dug
|
|
- dokumenti (clan_godisnjak ↔ dokumenti)
|
|
- obrasci (form_submissions po clan_id)
|
|
- nagrade (clan_nagrada)
|
|
"""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT c.*,
|
|
k.id AS klub__id,
|
|
k.naziv AS klub__naziv,
|
|
k.oib AS klub__oib,
|
|
k.iban AS klub__iban,
|
|
k.adresa AS klub__adresa,
|
|
k.grad AS klub__grad,
|
|
k.sport AS klub__sport,
|
|
k.savez_id AS klub__savez_id,
|
|
s.naziv AS klub__savez_naziv,
|
|
EXTRACT(YEAR FROM age(COALESCE(c.datum_rodenja, c.datum_rodjenja)))::int AS dob_calc
|
|
FROM pgz_sport.clanovi c
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
|
WHERE c.id = %s
|
|
""", (cid,))
|
|
clan_raw = cur.fetchone()
|
|
if not clan_raw:
|
|
raise HTTPException(404, "Član ne postoji")
|
|
|
|
# rastavi klub__* u nested objekt
|
|
c = {}
|
|
klub: dict = {}
|
|
for k, v in dict(clan_raw).items():
|
|
if k.startswith("klub__"):
|
|
klub[k.replace("klub__", "")] = v
|
|
else:
|
|
c[k] = v
|
|
|
|
# avatar URL fallback (slika_url može biti relativna)
|
|
slika = c.get("slika_url") or ""
|
|
if slika and not (slika.startswith("http") or slika.startswith("/")):
|
|
slika = f"{PUBLIC_AVATAR_PREFIX}/{slika}"
|
|
c["slika_url_full"] = slika or None
|
|
|
|
# SEZONE
|
|
cur.execute("""
|
|
SELECT id, sezona, natjecanje, klub_naziv, nastupi, zapoceo, zamjena,
|
|
pogoci, asistencije, zuti_kartoni, crveni_kartoni, minute_total,
|
|
napomena, scrape_url
|
|
FROM pgz_sport.clan_sezona
|
|
WHERE clan_id = %s
|
|
ORDER BY sezona DESC
|
|
LIMIT 50
|
|
""", (cid,))
|
|
sezone = [_row(r) for r in cur.fetchall()]
|
|
|
|
# UTAKMICE (zadnjih 20)
|
|
cur.execute("""
|
|
SELECT id, datum, domacin, gost, rezultat, natjecanje,
|
|
pogoci, zuti, crveni, minute, utakmica_url
|
|
FROM pgz_sport.clan_utakmica
|
|
WHERE clan_id = %s
|
|
ORDER BY datum DESC NULLS LAST
|
|
LIMIT 20
|
|
""", (cid,))
|
|
utakmice = [_row(r) for r in cur.fetchall()]
|
|
|
|
# LIJEČNIČKI
|
|
cur.execute("""
|
|
SELECT id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik,
|
|
spreman_za_natjecanje, ekg, krv, spirometrija,
|
|
placeno, iznos, datum_placanja,
|
|
(vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
|
|
CASE
|
|
WHEN vrijedi_do IS NULL THEN 'nepoznato'
|
|
WHEN vrijedi_do < CURRENT_DATE THEN 'istekao'
|
|
WHEN vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
|
|
ELSE 'vazeci'
|
|
END AS status_calc
|
|
FROM pgz_sport.lijecnicki_pregledi
|
|
WHERE clan_id = %s
|
|
ORDER BY datum_pregleda DESC
|
|
""", (cid,))
|
|
lijecnicki = [_row(r) for r in cur.fetchall()]
|
|
|
|
# ČLANARINE
|
|
cur.execute("""
|
|
SELECT id, godina, razdoblje, iznos_propisan, iznos_placen,
|
|
(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug,
|
|
datum_uplate, status, racun_broj, referenca, napomena
|
|
FROM pgz_sport.clanarine
|
|
WHERE clan_id = %s
|
|
ORDER BY godina DESC
|
|
""", (cid,))
|
|
clanarine = [_row(r) for r in cur.fetchall()]
|
|
|
|
# DOKUMENTI (preko clan_godisnjak)
|
|
cur.execute("""
|
|
SELECT cg.id AS link_id, cg.godina, cg.snippet, cg.has_medal, cg.has_kategorija,
|
|
d.id AS dokument_id, d.title, d.url, d.pdf_url, d.izvor_url,
|
|
d.vrsta, d.organizacija, d.izdano_datum
|
|
FROM pgz_sport.clan_godisnjak cg
|
|
JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id
|
|
WHERE cg.clan_id = %s
|
|
ORDER BY cg.godina DESC
|
|
LIMIT 50
|
|
""", (cid,))
|
|
dokumenti = [_row(r) for r in cur.fetchall()]
|
|
|
|
# OBRASCI (form_submissions)
|
|
cur.execute("""
|
|
SELECT s.id, s.template_id, s.template_code, s.status, s.reference_no,
|
|
s.submitted_at, s.created_at,
|
|
t.naziv AS template_naziv, t.kategorija
|
|
FROM pgz_sport.form_submissions s
|
|
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
|
|
WHERE s.clan_id = %s
|
|
ORDER BY s.created_at DESC
|
|
""", (cid,))
|
|
obrasci = [_row(r) for r in cur.fetchall()]
|
|
|
|
# NAGRADE
|
|
cur.execute("""
|
|
SELECT id, godina, sezona, natjecanje, razina_natjecanja,
|
|
dobna_kategorija, disciplina, plasman, klub_naziv
|
|
FROM pgz_sport.clan_nagrada
|
|
WHERE clan_id = %s
|
|
ORDER BY godina DESC NULLS LAST
|
|
LIMIT 50
|
|
""", (cid,))
|
|
nagrade = [_row(r) for r in cur.fetchall()]
|
|
|
|
# POVIJEST KLUBOVA (iz clan_sezona.klub_naziv distinct)
|
|
cur.execute("""
|
|
SELECT klub_naziv, MIN(sezona) AS od, MAX(sezona) AS do_, COUNT(*) AS broj_sezona
|
|
FROM pgz_sport.clan_sezona
|
|
WHERE clan_id = %s AND klub_naziv IS NOT NULL
|
|
GROUP BY klub_naziv
|
|
ORDER BY MAX(sezona) DESC
|
|
""", (cid,))
|
|
povijest_klubova = [_row(r) for r in cur.fetchall()]
|
|
|
|
# KPI / sažetak za panel
|
|
dug_total = sum(float(r.get("dug") or 0) for r in clanarine)
|
|
placeno_total = sum(float(r.get("iznos_placen") or 0) for r in clanarine)
|
|
propisan_total = sum(float(r.get("iznos_propisan") or 0) for r in clanarine)
|
|
last_lij = lijecnicki[0] if lijecnicki else None
|
|
nastupi_total = sum(int(r.get("nastupi") or 0) for r in sezone)
|
|
pogoci_total = sum(int(r.get("pogoci") or 0) for r in sezone)
|
|
|
|
return {
|
|
"clan": _row(c),
|
|
"klub": _row(klub) if klub.get("id") else None,
|
|
"kpi": {
|
|
"dug_clanarina_eur": round(dug_total, 2),
|
|
"placeno_clanarina_eur": round(placeno_total, 2),
|
|
"propisan_clanarina_eur": round(propisan_total, 2),
|
|
"lijecnicki_status": last_lij and last_lij.get("status_calc"),
|
|
"lijecnicki_dana_do_isteka": last_lij and last_lij.get("dana_do_isteka"),
|
|
"broj_sezona": len(sezone),
|
|
"broj_utakmica_zadnjih": len(utakmice),
|
|
"nastupi_total": nastupi_total,
|
|
"pogoci_total": pogoci_total,
|
|
"broj_obrazaca": len(obrasci),
|
|
"broj_nagrada": len(nagrade),
|
|
},
|
|
"sezone": sezone,
|
|
"utakmice_zadnje20": utakmice,
|
|
"lijecnicki": lijecnicki,
|
|
"clanarine": clanarine,
|
|
"dokumenti": dokumenti,
|
|
"obrasci": obrasci,
|
|
"nagrade": nagrade,
|
|
"povijest_klubova": povijest_klubova,
|
|
}
|
|
|
|
|
|
# ───────────── edit (PUT s permission gating) ─────────────
|
|
|
|
class ClanPatch(BaseModel):
|
|
# Sva potencijalno-editabilna polja (subset full schema-e):
|
|
ime: Optional[str] = None
|
|
prezime: Optional[str] = None
|
|
oib: Optional[str] = None
|
|
datum_rodenja: Optional[date] = None
|
|
spol: Optional[str] = None
|
|
mjesto_rodenja: Optional[str] = None
|
|
adresa: Optional[str] = None
|
|
grad: Optional[str] = None
|
|
postanski_broj: Optional[str] = None
|
|
email: Optional[str] = None
|
|
telefon: Optional[str] = None
|
|
kategorija: Optional[str] = None
|
|
podkategorija: Optional[str] = None
|
|
pozicija: Optional[str] = None
|
|
licenca_broj: Optional[str] = None
|
|
licenca_vrijedi_do: Optional[date] = None
|
|
reprezentativac: Optional[bool] = None
|
|
reprezentacija_kategorija: Optional[str] = None
|
|
kategoriziran: Optional[bool] = None
|
|
kategorija_hoo: Optional[int] = None
|
|
stipendiran: Optional[bool] = None
|
|
stipendija_iznos: Optional[float] = None
|
|
radno_pravni_status: Optional[str] = None
|
|
aktivan: Optional[bool] = None
|
|
datum_pristupa: Optional[date] = None
|
|
datum_napustanja: Optional[date] = None
|
|
napomena: Optional[str] = None
|
|
dominantna_noga: Optional[str] = None
|
|
visina_cm: Optional[int] = None
|
|
tezina_kg: Optional[int] = None
|
|
broj_dresa: Optional[int] = None
|
|
biografija: Optional[str] = None
|
|
sport: Optional[str] = None
|
|
uloga: Optional[str] = None
|
|
uloga_detalj: Optional[str] = None
|
|
klub_id: Optional[int] = None
|
|
slika_url: Optional[str] = None
|
|
|
|
|
|
@router.put("/clanovi/{cid}")
|
|
def update_clan(cid: int, patch: ClanPatch,
|
|
authorization: Optional[str] = Header(None),
|
|
x_role: Optional[str] = Header(None)):
|
|
role = (x_role or _resolve_role(authorization) or "viewer").lower()
|
|
|
|
requested = {k: v for k, v in patch.dict(exclude_unset=True).items() if v is not None}
|
|
if not requested:
|
|
raise HTTPException(400, "Nema polja za izmjenu")
|
|
|
|
allowed_fields = _check_field_perm(role, set(requested.keys()))
|
|
if not allowed_fields:
|
|
raise HTTPException(403, f"Role '{role}' nema dozvolu za nijedno od poslanih polja")
|
|
|
|
rejected = set(requested.keys()) - allowed_fields
|
|
final = {k: requested[k] for k in allowed_fields}
|
|
|
|
set_clauses = [f"{k} = %s" for k in final.keys()]
|
|
set_clauses.append("updated_at = now()")
|
|
params = list(final.values()) + [cid]
|
|
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"UPDATE pgz_sport.clanovi SET {', '.join(set_clauses)} WHERE id=%s RETURNING *",
|
|
params)
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(404, "Član ne postoji")
|
|
# audit log (best-effort)
|
|
try:
|
|
import json as _json
|
|
cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload)
|
|
VALUES (%s,%s,%s,%s::jsonb)""",
|
|
("clan", cid, "edit",
|
|
_json.dumps({"role": role,
|
|
"applied": list(final.keys()),
|
|
"rejected": list(rejected)})))
|
|
except Exception:
|
|
pass
|
|
conn.commit()
|
|
|
|
return {
|
|
"ok": True,
|
|
"id": cid,
|
|
"role": role,
|
|
"applied_fields": sorted(final.keys()),
|
|
"rejected_fields": sorted(rejected),
|
|
"clan": _row(r),
|
|
}
|
|
|
|
|
|
# ───────────── avatar upload ─────────────
|
|
|
|
@router.post("/clanovi/{cid}/avatar")
|
|
async def upload_avatar(cid: int, file: UploadFile = File(...),
|
|
authorization: Optional[str] = Header(None),
|
|
x_role: Optional[str] = Header(None)):
|
|
"""Upload avatar. R6 #2 demo mode: if there is no/invalid token,
|
|
accept upload but DO NOT persist (FS or DB) — return demo flag + mock URL.
|
|
Real save (FS + DB) requires a valid Bearer JWT for an authorized role."""
|
|
# validate file type early — applies to both demo and real
|
|
allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
ext_map = {"image/jpeg": "jpg", "image/png": "png",
|
|
"image/webp": "webp", "image/gif": "gif"}
|
|
ct = (file.content_type or "").lower()
|
|
if ct not in allowed_ct:
|
|
raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif")
|
|
|
|
contents = await file.read()
|
|
if len(contents) > 5 * 1024 * 1024:
|
|
raise HTTPException(413, "Slika prevelika (max 5 MB)")
|
|
|
|
# Try to resolve role from JWT (via auth_v2 — proper secret + revocation check)
|
|
resolved_role = ""
|
|
has_valid_auth = False
|
|
if authorization and authorization.lower().startswith("bearer "):
|
|
tok = authorization.split(" ", 1)[1].strip()
|
|
try:
|
|
import sys as _s; _s.path.insert(0, '/opt/pgz-sport')
|
|
from auth.auth_v2 import decode_token as _dt, _is_revoked as _rev
|
|
payload = _dt(tok)
|
|
if payload.get("typ") in (None, "access") and not _rev(payload.get("jti","")):
|
|
resolved_role = (payload.get("role") or "").lower()
|
|
has_valid_auth = True
|
|
except Exception:
|
|
has_valid_auth = False
|
|
role = (x_role or resolved_role or "").lower()
|
|
|
|
# ───── DEMO MODE: no/invalid token → mock storage ─────
|
|
if not has_valid_auth:
|
|
import hashlib as _h
|
|
digest = _h.sha256(contents).hexdigest()[:12]
|
|
mock_fname = f"demo-{cid}-{digest}.{ext_map[ct]}"
|
|
return {
|
|
"ok": True,
|
|
"id": cid,
|
|
"demo_mode": True,
|
|
"message": "Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.",
|
|
"slika_url": None,
|
|
"mock_filename": mock_fname,
|
|
"size_bytes": len(contents),
|
|
"content_type": ct,
|
|
"sha256": digest,
|
|
}
|
|
|
|
# ───── REAL SAVE: valid auth + role check ─────
|
|
if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"):
|
|
raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara")
|
|
|
|
# provjeri da član postoji
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,))
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(404, "Član ne postoji")
|
|
|
|
fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}"
|
|
fpath = UPLOADS_DIR / fname
|
|
with open(fpath, "wb") as fh:
|
|
fh.write(contents)
|
|
|
|
public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}"
|
|
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
old = r["slika_url"]
|
|
if old and PUBLIC_AVATAR_PREFIX in old:
|
|
try:
|
|
old_name = old.split("/")[-1]
|
|
old_path = UPLOADS_DIR / old_name
|
|
if old_path.exists() and str(old_path).startswith(str(UPLOADS_DIR)):
|
|
old_path.unlink()
|
|
except Exception:
|
|
pass
|
|
cur.execute("UPDATE pgz_sport.clanovi SET slika_url=%s, updated_at=now() WHERE id=%s",
|
|
(public_url, cid))
|
|
conn.commit()
|
|
return {
|
|
"ok": True,
|
|
"id": cid,
|
|
"demo_mode": False,
|
|
"slika_url": public_url,
|
|
"size_bytes": len(contents),
|
|
"content_type": ct,
|
|
"filename": fname,
|
|
}
|
|
|
|
|
|
# ───────────── permissions info (za UI) ─────────────
|
|
|
|
@router.get("/clanovi/permissions")
|
|
def permissions_matrix(role: Optional[str] = Query(None)):
|
|
if role:
|
|
r = role.lower()
|
|
allowed = EDITABLE_BY_ROLE.get(r, set())
|
|
return {"role": r, "editable": "ALL" if allowed == "ALL" else sorted(allowed)}
|
|
return {
|
|
"roles": {
|
|
r: ("ALL" if v == "ALL" else sorted(v))
|
|
for r, v in EDITABLE_BY_ROLE.items()
|
|
}
|
|
}
|