Files
pgz-sport/_backups/r3_cc5/lijecnicki_router.py.post_m9.1777932881
T
Damir Radulić 8fe2478b84 CC2 R3 frontend: login.html + admin_users.html (M1+M2+M10 UI)
- static/login.html: dark Palantir-style login with PGŽ branding,
  Prijava se / Zaboravljena lozinka, demo account quick-fills,
  GDPR cookie banner, autostore tokens (local/session)
- static/admin_users.html: full user-management admin panel:
  - Collapsible left sidebar (Pregled, Korisnici, Tenanti, Audit log,
    Sigurnost, GDPR, links to ERP/CRM)
  - Users table with filters (q, tenant, role, status, limit)
  - + Dodaj korisnika modal (CRUD via /api/admin/users/*)
  - Suspend / unsuspend / reset-password / delete actions
  - Audit log viewer + Security KPIs + GDPR queue
  - Self-service: change pwd, export data (Art. 20), erasure request (Art. 17)
- pgz_sport_api.py: /login and /admin/users URL routes
- auth/seed_demo.py: added tajnik@atletski.pgz.hr/Atl2026!,
  admin@ak-kvarner.hr/Kvarner2026! demo users

5/5 live tests pass: login JWT, /me, /admin/users, /gdpr/consent, /gdpr/export

Note: existing admin.html (CC4 ERP/OCR work) preserved intact;
admin_users.html is dedicated user-mgmt page linked from sidebar.
2026-05-05 00:20:03 +02:00

570 lines
22 KiB
Python

#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/lijecnicki_router.py
# Svrha: M8 — CRM Liječnički pregledi + ZZJZ PGŽ scheduling integracija
# ═══════════════════════════════════════════════════════════════════
"""M8 Liječnički router.
Endpointi (montirani na /api/crm):
GET /lijecnicki → lista (filteri)
POST /lijecnicki → novi pregled
GET /lijecnicki/{id} → detalji
PUT /lijecnicki/{id} → update
DELETE /lijecnicki/{id} → brisanje
GET /lijecnicki/uskoro-isticu → istekao + idućih 30 dana
POST /lijecnicki/{id}/zakazi → zakaži termin (ZZJZ PGŽ mock)
GET /zzjz/termini → dostupni termini ZZJZ PGŽ (mock + scrape stub)
"""
from __future__ import annotations
import sys
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional, List
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-lijecnicki"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
ZZJZ_BASE = "https://zzjzpgz.hr"
ZZJZ_INFO = {
"naziv": "Nastavni zavod za javno zdravstvo PGŽ",
"adresa": "Krešimirova 52a, 51000 Rijeka",
"telefon": "+385 51 358 770",
"email": "info@zzjzpgz.hr",
"web": ZZJZ_BASE,
# Najbliži postojeći odjel — sportski liječnički ide preko adolescentne medicine
"url_sportska_medicina": f"{ZZJZ_BASE}/zavod/odjeli/odjel-za-skolsku-i-adolescentnu-medicinu/",
}
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)
return v
def _row(d):
return {k: _conv(v) for k, v in dict(d).items()}
# ───────────── modeli ─────────────
class LijecnickiIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
datum_pregleda: date
vrijedi_do: Optional[date] = None
vrsta_pregleda: Optional[str] = "temeljni"
ustanova: Optional[str] = "ZZJZ PGŽ"
lijecnik: Optional[str] = None
spreman_za_natjecanje: Optional[bool] = True
ekg: Optional[bool] = False
krv: Optional[bool] = False
spirometrija: Optional[bool] = False
nalaz: Optional[str] = None
komentar_lijecnika: Optional[str] = None
preporuke: Optional[str] = None
iznos: Optional[float] = 0
iznos_zzjz: Optional[float] = 0
iznos_klub: Optional[float] = 0
iznos_clan: Optional[float] = 0
datum_placanja: Optional[date] = None
placeno: Optional[bool] = False
racun_broj: Optional[str] = None
nacin_placanja: Optional[str] = None
napomena: Optional[str] = None
class LijecnickiPatch(BaseModel):
klub_id: Optional[int] = None
datum_pregleda: Optional[date] = None
vrijedi_do: Optional[date] = None
vrsta_pregleda: Optional[str] = None
ustanova: Optional[str] = None
lijecnik: Optional[str] = None
spreman_za_natjecanje: Optional[bool] = None
ekg: Optional[bool] = None
krv: Optional[bool] = None
spirometrija: Optional[bool] = None
nalaz: Optional[str] = None
komentar_lijecnika: Optional[str] = None
preporuke: Optional[str] = None
iznos: Optional[float] = None
iznos_zzjz: Optional[float] = None
iznos_klub: Optional[float] = None
iznos_clan: Optional[float] = None
datum_placanja: Optional[date] = None
placeno: Optional[bool] = None
racun_broj: Optional[str] = None
nacin_placanja: Optional[str] = None
napomena: Optional[str] = None
class ZakaziIn(BaseModel):
datum: date
vrijeme: Optional[str] = "09:00"
ustanova: Optional[str] = "ZZJZ PGŽ"
napomena: Optional[str] = None
# ───────────── lista ─────────────
@router.get("/lijecnicki")
def list_lijecnicki(
klub_id: Optional[int] = Query(None),
clan_id: Optional[int] = Query(None),
status: Optional[str] = Query(None,
description="vazeci|uskoro|istekao"),
placeno: Optional[bool] = Query(None),
sort: str = Query("vrijedi_do"),
order: str = Query("asc"),
limit: int = Query(500, le=2000),
):
where, params = [], []
if klub_id:
where.append("l.klub_id = %s"); params.append(klub_id)
if clan_id:
where.append("l.clan_id = %s"); params.append(clan_id)
if placeno is not None:
where.append("l.placeno = %s"); params.append(placeno)
# status_calc: vazeci = >30d, uskoro = 0..30d, istekao = <0
if status == "vazeci":
where.append("l.vrijedi_do > (CURRENT_DATE + INTERVAL '30 days')")
elif status == "uskoro":
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '30 days')")
elif status == "istekao":
where.append("l.vrijedi_do < CURRENT_DATE")
sort_map = {
"vrijedi_do": "l.vrijedi_do",
"datum_pregleda": "l.datum_pregleda",
"klub": "k.naziv",
"clan": "cl.prezime",
"iznos": "l.iznos",
}
sort_col = sort_map.get(sort, "l.vrijedi_do")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(limit)
sql = f"""
SELECT l.*,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib, cl.email AS clan_email,
k.naziv AS klub, k.oib AS klub_oib,
CASE
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
ELSE 'vazeci'
END AS status_calc,
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
{where_sql}
ORDER BY {sort_col} {order_sql} NULLS LAST
LIMIT %s
"""
sum_sql = f"""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE l.vrijedi_do < CURRENT_DATE) AS istekli,
COUNT(*) FILTER (WHERE l.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
COUNT(*) FILTER (WHERE l.vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
COUNT(*) FILTER (WHERE l.placeno IS TRUE) AS placeni,
COALESCE(SUM(l.iznos), 0)::numeric(10,2) AS total_iznos
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
{where_sql}
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(sum_sql, params[:-1])
summary = _row(cur.fetchone() or {})
return {"count": len(rows), "rows": rows, "summary": summary}
# ───────────── uskoro isticu (30 dana + istekli) ─────────────
@router.get("/lijecnicki/uskoro-isticu")
def list_uskoro_isticu(
klub_id: Optional[int] = Query(None),
days: int = Query(30, ge=1, le=180),
include_expired: bool = Query(True),
):
where = ["l.vrijedi_do IS NOT NULL"]
params: list = []
if include_expired:
where.append("l.vrijedi_do <= (CURRENT_DATE + (%s || ' days')::interval)")
else:
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + (%s || ' days')::interval)")
params.append(str(days))
if klub_id:
where.append("l.klub_id = %s"); params.append(klub_id)
sql = f"""
SELECT l.id, l.clan_id, l.klub_id, l.datum_pregleda, l.vrijedi_do,
l.vrsta_pregleda, l.ustanova, l.lijecnik, l.placeno,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
k.naziv AS klub, k.oib AS klub_oib,
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
CASE
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
ELSE 'uskoro'
END AS status_calc
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE {' AND '.join(where)}
ORDER BY l.vrijedi_do ASC
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
n_istekli = sum(1 for r in rows if (r.get("dana_do_isteka") or 0) < 0)
n_uskoro = len(rows) - n_istekli
return {"count": len(rows), "istekli": n_istekli, "uskoro": n_uskoro,
"days_window": days, "rows": rows}
# ───────────── detalji ─────────────
@router.get("/lijecnicki/{lid}")
def get_lijecnicki(lid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT l.*,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib, cl.email AS clan_email,
cl.telefon AS clan_telefon,
k.naziv AS klub, k.oib AS klub_oib,
CASE
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
ELSE 'vazeci'
END AS status_calc,
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.id = %s
""", (lid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
return _row(r)
# ───────────── kreiraj ─────────────
@router.post("/lijecnicki")
def create_lijecnicki(body: LijecnickiIn):
klub_id = body.klub_id
with _conn() as conn, conn.cursor() as cur:
if not klub_id:
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
r = cur.fetchone()
klub_id = r["klub_id"] if r else None
# default vrijedi_do = +1 godina ako nije postavljeno
vrijedi_do = body.vrijedi_do
if vrijedi_do is None:
vrijedi_do = body.datum_pregleda + timedelta(days=365)
cur.execute("""
INSERT INTO pgz_sport.lijecnicki_pregledi
(clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda,
ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija,
nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz,
iznos_klub, iznos_clan, datum_placanja, placeno, racun_broj,
nacin_placanja, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""", (body.clan_id, klub_id, body.datum_pregleda, vrijedi_do,
body.vrsta_pregleda, body.ustanova, body.lijecnik,
body.spreman_za_natjecanje, body.ekg, body.krv, body.spirometrija,
body.nalaz, body.komentar_lijecnika, body.preporuke,
body.iznos, body.iznos_zzjz, body.iznos_klub, body.iznos_clan,
body.datum_placanja, body.placeno, body.racun_broj,
body.nacin_placanja, body.napomena))
r = cur.fetchone()
conn.commit()
return _row(r)
# ───────────── update / delete ─────────────
@router.put("/lijecnicki/{lid}")
def update_lijecnicki(lid: int, patch: LijecnickiPatch):
fields, params = [], []
for f in ("klub_id", "datum_pregleda", "vrijedi_do", "vrsta_pregleda",
"ustanova", "lijecnik", "spreman_za_natjecanje",
"ekg", "krv", "spirometrija", "nalaz", "komentar_lijecnika",
"preporuke", "iznos", "iznos_zzjz", "iznos_klub", "iznos_clan",
"datum_placanja", "placeno", "racun_broj", "nacin_placanja",
"napomena"):
v = getattr(patch, f)
if v is not None:
fields.append(f"{f} = %s"); params.append(v)
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at = now()")
params.append(lid)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.lijecnicki_pregledi SET {', '.join(fields)} WHERE id=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
conn.commit()
return _row(r)
@router.delete("/lijecnicki/{lid}")
def delete_lijecnicki(lid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s RETURNING id", (lid,))
r = cur.fetchone()
conn.commit()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
return {"ok": True, "id": lid, "deleted": True}
# ───────────── ZZJZ PGŽ scheduling ─────────────
def _mock_zzjz_termini(week_start: date) -> list[dict]:
"""
Mock dostupnih termina za sportsku medicinu.
TODO: zamijeniti realnim scrapeom iz https://zzjzpgz.hr/djelatnosti/sportska-medicina/
Format termina: po danu (pon-pet), 09:00-15:00 svakih 30 min.
"""
out = []
times = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30",
"11:00", "11:30", "12:30", "13:00", "13:30", "14:00", "14:30"]
for d in range(5):
day = week_start + timedelta(days=d)
if day.weekday() >= 5:
continue
for t in times:
# pseudo-availability deterministic by day*hour
h = int(t.split(":")[0])
available = ((day.day + h) % 3) != 0
out.append({
"datum": day.isoformat(),
"vrijeme": t,
"doktor": "Dr. Sportska medicina",
"ustanova": "ZZJZ PGŽ",
"available": available,
"iznos_eur": 25.00,
})
return out
@router.get("/zzjz/info")
def zzjz_info():
"""Vraća kontakt + provjerava ima li online termin sustav (best-effort scrape)."""
online_booking = _detect_zzjz_booking()
return {**ZZJZ_INFO, "online_booking": online_booking}
def _detect_zzjz_booking() -> dict:
"""
Best-effort detekcija da li ZZJZ PGŽ ima online termin formu na stranici.
Vraća: {available: bool, url: str|None, kind: 'iframe'|'link'|'email'}
Ne baca iznimku — uvijek vrati strukturu (fallback je email).
"""
try:
import urllib.request
import re as _re
req = urllib.request.Request(ZZJZ_INFO["url_sportska_medicina"],
headers={"User-Agent": "PGZSport/1.0"})
with urllib.request.urlopen(req, timeout=4) as resp:
html = resp.read(200_000).decode("utf-8", errors="ignore")
# tražimo standardne oznake online booking sustava
patterns = [
r'(https?://[^"\']*(?:doktor|booking|narucivanje|naruci|termin)[^"\']*)',
r'<iframe[^>]+src="([^"]+)"',
]
for p in patterns:
m = _re.search(p, html, _re.IGNORECASE)
if m:
url = m.group(1)
if "iframe" in p:
return {"available": True, "url": url, "kind": "iframe"}
return {"available": True, "url": url, "kind": "link"}
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
"kind": "email",
"fallback_email": ZZJZ_INFO["email"],
"note": "Nije pronađen online sustav — koristi e-mail kontakt."}
except Exception as e:
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
"kind": "email",
"fallback_email": ZZJZ_INFO["email"],
"error": str(e)[:120],
"note": "Detekcija nije uspjela — fallback na e-mail."}
@router.get("/zzjz/termini")
def zzjz_termini(
od: Optional[date] = Query(None,
description="Početak tjedna; default = ovaj tjedan"),
):
"""
Vraća dostupne termine za sportsku medicinu pri ZZJZ PGŽ.
Trenutno: mock (deterministička dostupnost). Stvarna integracija
čeka API ili scraping form-e na zzjzpgz.hr.
"""
if od is None:
today = date.today()
od = today - timedelta(days=today.weekday())
termini = _mock_zzjz_termini(od)
return {
"ustanova": ZZJZ_INFO,
"week_start": od.isoformat(),
"count": len(termini),
"available": sum(1 for t in termini if t["available"]),
"termini": termini,
"note": "Mock podaci. Realni termini čekaju ZZJZ PGŽ API ili authorizirani scraper.",
}
@router.post("/lijecnicki/{lid}/zakazi")
def zakazi_termin(lid: int, body: ZakaziIn):
"""
Zakazuje termin za pregled.
- Ako ZZJZ PGŽ ima online booking → vraća iframe/deeplink URL.
- Ako nema → vraća mailto: deeplink za zahtjev e-mailom.
Status pregleda u DB se ažurira (ustanova + napomena).
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT l.id, l.clan_id, l.ustanova,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email,
k.naziv AS klub
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.id=%s
""", (lid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
new_napomena = (
f"Termin zakazan: {body.datum.isoformat()} {body.vrijeme} @ "
f"{body.ustanova}. {body.napomena or ''}"
).strip()
cur.execute("""
UPDATE pgz_sport.lijecnicki_pregledi
SET ustanova = COALESCE(%s, ustanova),
napomena = %s,
updated_at = now()
WHERE id = %s
RETURNING *
""", (body.ustanova, new_napomena, lid))
upd = cur.fetchone()
conn.commit()
booking = _detect_zzjz_booking()
from urllib.parse import quote as _q
subj = _q(f"Zahtjev za termin sportske medicine — {r.get('clan') or '(sportaš)'}")
body_email = _q(
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
f"Sportaš: {r.get('clan') or ''}\n"
f"Klub: {r.get('klub') or ''}\n"
f"Željeni datum: {body.datum.isoformat()} oko {body.vrijeme}\n"
f"Kontakt: {r.get('clan_email') or '(nepoznato)'}\n\n"
f"Lijep pozdrav,\nPGŽ Sport platforma"
)
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
return {
"ok": True,
"id": lid,
"zakazano_za": f"{body.datum.isoformat()} {body.vrijeme}",
"ustanova": body.ustanova,
"zzjz": ZZJZ_INFO,
"booking": booking,
"mailto": mailto,
"note": (
"Online booking detektiran — koristi 'booking.url' za iframe/redirect."
if booking.get("available") else
"Online booking nije pronađen — fallback: koristi 'mailto' za zahtjev e-mailom."
),
"pregled": _row(upd),
}
class ZakaziEmailIn(BaseModel):
klub_id: Optional[int] = None
clan_id: int
zeljeni_datum: Optional[date] = None
zeljeno_vrijeme: Optional[str] = "09:00"
napomena: Optional[str] = None
@router.post("/lijecnicki/zakazi-email")
def zakazi_email(body: ZakaziEmailIn):
"""
Bez postojećeg pregleda — generira mailto: link s pred-popunjenim
podacima sportaša/kluba za slanje zahtjeva ZZJZ PGŽ.
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT cl.id, cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
cl.datum_rodenja, cl.oib AS clan_oib,
k.naziv AS klub, k.oib AS klub_oib
FROM pgz_sport.clanovi cl
LEFT JOIN pgz_sport.klubovi k ON k.id = cl.klub_id
WHERE cl.id=%s
""", (body.clan_id,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
from urllib.parse import quote as _q
when = (body.zeljeni_datum.isoformat() if body.zeljeni_datum else "po dogovoru")
subj = _q(f"Zahtjev za termin sportske medicine — {r['clan']}")
body_email = _q(
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
f"Sportaš: {r['clan']}\n"
f"OIB: {r['clan_oib'] or ''}\n"
f"Datum rođenja: {r['datum_rodenja'] or ''}\n"
f"Klub: {r['klub'] or ''}\n"
f"Željeni termin: {when} oko {body.zeljeno_vrijeme}\n"
f"Kontakt: {r['clan_email'] or ''} / {r['clan_telefon'] or ''}\n\n"
f"Napomena: {body.napomena or ''}\n\n"
f"Lijep pozdrav,\nPGŽ Sport platforma"
)
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
booking = _detect_zzjz_booking()
return {
"ok": True,
"clan": r["clan"],
"zzjz": ZZJZ_INFO,
"booking": booking,
"mailto": mailto,
}