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:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user