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)
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
#!/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}
|
||||
Reference in New Issue
Block a user