Files
pgz-sport/_backups/r3_cc5/crm_extras_router.py.pre_r6.1777937999
T
Damir Radulić f9ebcddf28 CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended:
- Was: /api/admin/* only
- Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT
- Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password,
  /password/reset, /reset-password, /setup-password, /google;
  /api/gdpr/consent; any path ending /avatar
- 14 mutating endpoints verified to return 401 without token

#2 Avatar upload demo mode (routers/clan_panel_router.py):
- Anonymous → returns {demo_mode:true, slika_url:null,
  message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'},
  no FS write, no DB write
- Authenticated (valid JWT, allowed role) → real save as before
- Auth check now uses auth.auth_v2.decode_token (proper secret + revocation)
  instead of the broken local _resolve_role

#3 Mock mailer (auth/mailer.py):
- send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl
- send_password_reset, send_invite helpers with HR text + HTML alt
- Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default)
- forgot-password and admin invite both call mailer; audit logs mail status

#5 Rate limiting on /api/auth/login:
- Per-user: 5 wrong attempts → 5-minute DB-backed lockout
  (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES.
- Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429
  Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful
  login clears the IP counter.
- Failed attempts respond '(N/5) — račun je zaključan na 5 minuta'
- New audit actions: login.ratelimit.ip; login.fail meta now
  includes fails count, locked, lock_minutes

#4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14
   mutating endpoints, public path whitelist, demo-mode avatar +
   real save, forgot-password e-mail to mailbox, no-leak unknown email,
   5-fail lockout, 423 during lockout, audit coverage.
2026-05-05 01:42:53 +02:00

1010 lines
41 KiB
Python

#!/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 re as _re
import sys
import zipfile
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, Header
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,
)
DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000"
DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport"
DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka"
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
col_letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + ["AA", "AB", "AC", "AD", "AE", "AF"]
for col_letter, h in zip(col_letters, 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
include_expired: bool = True # uključi i one koji su već istekli
@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. Ako include_expired=True, isto kreira jednu
notifikaciju (threshold=0) za već istekle.
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 []
# threshold=0 → već istekli (poseban "expired" bucket)
scan_buckets = [(thr, "uskoro") for thr in thresholds]
if body.include_expired:
scan_buckets.append((0, "expired"))
created = []
with _conn() as conn, conn.cursor() as cur:
for thr, kind in scan_buckets:
if kind == "expired":
where_window = "(l.vrijedi_do - CURRENT_DATE) < 0"
where_params = []
else:
where_window = "(l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s"
where_params = [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 {where_window}
{klub_filter}
""", where_params + 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
if r['dana'] is not None and r['dana'] < 0:
subject = f"⚠ Liječnički pregled ISTEKAO ({-r['dana']} dana): {r['clan']}"
msg_dana = f"istekao prije {-r['dana']} dana"
else:
subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}"
msg_dana = f"{r['dana']} dana ostalo"
body_txt = (
f"Liječnički pregled za sportaša {r['clan']} "
f"({r.get('klub') or '(bez kluba)'}) — vrijedi do {r['vrijedi_do']} "
f"{msg_dana}.\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]}
# ════════════════════════════════════════════════════
# R6 #2 — BATCH HUB-3 PDFs ZIP
# ════════════════════════════════════════════════════
class BulkZipIn(BaseModel):
ids: Optional[list[int]] = None
klub_id: Optional[int] = None
godina: Optional[int] = None
only_unpaid: bool = True
limit: int = 200
def _safe_filename(s: str) -> str:
s = (s or "x").strip()
s = _re.sub(r"[^\w\-\.]+", "_", s, flags=_re.UNICODE)
return s[:80] or "x"
@router.post("/clanarine/bulk/uplatnice.zip")
def bulk_uplatnice_zip(body: BulkZipIn):
"""
Generira ZIP archive sa svim HUB-3 PDF uplatnicama za odabrane članarine.
Filename pattern: <KlubSlug>/<Prezime_Ime>-<id>-<godina>.pdf
"""
where, params = [], []
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)
if body.only_unpaid and not body.ids:
where.append("c.status IN ('nepodmireno','djelomicno')")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(body.limit)
sql = f"""
SELECT c.id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime, cl.prezime, cl.adresa AS clan_adresa, cl.grad AS clan_grad,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
k.adresa AS klub_adresa, k.grad AS klub_grad
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_sql}
ORDER BY k.naziv NULLS LAST, cl.prezime, cl.ime
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
if not rows:
raise HTTPException(404, "Nema članarina za batch")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z:
manifest = []
for r in rows:
dug = float(r["dug"] or 0)
if dug <= 0:
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj_naziv = r.get("klub") or DEFAULT_PRIMATELJ_NAZIV
primatelj_adresa = ", ".join(
[x for x in [r.get("klub_adresa"), r.get("klub_grad")] if x]
) or DEFAULT_PRIMATELJ_ADRESA
platitelj_naziv = f"{r.get('ime') or ''} {r.get('prezime') or ''}".strip() or "Član"
platitelj_adresa = ", ".join(
[x for x in [r.get("clan_adresa"), r.get("clan_grad")] if x]
) or ""
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
try:
pdf = build_hub3_pdf(
platitelj_naziv=platitelj_naziv,
platitelj_adresa=platitelj_adresa,
primatelj_naziv=primatelj_naziv,
primatelj_adresa=primatelj_adresa,
iban=iban,
amount_eur=dug,
model="HR00",
poziv_na_broj=poziv,
opis=f"Članarina {r['godina']}{r.get('razdoblje') or 'godišnja'}",
sifra_namjene="OTHR",
)
except Exception as e:
manifest.append(f"{r['id']}\tERROR\t{e}")
continue
klub_dir = _safe_filename(primatelj_naziv)
fname = (f"{klub_dir}/"
f"{_safe_filename(r.get('prezime') or 'X')}_"
f"{_safe_filename(r.get('ime') or 'X')}-"
f"{r['id']}-{r['godina']}.pdf")
z.writestr(fname, pdf)
manifest.append(f"{r['id']}\t{fname}\t{dug:.2f} EUR\t{poziv}")
# Manifest TXT
z.writestr("_manifest.txt",
"ID\tFILENAME\tIZNOS\tPOZIV_NA_BROJ\n" + "\n".join(manifest))
# Manifest JSON
z.writestr("_manifest.json", _json.dumps(
{"count": len(rows),
"generated_at": datetime.now().isoformat(),
"items": [{"id": r["id"], "klub": r.get("klub"),
"clan": f"{r.get('ime','')} {r.get('prezime','')}".strip(),
"godina": r["godina"], "iznos_eur": float(r["dug"] or r["iznos_propisan"] or 0)}
for r in rows]},
ensure_ascii=False, indent=2))
fname = f"hub3-batch-{date.today().isoformat()}-{len(rows)}.zip"
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{fname}"',
"X-Batch-Count": str(len(rows))},
)
# ════════════════════════════════════════════════════
# R6 #3 — E-MAIL TEMPLATES (CRUD + render + send-mock)
# ════════════════════════════════════════════════════
def _render(tpl: str, vars: dict) -> str:
"""Vrlo jednostavan {{key}} render."""
if not tpl:
return ""
out = tpl
for k, v in (vars or {}).items():
out = out.replace("{{" + str(k) + "}}", "" if v is None else str(v))
return out
class EmailTemplateIn(BaseModel):
code: str
naziv: str
kategorija: Optional[str] = None
subject_tpl: str
body_tpl: str
variables: Optional[list[str]] = None
active: bool = True
class EmailTemplatePatch(BaseModel):
naziv: Optional[str] = None
kategorija: Optional[str] = None
subject_tpl: Optional[str] = None
body_tpl: Optional[str] = None
variables: Optional[list[str]] = None
active: Optional[bool] = None
@router.get("/email-templates")
def list_email_templates(kategorija: Optional[str] = Query(None),
active_only: bool = Query(True)):
where, params = [], []
if active_only:
where.append("active = TRUE")
if kategorija:
where.append("kategorija = %s"); params.append(kategorija)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT id, code, naziv, kategorija, subject_tpl, body_tpl,
variables, active, created_at, updated_at
FROM pgz_sport.email_templates
{where_sql}
ORDER BY kategorija NULLS LAST, naziv
""", params)
rows = [_row(r) for r in cur.fetchall()]
return {"count": len(rows), "templates": rows}
@router.get("/email-templates/{code_or_id}")
def get_email_template(code_or_id: str):
with _conn() as conn, conn.cursor() as cur:
if code_or_id.isdigit():
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
else:
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Email template ne postoji")
return _row(r)
@router.post("/email-templates")
def create_email_template(body: EmailTemplateIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
INSERT INTO pgz_sport.email_templates
(code, naziv, kategorija, subject_tpl, body_tpl, variables, active)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s)
RETURNING *
""", (body.code, body.naziv, body.kategorija, body.subject_tpl,
body.body_tpl, _json.dumps(body.variables or []), body.active))
r = cur.fetchone(); conn.commit()
return _row(r)
@router.put("/email-templates/{code_or_id}")
def update_email_template(code_or_id: str, body: EmailTemplatePatch):
fields, params = [], []
for f in ("naziv", "kategorija", "subject_tpl", "body_tpl", "active"):
v = getattr(body, f)
if v is not None:
fields.append(f"{f} = %s"); params.append(v)
if body.variables is not None:
fields.append("variables = %s::jsonb"); params.append(_json.dumps(body.variables))
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at = now()")
where_col = "id" if code_or_id.isdigit() else "code"
where_val = int(code_or_id) if code_or_id.isdigit() else code_or_id
params.append(where_val)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.email_templates SET {', '.join(fields)} WHERE {where_col}=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Template ne postoji")
conn.commit()
return _row(r)
class EmailRenderIn(BaseModel):
variables: dict = {}
@router.post("/email-templates/{code_or_id}/render")
def render_email_template(code_or_id: str, body: EmailRenderIn):
"""Vrati subject/body s popunjenim {{vars}}."""
with _conn() as conn, conn.cursor() as cur:
if code_or_id.isdigit():
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
else:
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
t = cur.fetchone()
if not t:
raise HTTPException(404, "Template ne postoji")
return {
"code": t["code"],
"naziv": t["naziv"],
"subject": _render(t["subject_tpl"], body.variables),
"body": _render(t["body_tpl"], body.variables),
"variables_provided": list(body.variables.keys()),
"variables_required": t.get("variables") or [],
}
class EmailSendIn(BaseModel):
to: Optional[str] = None
user_id: Optional[int] = None
variables: dict = {}
schedule_inapp: bool = True
@router.post("/email-templates/{code_or_id}/send")
def send_email_template(code_or_id: str, body: EmailSendIn):
"""
Mock send: rendera template i upiše u notifications (channel=email + inapp).
Stvarni SMTP nije konfiguriran.
"""
with _conn() as conn, conn.cursor() as cur:
if code_or_id.isdigit():
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
else:
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
t = cur.fetchone()
if not t:
raise HTTPException(404, "Template ne postoji")
subject = _render(t["subject_tpl"], body.variables)
body_txt = _render(t["body_tpl"], body.variables)
meta = _json.dumps({"template_code": t["code"],
"to": body.to,
"variables": body.variables})
ids = []
if body.to:
cur.execute("""INSERT INTO pgz_sport.notifications
(user_id, channel, subject, body, status, scheduled_at, meta)
VALUES (%s,'email',%s,%s,'pending',now(),%s::jsonb)
RETURNING id""",
(body.user_id, subject, body_txt, meta))
ids.append({"channel": "email", "id": cur.fetchone()["id"]})
if body.schedule_inapp:
cur.execute("""INSERT INTO pgz_sport.notifications
(user_id, channel, subject, body, status, scheduled_at, meta)
VALUES (%s,'inapp',%s,%s,'pending',now(),%s::jsonb)
RETURNING id""",
(body.user_id, subject, body_txt, meta))
ids.append({"channel": "inapp", "id": cur.fetchone()["id"]})
conn.commit()
return {"ok": True, "queued": ids, "subject": subject,
"body_preview": body_txt[:200]}
# ════════════════════════════════════════════════════
# R6 #4 — /api/notifications/me (alias na /api/crm/notifications/me)
# ════════════════════════════════════════════════════
def _resolve_user_id(authorization: Optional[str], x_user_id: Optional[str]) -> Optional[int]:
"""
Priority:
1) X-User-Id header (UI / debug)
2) JWT 'sub' claim iz Bearer tokena (auth_v2)
"""
if x_user_id:
try:
return int(x_user_id)
except (TypeError, ValueError):
pass
if not authorization:
return None
tok = authorization.replace("Bearer ", "").strip()
try:
import jwt as _jwt # type: ignore
for secret in (
__import__("os").environ.get("JWT_SECRET"),
"rinet-jwt-secret-2026",
):
if not secret:
continue
try:
payload = _jwt.decode(tok, secret, algorithms=["HS256"])
sub = payload.get("sub") or payload.get("user_id")
if sub is not None:
return int(sub)
except Exception:
continue
except Exception:
pass
return None
@router.get("/notifications/me")
def my_notifications(
only_unread: bool = Query(True),
channel: Optional[str] = Query(None),
limit: int = Query(50, le=200),
authorization: Optional[str] = Header(None),
x_user_id: Optional[str] = Header(None),
):
"""
Lista notifikacija za current usera (iz JWT sub ili X-User-Id headera).
Kao fallback (kad nije autentikiran) vraća notifikacije BEZ user_id
(broadcast / system).
"""
user_id = _resolve_user_id(authorization, x_user_id)
where = []
params: list = []
if user_id is None:
# broadcast: notifs bez user_id
where.append("user_id IS NULL")
else:
where.append("(user_id = %s OR user_id IS NULL)"); params.append(user_id)
if only_unread:
where.append("read_at IS NULL")
if channel:
where.append("channel = %s"); params.append(channel)
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 {' AND '.join(where)}
ORDER BY scheduled_at DESC NULLS LAST
LIMIT %s
""", params)
rows = [_row(r) for r in cur.fetchall()]
# summary za badge
sum_where = ["read_at IS NULL"]
sum_params = []
if user_id is not None:
sum_where.append("(user_id = %s OR user_id IS NULL)")
sum_params.append(user_id)
else:
sum_where.append("user_id IS NULL")
cur.execute(f"""
SELECT COUNT(*) AS unread,
COUNT(*) FILTER (WHERE channel='inapp') AS unread_inapp,
COUNT(*) FILTER (WHERE channel='email') AS unread_email
FROM pgz_sport.notifications
WHERE {' AND '.join(sum_where)}
""", sum_params)
summary = _row(cur.fetchone())
return {
"user_id": user_id,
"count": len(rows),
"summary": summary,
"rows": rows,
}
# ════════════════════════════════════════════════════
# Alias router: /api/notifications/me (bez /crm prefiksa)
# ════════════════════════════════════════════════════
alias_router = APIRouter(prefix="/api/notifications", tags=["notifications-alias"])
@alias_router.get("/me")
def my_notifications_alias(
only_unread: bool = Query(True),
channel: Optional[str] = Query(None),
limit: int = Query(50, le=200),
authorization: Optional[str] = Header(None),
x_user_id: Optional[str] = Header(None),
):
"""Alias za /api/crm/notifications/me — kompatibilnost s /api/notifications/me."""
return my_notifications(only_unread=only_unread, channel=channel, limit=limit,
authorization=authorization, x_user_id=x_user_id)