Files
pgz-sport/routers/notif_router.py
T
damir f7b5114f58 PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)
nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
2026-05-05 13:51:07 +02:00

246 lines
7.9 KiB
Python

#!/usr/bin/env python3
# ==============================================================================
# notif_router.py — Notification center API for /app#notif
# Author : Damir Radulić — dradulic@outlook.com / damir@rinet.one
# Version: 1.0.0
# Date : 2026-05-05
# Purpose: REST endpoints under /api/v2/notif/* powering the in-app
# notification center. Operates on pgz_sport.notifications table
# (extended by migrations/notifications_20260505.sql).
# Routes : GET /api/v2/notif/list?unread_only=&limit=&user_id=
# GET /api/v2/notif/count?user_id=
# POST /api/v2/notif/{id}/read
# POST /api/v2/notif/mark-all-read
# DELETE /api/v2/notif/{id}
# ==============================================================================
from __future__ import annotations
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, HTTPException, Query, Header
from pydantic import BaseModel
import psycopg2
import psycopg2.extras
# Reuse the v2 DB config (same as pgz_sport_v2_router)
DB = dict(
host="10.10.0.2",
port=6432,
dbname="rinet_v3",
user="rinet",
password="R1net2026!SecureDB#v7",
)
router = APIRouter(prefix="/api/v2/notif", tags=["pgz_sport_notif"])
# ----------------------------- helpers ---------------------------------------
def _conn():
return psycopg2.connect(**DB)
def _row(r) -> Dict[str, Any]:
if r is None:
return {}
if isinstance(r, dict):
d = dict(r)
else:
d = dict(r)
# JSON-friendly conversions
for k, v in list(d.items()):
if hasattr(v, "isoformat"):
d[k] = v.isoformat()
return d
def _resolve_user_id(authorization: Optional[str]) -> Optional[int]:
"""Best-effort: pull user_id from Bearer token. Tries JWT (auth_v2) first
then falls back to legacy session-token-hash lookup."""
if not authorization:
return None
token = authorization.replace("Bearer ", "").strip()
if not token:
return None
# 1) Try JWT (preferred — current auth)
try:
from auth.auth_v2 import decode_token # type: ignore
payload = decode_token(token)
sub = payload.get("sub")
if sub is not None:
try:
return int(sub)
except (TypeError, ValueError):
pass
except Exception:
pass
# 2) Legacy: SHA-256 hash lookup in user_sessions
import hashlib
th = hashlib.sha256(token.encode()).hexdigest()
try:
with _conn() as c:
cur = c.cursor()
cur.execute(
"""SELECT user_id FROM pgz_sport.user_sessions
WHERE token_hash=%s AND revoked=false AND expires_at>now()
LIMIT 1""",
(th,),
)
r = cur.fetchone()
return r[0] if r else None
except Exception:
return None
# ----------------------------- LIST ------------------------------------------
@router.get("/list")
def notif_list(
unread_only: bool = Query(False),
limit: int = Query(50, ge=1, le=500),
user_id: Optional[int] = Query(None, description="Override (admin); else taken from JWT"),
authorization: Optional[str] = Header(None),
):
"""
Returns InApp notifications visible to the user. System-wide (user_id IS NULL)
rows are always included. If no auth + no user_id, returns only system-wide.
"""
uid = user_id if user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'"]
params: List[Any] = []
if uid is not None:
where.append("(user_id = %s OR user_id IS NULL)")
params.append(uid)
else:
where.append("user_id IS NULL")
if unread_only:
where.append("COALESCE(is_read, false) = false")
sql = f"""
SELECT id, user_id, kind, title, COALESCE(subject, title) AS subject, body,
link, COALESCE(is_read, read_at IS NOT NULL) AS is_read,
status, created_at, read_at
FROM pgz_sport.notifications
WHERE {' AND '.join(where)}
ORDER BY created_at DESC NULLS LAST, id DESC
LIMIT %s
"""
params.append(limit)
with _conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
return {"count": len(rows), "rows": rows, "user_id": uid}
# ----------------------------- COUNT -----------------------------------------
@router.get("/count")
def notif_count(
user_id: Optional[int] = Query(None),
authorization: Optional[str] = Header(None),
):
uid = user_id if user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'"]
params: List[Any] = []
if uid is not None:
where.append("(user_id = %s OR user_id IS NULL)")
params.append(uid)
else:
where.append("user_id IS NULL")
sql = f"""
SELECT
COUNT(*) FILTER (WHERE COALESCE(is_read, read_at IS NOT NULL) = false) AS unread,
COUNT(*) AS total
FROM pgz_sport.notifications
WHERE {' AND '.join(where)}
"""
with _conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
r = cur.fetchone() or {"unread": 0, "total": 0}
return {
"unread": int(r["unread"] or 0),
"total": int(r["total"] or 0),
"user_id": uid,
}
# ----------------------------- MARK ONE READ ---------------------------------
@router.post("/{nid}/read")
def notif_mark_read(nid: int):
with _conn() as conn:
cur = conn.cursor()
cur.execute(
"""UPDATE pgz_sport.notifications
SET is_read = true,
read_at = COALESCE(read_at, now()),
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
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, "is_read": True}
# ----------------------------- MARK ALL READ ---------------------------------
class MarkAllIn(BaseModel):
user_id: Optional[int] = None
include_system: bool = True
@router.post("/mark-all-read")
def notif_mark_all_read(
body: Optional[MarkAllIn] = None,
authorization: Optional[str] = Header(None),
):
body = body or MarkAllIn()
uid = body.user_id if body.user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'", "COALESCE(is_read, false) = false"]
params: List[Any] = []
if uid is not None:
if body.include_system:
where.append("(user_id = %s OR user_id IS NULL)")
else:
where.append("user_id = %s")
params.append(uid)
else:
where.append("user_id IS NULL")
with _conn() as conn:
cur = conn.cursor()
cur.execute(
f"""UPDATE pgz_sport.notifications
SET is_read = true,
read_at = COALESCE(read_at, now()),
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
WHERE {' AND '.join(where)}
RETURNING id""",
params,
)
ids = [r[0] for r in cur.fetchall()]
conn.commit()
return {"ok": True, "marked_read": len(ids), "ids": ids[:500], "user_id": uid}
# ----------------------------- DELETE ----------------------------------------
@router.delete("/{nid}")
def notif_delete(nid: int):
with _conn() as conn:
cur = conn.cursor()
cur.execute(
"DELETE FROM pgz_sport.notifications 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, "deleted": True}