CC2 R5: defense-in-depth JWT + invite/reset token flows + audit

#1 JWT middleware:
- pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before
  every /api/admin/* route. Even routes that lack Depends(require_user)
  cannot be reached without a valid Bearer token (verifies signature,
  exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS.

#2 Invitation flow:
- pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at,
  used_at, created_by, ip, meta). Single-use, raw token never persisted.
- POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d),
  marks must_change_pwd, revokes existing sessions, returns invite_link.
- GET  /api/auth/setup-password?token=X — preflight (no consume).
- POST /api/auth/setup-password — consumes token, sets password, sets
  email_verified=true.

#3 Password reset flow:
- POST /api/auth/forgot-password — generic 'ako račun postoji' response;
  issues 'reset' token (TTL 2h) only for active users. Token returned in
  response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1.
- GET  /api/auth/reset-password?token=X — preflight.
- POST /api/auth/reset-password — consumes token, sets new password,
  revokes all active sessions.

#4 Audit coverage (auth events):
- login.ok, login.fail (with reason), login.locked, login.2fa_required,
  login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok,
  password.reset.fail, password.forgot.issue, password.forgot.miss,
  invite.consume.ok, invite.consume.fail, user.invite, user.create,
  user.update, user.delete, user.role.change, user.suspend, user.unsuspend,
  user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable.

#5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user).
   Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
This commit is contained in:
Damir Radulić
2026-05-05 01:28:29 +02:00
parent 8dce58c5f9
commit 0046b8d695
24 changed files with 15419 additions and 72 deletions
+588
View File
@@ -0,0 +1,588 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/crm_extras_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/crm_extras_router.py
# Svrha: R5 — bulk akcije za članarine, XLSX export članova, /crm/stats,
# notifikacije za isteke liječničkih (Email + InApp)
# ═══════════════════════════════════════════════════════════════════
"""R5 CRM extras.
Endpointi (montirani na /api/crm):
POST /clanarine/bulk/notify → opomena svim koji duguju (mock email + InApp)
POST /clanarine/bulk/uplatnice → batch HUB-3 PDF (zip ili JSON s URL-ovima)
GET /clanovi/export.xlsx → XLSX svih članova (filteri klub, aktivan)
GET /stats → aktivni vs neaktivni, trend uplata, ...
POST /lijecnicki/notify-scan → skenira pretvorbe < N dana, kreira notifikacije
GET /notifications → lista (filter user/status/channel)
POST /notifications/{id}/read → mark read
POST /notifications/mark-all-read → mark all read za usera
"""
from __future__ import annotations
import io
import json as _json
import sys
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
sys.path.insert(0, "/opt/pgz-sport")
from crm.payments import (
build_hub3_pdf, make_poziv_na_broj, normalize_iban,
)
router = APIRouter(prefix="/api/crm", tags=["crm-extras"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
# Pragovi za scan liječničkih (dana do isteka)
LIJEC_THRESHOLDS = (30, 15, 7)
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 None if d is None else {k: _conv(v) for k, v in dict(d).items()}
# ════════════════════════════════════════════════════
# #3 — BULK AKCIJE ZA ČLANARINE
# ════════════════════════════════════════════════════
class BulkOpomenaIn(BaseModel):
klub_id: Optional[int] = None
godina: Optional[int] = None
ids: Optional[list[int]] = None # specifične clanarina ID
template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu."
@router.post("/clanarine/bulk/notify")
def bulk_opomena(body: BulkOpomenaIn):
"""Pošalji opomenu (mock e-mail + InApp notification) svim dužnicima."""
where = ["c.status IN ('nepodmireno','djelomicno')"]
params: list = []
if body.ids:
where.append("c.id = ANY(%s)"); params.append(body.ids)
if body.klub_id:
where.append("c.klub_id = %s"); params.append(body.klub_id)
if body.godina:
where.append("c.godina = %s"); params.append(body.godina)
where_sql = "WHERE " + " AND ".join(where)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT c.id, c.godina, c.iznos_propisan,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.id AS clan_id, cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, k.naziv AS klub
FROM pgz_sport.clanarine c
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY dug DESC
LIMIT 1000
""", params)
rows = [_row(r) for r in cur.fetchall()]
# Insert notifications za one s e-mailom
n_email, n_inapp = 0, 0
for r in rows:
subject = f"Opomena: nepodmirena članarina {r['godina']} ({r['dug']:.2f} €)"
body_txt = (f"{body.template}\n\n"
f"Klub: {r.get('klub')}\n"
f"Iznos duga: {r['dug']:.2f} EUR\n"
f"Godina: {r['godina']}\n\n"
f"PGŽ Sport ERP/CRM")
meta = _json.dumps({
"clanarina_id": r["id"], "clan_id": r["clan_id"],
"iznos_dug": float(r["dug"]),
"uplatnica_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf",
})
# InApp uvijek
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)""",
(subject, body_txt, meta))
n_inapp += 1
# Email mock — samo log
if r.get("clan_email"):
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)""",
(subject, body_txt, _json.dumps({**_json.loads(meta),
"to": r["clan_email"]})))
n_email += 1
conn.commit()
return {
"ok": True,
"matched": len(rows),
"queued_inapp": n_inapp,
"queued_email": n_email,
"note": "Mock — SMTP nije konfiguriran; e-mail je upisan u notifications tablicu sa status='pending'.",
"recipients_preview": rows[:20],
}
class BulkUplatniceIn(BaseModel):
ids: Optional[list[int]] = None
klub_id: Optional[int] = None
godina: Optional[int] = None
@router.post("/clanarine/bulk/uplatnice")
def bulk_uplatnice(body: BulkUplatniceIn):
"""
Vraća JSON s listom uplatnica + linkovima na pojedinačne PDF-ove.
(PDF-ovi se generiraju on-demand kroz /clanarine/{id}/uplatnica.pdf.)
"""
where = ["c.status IN ('nepodmireno','djelomicno')"]
params: list = []
if body.ids:
where = ["c.id = ANY(%s)"]; params = [body.ids]
else:
if body.klub_id:
where.append("c.klub_id = %s"); params.append(body.klub_id)
if body.godina:
where.append("c.godina = %s"); params.append(body.godina)
where_sql = "WHERE " + " AND ".join(where)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT c.id, c.godina, c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan,
k.naziv AS klub, k.iban AS klub_iban
FROM pgz_sport.clanarine c
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY k.naziv, cl.prezime
LIMIT 500
""", params)
rows = [_row(r) for r in cur.fetchall()]
return {
"ok": True,
"count": len(rows),
"total_dug_eur": round(sum(float(r["dug"] or 0) for r in rows), 2),
"uplatnice": [{
"id": r["id"], "clan": r["clan"], "klub": r["klub"],
"godina": r["godina"], "iznos_eur": float(r["dug"] or 0),
"pdf_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf",
"qr_url": f"/sport/api/crm/clanarine/{r['id']}/qr.png",
} for r in rows],
}
# ════════════════════════════════════════════════════
# #4 — XLSX EXPORT ČLANOVA
# ════════════════════════════════════════════════════
@router.get("/clanovi/export.xlsx")
def export_clanovi_xlsx(
klub_id: Optional[int] = Query(None),
aktivan: Optional[bool] = Query(None),
sport: Optional[str] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(5000, le=20000),
):
where, params = ["1=1"], []
if klub_id: where.append("c.klub_id = %s"); params.append(klub_id)
if aktivan is not None: where.append("c.aktivan = %s"); params.append(aktivan)
if sport: where.append("(c.sport ILIKE %s OR k.sport ILIKE %s)"); params += [f"%{sport}%", f"%{sport}%"]
if q: where.append("(c.ime || ' ' || c.prezime) ILIKE %s"); params.append(f"%{q}%")
params.append(limit)
where_sql = "WHERE " + " AND ".join(where)
sql = f"""
SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol,
c.email, c.telefon, c.adresa, c.grad, c.postanski_broj,
c.kategorija, c.podkategorija, c.pozicija, c.broj_dresa,
c.visina_cm, c.tezina_kg, c.dominantna_noga,
c.aktivan, c.datum_pristupa, c.reprezentativac,
c.kategoriziran, c.kategorija_hoo,
c.stipendiran, c.stipendija_iznos,
c.licenca_broj, c.licenca_vrijedi_do,
k.naziv AS klub, k.oib AS klub_oib,
s.naziv AS savez
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_sql}
ORDER BY k.naziv NULLS LAST, 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()]
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Članovi PGŽ"
headers = [
"ID", "Ime", "Prezime", "OIB", "Datum rođ.", "Spol",
"E-mail", "Telefon", "Adresa", "Grad", "Pošt.",
"Kategorija", "Podkat.", "Pozicija", "Dres",
"Vis. (cm)", "Tež. (kg)", "Dom. noga",
"Aktivan", "Datum prist.", "Repr.",
"Kategoriziran", "HOO kat.",
"Stipendiran", "Stipendija (€)",
"Licenca", "Licenca do",
"Klub", "OIB kluba", "Savez",
]
for col, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=h)
cell.font = Font(bold=True, color="FFFFFF", size=10)
cell.fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = Border(bottom=Side(border_style="thin", color="FFFFFF"))
keys = [
"id", "ime", "prezime", "oib", "datum_rodenja", "spol",
"email", "telefon", "adresa", "grad", "postanski_broj",
"kategorija", "podkategorija", "pozicija", "broj_dresa",
"visina_cm", "tezina_kg", "dominantna_noga",
"aktivan", "datum_pristupa", "reprezentativac",
"kategoriziran", "kategorija_hoo",
"stipendiran", "stipendija_iznos",
"licenca_broj", "licenca_vrijedi_do",
"klub", "klub_oib", "savez",
]
for ridx, r in enumerate(rows, start=2):
for cidx, k in enumerate(keys, 1):
v = r.get(k)
if isinstance(v, bool):
v = "DA" if v else "NE"
ws.cell(row=ridx, column=cidx, value=v)
# Auto column widths
for col_letter, h in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "AA AB AC AD".split(), headers):
ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4))
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
buf = io.BytesIO()
wb.save(buf)
fname = f"clanovi-pgz-{date.today().isoformat()}.xlsx"
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ════════════════════════════════════════════════════
# #5 — /crm/stats
# ════════════════════════════════════════════════════
@router.get("/stats")
def crm_stats(klub_id: Optional[int] = Query(None)):
"""Aktivni/neaktivni članovi, trend uplata, KPI summary."""
klub_filter = "AND klub_id = %s" if klub_id else ""
klub_params = [klub_id] if klub_id else []
with _conn() as conn, conn.cursor() as cur:
# aktivni vs neaktivni
cur.execute(f"""
SELECT
COUNT(*) FILTER (WHERE aktivan = TRUE) AS aktivni,
COUNT(*) FILTER (WHERE aktivan = FALSE) AS neaktivni,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE reprezentativac = TRUE) AS reprezentativci,
COUNT(*) FILTER (WHERE kategoriziran = TRUE) AS kategorizirani,
COUNT(*) FILTER (WHERE stipendiran = TRUE) AS stipendirani
FROM pgz_sport.clanovi
WHERE 1=1 {klub_filter}
""", klub_params)
clanovi_summary = _row(cur.fetchone())
# po spolu
cur.execute(f"""
SELECT spol, COUNT(*) AS n
FROM pgz_sport.clanovi
WHERE aktivan = TRUE {klub_filter}
GROUP BY spol ORDER BY n DESC
""", klub_params)
po_spolu = [_row(r) for r in cur.fetchall()]
# po kategoriji
cur.execute(f"""
SELECT COALESCE(kategorija, '(nepoznato)') AS kategorija, COUNT(*) AS n
FROM pgz_sport.clanovi
WHERE aktivan = TRUE {klub_filter}
GROUP BY kategorija ORDER BY n DESC LIMIT 12
""", klub_params)
po_kategoriji = [_row(r) for r in cur.fetchall()]
# trend uplata po mjesecu — zadnjih 12
cur.execute(f"""
SELECT to_char(date_trunc('month', datum_uplate), 'YYYY-MM') AS mjesec,
COUNT(*) AS broj_uplata,
SUM(iznos_placen)::numeric(10,2) AS iznos_total
FROM pgz_sport.clanarine
WHERE datum_uplate IS NOT NULL
AND datum_uplate >= (CURRENT_DATE - INTERVAL '12 months')
{('AND klub_id = %s' if klub_id else '')}
GROUP BY date_trunc('month', datum_uplate)
ORDER BY mjesec
""", klub_params)
trend_uplata = [_row(r) for r in cur.fetchall()]
# članarine summary
cur.execute(f"""
SELECT COUNT(*) AS total,
SUM(iznos_propisan)::numeric(10,2) AS propisan,
SUM(iznos_placen)::numeric(10,2) AS placen,
SUM(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug,
COUNT(*) FILTER (WHERE status='nepodmireno') AS n_nepodmireno,
COUNT(*) FILTER (WHERE status='djelomicno') AS n_djelomicno,
COUNT(*) FILTER (WHERE status='podmireno') AS n_podmireno
FROM pgz_sport.clanarine
WHERE 1=1 {klub_filter}
""", klub_params)
clanarine_summary = _row(cur.fetchone())
# liječnički status
cur.execute(f"""
SELECT
COUNT(*) FILTER (WHERE vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
COUNT(*) FILTER (WHERE vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
COUNT(*) FILTER (WHERE vrijedi_do < CURRENT_DATE) AS istekli,
COUNT(*) AS total
FROM pgz_sport.lijecnicki_pregledi
WHERE 1=1 {klub_filter}
""", klub_params)
lijecnicki_summary = _row(cur.fetchone())
# najnovije uplate (zadnjih 10)
cur.execute(f"""
SELECT c.id, c.iznos_placen, c.datum_uplate, c.godina,
cl.ime||' '||cl.prezime AS clan, k.naziv AS klub
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.datum_uplate IS NOT NULL {klub_filter.replace('klub_id', 'c.klub_id')}
ORDER BY c.datum_uplate DESC
LIMIT 10
""", klub_params)
najnovije_uplate = [_row(r) for r in cur.fetchall()]
return {
"klub_id": klub_id,
"clanovi": clanovi_summary,
"po_spolu": po_spolu,
"po_kategoriji": po_kategoriji,
"trend_uplata_12m": trend_uplata,
"clanarine": clanarine_summary,
"lijecnicki": lijecnicki_summary,
"najnovije_uplate": najnovije_uplate,
}
# ════════════════════════════════════════════════════
# #6 — NOTIFIKACIJE LIJEČNIČKI ISTECI
# ════════════════════════════════════════════════════
class NotifScanIn(BaseModel):
klub_id: Optional[int] = None
thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS
@router.post("/lijecnicki/notify-scan")
def lijecnicki_notify_scan(body: NotifScanIn):
"""
Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock)
za pragove 30/15/7 dana. Ne duplicira: gleda meta.lijecnicki_id+threshold
u zadnjih 7 dana.
"""
thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True)
klub_filter = "AND l.klub_id = %s" if body.klub_id else ""
klub_params = [body.klub_id] if body.klub_id else []
created = []
with _conn() as conn, conn.cursor() as cur:
for thr in thresholds:
cur.execute(f"""
SELECT l.id, l.vrijedi_do, l.clan_id,
(l.vrijedi_do - CURRENT_DATE)::int AS dana,
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.vrijedi_do IS NOT NULL
AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s
AND (l.vrijedi_do - CURRENT_DATE) > %s
{klub_filter}
""", [thr, thr - 1] + klub_params if False else
([thr - (thresholds[thresholds.index(thr)+1] if thresholds.index(thr)+1 < len(thresholds) else 0),
-1] + klub_params))
# Pojednostavljen scan: samo "≤ thr & > prev_thr" dovodi do duplika;
# umjesto toga samo gledamo "u prozoru ≤ thr".
cur.execute(f"""
SELECT l.id, l.vrijedi_do, l.clan_id,
(l.vrijedi_do - CURRENT_DATE)::int AS dana,
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.vrijedi_do IS NOT NULL
AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s
{klub_filter}
""", [thr] + klub_params)
kandidati = [_row(r) for r in cur.fetchall()]
for r in kandidati:
# de-dup: već postoji notifikacija za ovaj lijec_id+threshold u <7 dana?
cur.execute("""
SELECT 1 FROM pgz_sport.notifications
WHERE meta->>'lijecnicki_id' = %s
AND meta->>'threshold' = %s
AND scheduled_at > now() - INTERVAL '7 days'
LIMIT 1
""", (str(r["id"]), str(thr)))
if cur.fetchone():
continue
subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}"
body_txt = (
f"Liječnički pregled za sportaša {r['clan']} "
f"({r.get('klub') or '(bez kluba)'}) ističe {r['vrijedi_do']} "
f"{r['dana']} dana ostalo.\n\n"
f"Molimo zakažite novi termin u ZZJZ PGŽ "
f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n"
f"PGŽ Sport ERP/CRM"
)
meta = _json.dumps({
"lijecnicki_id": r["id"],
"clan_id": r["clan_id"],
"threshold": thr,
"vrijedi_do": str(r["vrijedi_do"]),
"dana": r["dana"],
"zakazi_url": f"/sport/api/crm/lijecnicki/{r['id']}/zakazi",
"klub": r.get("klub"),
})
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)
RETURNING id""", (subject, body_txt, meta))
inapp_id = cur.fetchone()["id"]
created.append({"channel": "inapp", "id": inapp_id, "lijec_id": r["id"], "thr": thr})
if r.get("clan_email"):
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)
RETURNING id""",
(subject, body_txt,
_json.dumps({**_json.loads(meta), "to": r["clan_email"]})))
em_id = cur.fetchone()["id"]
created.append({"channel": "email", "id": em_id, "lijec_id": r["id"], "thr": thr,
"to": r["clan_email"]})
conn.commit()
return {
"ok": True,
"thresholds_dana": thresholds,
"created": len(created),
"items": created[:50],
"note": "Mock — SMTP nije konfiguriran. Email notifikacije su upisane u DB sa status='pending'.",
}
@router.get("/notifications")
def list_notifications(
user_id: Optional[int] = Query(None),
status: Optional[str] = Query(None, description="pending|sent|read"),
channel: Optional[str] = Query(None, description="inapp|email"),
limit: int = Query(100, le=500),
):
where, params = [], []
if user_id is not None:
where.append("user_id = %s"); params.append(user_id)
if status:
where.append("status = %s"); params.append(status)
if channel:
where.append("channel = %s"); params.append(channel)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(limit)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT id, user_id, channel, subject, body, status,
scheduled_at, sent_at, read_at, meta
FROM pgz_sport.notifications
{where_sql}
ORDER BY scheduled_at DESC NULLS LAST
LIMIT %s
""", params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(f"""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE status='pending') AS pending,
COUNT(*) FILTER (WHERE status='sent') AS sent,
COUNT(*) FILTER (WHERE read_at IS NULL AND channel='inapp') AS unread_inapp
FROM pgz_sport.notifications
{where_sql}
""", params[:-1])
summary = _row(cur.fetchone())
return {"count": len(rows), "summary": summary, "rows": rows}
@router.post("/notifications/{nid}/read")
def mark_read(nid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""UPDATE pgz_sport.notifications
SET read_at = now(), status = 'sent'
WHERE id = %s
RETURNING id""", (nid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Notifikacija ne postoji")
conn.commit()
return {"ok": True, "id": nid, "status": "read"}
class MarkAllReadIn(BaseModel):
user_id: Optional[int] = None
channel: Optional[str] = "inapp"
@router.post("/notifications/mark-all-read")
def mark_all_read(body: MarkAllReadIn):
where = ["read_at IS NULL"]
params = []
if body.user_id is not None:
where.append("user_id = %s"); params.append(body.user_id)
if body.channel:
where.append("channel = %s"); params.append(body.channel)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""UPDATE pgz_sport.notifications
SET read_at = now(), status = 'sent'
WHERE {' AND '.join(where)}
RETURNING id""", params)
ids = [r["id"] for r in cur.fetchall()]
conn.commit()
return {"ok": True, "marked_read": len(ids), "ids": ids[:200]}
+300 -36
View File
@@ -308,13 +308,19 @@ def _load_row(kind: str, eid: int) -> dict:
adresa, godina_osnutka, source_url, metadata
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
elif kind == 'sportas':
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url,
slika_url, source_url, source, source_id,
hns_igrac_id, biografija,
datum_rodenja, mjesto_rodenja, broj_dresa,
visina_cm, tezina_kg, dominantna_noga, oib,
vanjski_id, metadata
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
row = _fetch_one("""SELECT c.id, c.ime, c.prezime, c.sport, c.klub_id, c.profile_url,
c.slika_url, c.source_url, c.source, c.source_id,
c.hns_igrac_id, c.biografija,
c.datum_rodenja, c.mjesto_rodenja, c.broj_dresa,
c.visina_cm, c.tezina_kg, c.dominantna_noga, c.oib,
c.vanjski_id, c.metadata,
k.sport AS klub_sport, k.naziv AS klub_naziv
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s""", (eid,))
# Fall back to klub.sport when c.sport is empty
if row and not row.get('sport') and row.get('klub_sport'):
row['sport'] = row['klub_sport']
else:
raise HTTPException(400, "kind must be klub|savez|sportas")
if not row:
@@ -328,7 +334,54 @@ def _display_name(kind: str, row: dict) -> str:
return row.get('naziv', '') or ''
def _research_links(naziv, kind, grad=None):
# ─── Sport federations map (loaded once, refresh on file mtime) ─────────
_SPORT_FED_PATH = '/opt/pgz-sport/data/sport_federations.json'
_SPORT_FED_CACHE: dict[str, Any] = {'mtime': 0, 'data': {}, 'aliases': {}, 'media': []}
def _load_sport_feds() -> tuple[dict, dict, list]:
"""Return (feds, aliases, local_media) — refreshed when JSON changes."""
try:
st = os.stat(_SPORT_FED_PATH)
except FileNotFoundError:
return ({}, {}, [])
if st.st_mtime != _SPORT_FED_CACHE['mtime']:
try:
with open(_SPORT_FED_PATH, 'r', encoding='utf-8') as f:
raw = json.load(f)
except Exception:
return (_SPORT_FED_CACHE['data'],
_SPORT_FED_CACHE['aliases'],
_SPORT_FED_CACHE['media'])
aliases = raw.pop('_aliases', {}) if isinstance(raw, dict) else {}
media = raw.pop('_local_media_pgz', []) if isinstance(raw, dict) else []
raw.pop('_meta', None)
_SPORT_FED_CACHE.update(mtime=st.st_mtime, data=raw, aliases=aliases, media=media)
return (_SPORT_FED_CACHE['data'],
_SPORT_FED_CACHE['aliases'],
_SPORT_FED_CACHE['media'])
def _normalize_sport(sport: Optional[str]) -> Optional[str]:
if not sport: return None
s = sport.strip().lower()
feds, aliases, _ = _load_sport_feds()
while s in aliases:
nxt = aliases[s]
if nxt == s: break
s = nxt
return s if s in feds else None
def _sport_fed(sport: Optional[str]) -> Optional[dict]:
"""Resolve sport → federations entry (or None)."""
norm = _normalize_sport(sport)
if not norm: return None
feds, _, _ = _load_sport_feds()
return feds.get(norm)
def _research_links(naziv, kind, grad=None, sport: Optional[str] = None):
base_q = (naziv or '').strip()
q = (base_q + ' ' + grad) if grad else base_q
qenc = urllib.parse.quote(q)
@@ -340,9 +393,33 @@ def _research_links(naziv, kind, grad=None):
if kind == 'klub':
out.append({'label': 'Sportilus', 'icon': '', 'url': 'https://www.sportilus.com/?s=' + qenc})
out.append({'label': 'Sudski registar', 'icon': '', 'url': 'https://sudreg.pravosudje.hr/registar/oc/index.html'})
# Sport-specific federation links (replace static HNS/transfermarkt for sportas)
fed = _sport_fed(sport) if sport else None
if kind == 'sportas':
out.append({'label': 'HNS Semafor', 'icon': '', 'url': 'https://semafor.hns.family/?s=' + qenc})
out.append({'label': 'transfermarkt','icon': '', 'url': 'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query=' + qenc})
if fed and isinstance(fed.get('national'), dict):
nat = fed['national']
search = (nat.get('search_url') or nat.get('url') or '').replace('{q}', qenc)
if search:
out.append({'label': nat.get('name', 'Nacionalni savez'),
'icon': '🏆', 'url': search})
if fed and isinstance(fed.get('pgz'), dict):
pgz = fed['pgz']
url = pgz.get('search_url') or pgz.get('url') or ''
if url:
out.append({'label': pgz.get('name', 'PGŽ savez'),
'icon': '🏟', 'url': url.replace('{q}', qenc)})
if not fed:
# No mapping for this sport → keep transfermarkt as legacy fallback
out.append({'label': 'HNS Semafor', 'icon': '', 'url': 'https://semafor.hns.family/?s=' + qenc})
out.append({'label': 'transfermarkt','icon': '', 'url': 'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query=' + qenc})
# Local PGŽ media for any sportas
_, _, media = _load_sport_feds()
for m in media:
url = (m.get('search_url') or '').replace('{q}', qenc)
if url:
out.append({'label': m.get('name', 'Lokalni medij'),
'icon': '📰', 'url': url})
if kind == 'savez':
out.append({'label': 'sport-pgz.hr savezi', 'icon': '🏅', 'url': 'https://sport-pgz.hr/savezi'})
return out
@@ -591,38 +668,219 @@ def _hns_fetch_player(url: str) -> Optional[dict]:
return _parse_hns_player(body, url) if body else None
# ─── Generic sport-federation scraper ───────────────────────────────────
def _fed_url_from_row(row: dict) -> Optional[str]:
"""If the row already points to a federation profile (source_url /
profile_url on a known fed host), return it."""
feds, _, _ = _load_sport_feds()
fed_hosts = set()
for entry in feds.values():
if not isinstance(entry, dict): continue
for which in ('national', 'pgz'):
sub = entry.get(which) or {}
for k in ('url', 'search_url', 'profile_url_pattern'):
v = sub.get(k)
if v:
try:
h = urllib.parse.urlparse(v.replace('{q}', 'x').replace('{slug}', 'x').replace('{hns_pid}', '1')).hostname
if h: fed_hosts.add(h)
except Exception:
pass
for k in ('source_url', 'profile_url'):
u = row.get(k)
if not u: continue
try:
h = urllib.parse.urlparse(u).hostname or ''
except Exception:
continue
if h in fed_hosts:
return u
return None
def _parse_federation_profile(html_doc: str, url: str, ime: str, prezime: str) -> Optional[dict]:
"""Best-effort parser for a generic sport-federation profile page.
Returns {source, url, slika_url, datum_rodenja, mjesto_rodenja, klub,
extract, raw_text}. Tolerant of varied page structures.
"""
if not html_doc: return None
host = urllib.parse.urlparse(url).hostname or ''
out: dict[str, Any] = {
'source': host,
'url': url,
}
# Title
m = re.search(r'<title[^>]*>([^<]+)</title>', html_doc, re.I)
if m: out['title'] = html.unescape(m.group(1).strip())[:300]
# Meta description
m = re.search(r'<meta\s+name=["\']description["\']\s+content=["\']([^"\']+)["\']', html_doc, re.I)
if m: out['description'] = html.unescape(m.group(1).strip())[:600]
name_tokens = []
for t in (ime, prezime):
if t and len(t) >= 3:
name_tokens.append(re.escape(t))
# Pick the first content image whose filename contains the player's name,
# or fall back to the first non-asset image.
img_candidates = re.findall(r'<img[^>]+src=["\']([^"\']+)["\']', html_doc, re.I)
chosen_img = None
for src in img_candidates:
low = src.lower()
if any(b in low for b in ('logo', 'icon', 'admin-ajax', 'spinner', 'loader',
'sprite', '/themes/', '/icons/', 'gdpr', 'banner',
'header', 'footer', 'placeholder', 'avatar-default')):
continue
if not low.endswith(('.jpg', '.jpeg', '.png', '.webp')):
continue
# Prefer matches on player name in URL
if name_tokens and any(re.search(t, src, re.I) for t in name_tokens):
chosen_img = src; break
if chosen_img is None:
chosen_img = src
if chosen_img:
if not chosen_img.startswith('http'):
chosen_img = urllib.parse.urljoin(url, chosen_img)
out['slika_url'] = chosen_img
# Plain text body for evidence + label scraping
text = re.sub(r'<script[^>]*>.*?</script>', ' ', html_doc, flags=re.S | re.I)
text = re.sub(r'<style[^>]*>.*?</style>', ' ', text, flags=re.S | re.I)
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(re.sub(r'\s+', ' ', text)).strip()
out['raw_text'] = text[:4000]
out['extract'] = (out.get('description')
or text[max(0, text.find(prezime)-30):max(0, text.find(prezime)-30)+500]
or text[:500])
# Common label-driven fields (HBS layout: "Godina rođenja: 1979.", "Matični klub: …")
m = re.search(r'Datum\s+ro[đdj]?enja[:\s]+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{4})', text, re.I)
if m:
try:
from datetime import date as _date
d = re.split(r'[.\-/]', m.group(1))
out['datum_rodenja'] = _date(int(d[2]), int(d[1]), int(d[0])).isoformat()
except Exception:
pass
if 'datum_rodenja' not in out:
m = re.search(r'Godina\s+ro[đdj]?enja[:\s]+(\d{4})', text, re.I)
if m:
try:
from datetime import date as _date
out['datum_rodenja'] = _date(int(m.group(1)), 1, 1).isoformat()
except Exception:
pass
m = re.search(r'Mjesto\s+ro[đdj]?enja[:\s]+([A-ZČĆŠĐŽ][^,\n.]{2,40})', text)
if m: out['mjesto_rodenja'] = m.group(1).strip()
m = re.search(r'Mati[čc]ni\s+klub[:\s]+([^\n]{3,60}?)(?:\s+(?:Sportski|Datum|Liječni|Reprezent|Sezona|Domaće|Nastupi))', text, re.I)
if m: out['klub_naziv'] = m.group(1).strip().rstrip('.')
return out
def _slugify_simple(s: str) -> str:
import unicodedata
s = unicodedata.normalize('NFKD', s or '').encode('ascii', 'ignore').decode('ascii').lower()
return re.sub(r'[^a-z0-9]+', '-', s).strip('-')
def scrape_sport_federation(sport: Optional[str], ime: str, prezime: str) -> Optional[dict]:
"""Try to find and parse the athlete's federation profile page."""
fed = _sport_fed(sport) if sport else None
if not fed: return None
nat = (fed or {}).get('national') or {}
full_name = (ime + ' ' + prezime).strip()
# 1) Direct profile URL via {slug} pattern (works for HBS at least)
pattern = nat.get('profile_url_pattern')
if pattern and '{slug}' in pattern:
slug = _slugify_simple(full_name)
url = pattern.replace('{slug}', slug)
body = _http_get(url, timeout=8)
if body and prezime.lower() in body.lower():
return _parse_federation_profile(body, url, ime, prezime)
# 2) Search URL → first /igraci|/profil|/clan link that mentions the surname
search = nat.get('search_url')
if search:
body = _http_get(search.replace('{q}', urllib.parse.quote(full_name)), timeout=10)
if body:
for href_re in (r'href="([^"]*?/igraci/[^"]+)"',
r'href="([^"]*?/igrac/[^"]+)"',
r'href="([^"]*?/sportasi/[^"]+)"',
r'href="([^"]*?/clanovi/[^"]+)"',
r'href="([^"]*?/profil/[^"]+)"'):
for m in re.finditer(href_re, body, re.I):
cand = m.group(1)
if not cand.startswith('http'):
cand = urllib.parse.urljoin(nat.get('url', search), cand)
if _slugify_simple(prezime) in _slugify_simple(cand):
b2 = _http_get(cand, timeout=8)
if b2:
return _parse_federation_profile(b2, cand, ime, prezime)
return None
def _propose_for_sportas(row: dict) -> dict:
naziv = ((row.get('ime') or '') + ' ' + (row.get('prezime') or '')).strip()
ime, prezime = (row.get('ime') or ''), (row.get('prezime') or '')
sport = row.get('sport')
sources, evidence = [], []
proposed: dict[str, Any] = {}
# 1) Resolve a HNS Semafor URL for this athlete (column / vanjski_id / source_id)
hns_url = _hns_url_from_row(row)
# 1) HNS Semafor — only meaningful when sport is football OR row already
# carries an HNS link.
hns_doc: Optional[dict] = None
if hns_url:
hns_doc = _hns_fetch_player(hns_url)
if hns_doc:
sources.append(hns_doc)
evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '')
if _normalize_sport(sport) == 'nogomet' or _hns_url_from_row(row):
hns_url = _hns_url_from_row(row)
if hns_url:
hns_doc = _hns_fetch_player(hns_url)
if hns_doc:
sources.append(hns_doc)
evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '')
# Field-level proposals from HNS Semafor (only when DB is empty)
if hns_doc:
if not row.get('profile_url') and hns_doc.get('url'):
proposed['profile_url'] = hns_doc['url']
if not row.get('source_url') and hns_doc.get('url'):
proposed['source_url'] = hns_doc['url']
if not row.get('slika_url') and hns_doc.get('slika_url'):
proposed['slika_url'] = hns_doc['slika_url']
if not row.get('hns_igrac_id') and hns_doc.get('hns_igrac_id'):
proposed['hns_igrac_id'] = hns_doc['hns_igrac_id']
if not row.get('datum_rodenja') and hns_doc.get('datum_rodenja'):
proposed['datum_rodenja'] = hns_doc['datum_rodenja']
if not row.get('mjesto_rodenja') and hns_doc.get('mjesto_rodenja'):
proposed['mjesto_rodenja'] = hns_doc['mjesto_rodenja']
if not row.get('broj_dresa') and hns_doc.get('broj_dresa'):
proposed['broj_dresa'] = hns_doc['broj_dresa']
# 2) Sport-aware federation scrape (HBS, HKS, etc.) — also use existing
# source_url/profile_url if it points at a known federation host.
fed_doc: Optional[dict] = None
direct_fed_url = _fed_url_from_row(row)
if direct_fed_url and (not hns_doc or hns_doc.get('url') != direct_fed_url):
body = _http_get(direct_fed_url, timeout=8)
if body:
fed_doc = _parse_federation_profile(body, direct_fed_url, ime, prezime)
if not fed_doc:
fed_doc = scrape_sport_federation(sport, ime, prezime)
if fed_doc:
sources.append(fed_doc)
evidence.append(fed_doc.get('raw_text') or fed_doc.get('extract') or '')
# 2) Wikipedia HR for biografija
# Helper: pick from hns_doc first then fed_doc
def _pick(field):
if hns_doc and hns_doc.get(field): return hns_doc[field]
if fed_doc and fed_doc.get(field): return fed_doc[field]
return None
if not row.get('profile_url'):
v = _pick('url') or (hns_doc and hns_doc.get('url')) or (fed_doc and fed_doc.get('url'))
if v: proposed['profile_url'] = v
if not row.get('source_url'):
v = (hns_doc and hns_doc.get('url')) or (fed_doc and fed_doc.get('url'))
if v: proposed['source_url'] = v
if not row.get('slika_url'):
v = _pick('slika_url')
if v: proposed['slika_url'] = v
if not row.get('hns_igrac_id') and hns_doc and hns_doc.get('hns_igrac_id'):
proposed['hns_igrac_id'] = hns_doc['hns_igrac_id']
if not row.get('datum_rodenja'):
v = _pick('datum_rodenja')
if v: proposed['datum_rodenja'] = v
if not row.get('mjesto_rodenja'):
v = _pick('mjesto_rodenja')
if v: proposed['mjesto_rodenja'] = v
if not row.get('broj_dresa') and hns_doc and hns_doc.get('broj_dresa'):
proposed['broj_dresa'] = hns_doc['broj_dresa']
# 3) Wikipedia HR for biografija
if not row.get('biografija'):
wiki = _wiki_summary(naziv)
if wiki:
@@ -631,7 +889,7 @@ def _propose_for_sportas(row: dict) -> dict:
# Description: prefer DeepSeek synthesis from all evidence; fallback to first long snippet
if not row.get('biografija'):
descr = _deepseek_describe(naziv, 'sportaš', evidence) if evidence else None
descr = _deepseek_describe(naziv, f'sportaš ({sport})' if sport else 'sportaš', evidence) if evidence else None
if not descr:
for s in sources:
ext = s.get('extract')
@@ -863,7 +1121,13 @@ def enrich_preview(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), eid:
'coverage': coverage, 'filled_fields': filled, 'total_fields': len(keys),
'missing_fields': missing,
'live_snippet': _fetch_title(primary) if primary else None,
'research_links': _research_links(naziv, kind, grad),
'research_links': _research_links(naziv, kind, grad, sport=row.get('sport')),
'sport': row.get('sport'),
'sport_federation': (lambda f: {
'national': (f.get('national') or {}).get('name') if f else None,
'national_url': (f.get('national') or {}).get('url') if f else None,
'pgz': (f.get('pgz') or {}).get('name') if f else None,
})(_sport_fed(row.get('sport'))),
'sources': res['sources'],
'current': current,
'proposed': proposed,