Files
pgz-sport/routers/kalendar_router.py
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

299 lines
9.6 KiB
Python

#!/usr/bin/env python3
# ===================================================================
# Fajl: routers/kalendar_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulic <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/kalendar_router.py
# Svrha: Kalendar CRUD (eventi/manifestacije/sastanci/termini) za /app#kalendar.
# Endpoints under /api/v2/kalendar:
# GET /events?from=&to=&klub_id=&savez_id=
# POST /events
# GET /events/{id}
# PUT /events/{id}
# DELETE /events/{id}
# ===================================================================
"""Kalendar v2 router.
Prefix: /api/v2/kalendar
Backed by table pgz_sport.kalendar_events.
"""
from __future__ import annotations
import hashlib
from datetime import date, datetime
from typing import Optional, List, Dict, Any
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query, Body, Depends, Header
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/v2/kalendar", tags=["kalendar-v2"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
ALLOWED_TYPES = {"event", "meeting", "manif", "training", "medical", "other"}
ALLOWED_COLORS = {"a", "b", "g", "r"}
# --------- DB helpers ---------
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date, datetime)):
return v.isoformat()
return v
def _row(d):
if d is None:
return None
return {k: _conv(v) for k, v in dict(d).items()}
def _rows(seq):
return [_row(r) for r in (seq or [])]
# --------- Auth ---------
# Supports both auth styles found in this codebase:
# 1) JWT (auth.auth_v2): token decoded, jti checked in user_sessions
# 2) Legacy opaque token: sha256(token) lookup in user_sessions
# Tries JWT first (canonical), falls back to legacy.
try:
from auth.auth_v2 import get_current_user as _jwt_current_user # type: ignore
except Exception:
_jwt_current_user = None # type: ignore
def _current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict[str, Any]]:
# Prefer JWT path (auth_v2)
if _jwt_current_user is not None:
try:
u = _jwt_current_user(authorization)
if u:
# Normalize key name used downstream
if "user_id" not in u and "id" in u:
u = dict(u)
u["user_id"] = u.get("id")
return u
except Exception:
pass
# Legacy opaque-token fallback
if not authorization:
return None
tok = authorization.replace("Bearer ", "").strip()
if not tok:
return None
th = hashlib.sha256(tok.encode()).hexdigest()
try:
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"""
SELECT s.user_id, u.email, u.user_type, u.klub_id, u.savez_id, u.aktivan,
u.ime, u.prezime
FROM pgz_sport.user_sessions s
JOIN pgz_sport.users u ON u.id = s.user_id
WHERE s.token_hash = %s
AND s.revoked = false
AND s.expires_at > now()
LIMIT 1
""",
(th,),
)
r = cur.fetchone()
if not r:
return None
d = _row(r)
if d.get("aktivan") is False:
return None
return d
except Exception:
return None
def _is_admin(user: Dict) -> bool:
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
def _require_user_optional(user=Depends(_current_user)):
"""Auth optional for GET (so /app dashboard works even pre-login).
Returns user dict or None."""
return user
def _require_user(user=Depends(_current_user)):
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
# --------- Models ---------
class EventIn(BaseModel):
title: str = Field(..., min_length=1, max_length=400)
start_at: str # ISO timestamp
end_at: Optional[str] = None
location: Optional[str] = None
description: Optional[str] = None
event_type: Optional[str] = "event"
color: Optional[str] = "b"
klub_id: Optional[int] = None
savez_id: Optional[int] = None
class EventPatch(BaseModel):
title: Optional[str] = None
start_at: Optional[str] = None
end_at: Optional[str] = None
location: Optional[str] = None
description: Optional[str] = None
event_type: Optional[str] = None
color: Optional[str] = None
klub_id: Optional[int] = None
savez_id: Optional[int] = None
def _validate_payload(p: Dict[str, Any]) -> None:
et = p.get("event_type")
if et is not None and et not in ALLOWED_TYPES:
raise HTTPException(400, f"event_type must be one of {sorted(ALLOWED_TYPES)}")
co = p.get("color")
if co is not None and co not in ALLOWED_COLORS:
raise HTTPException(400, f"color must be one of {sorted(ALLOWED_COLORS)}")
# --------- Endpoints ---------
@router.get("/events")
def list_events(
from_: Optional[str] = Query(None, alias="from"),
to: Optional[str] = None,
klub_id: Optional[int] = None,
savez_id: Optional[int] = None,
limit: int = Query(500, ge=1, le=2000),
user=Depends(_require_user_optional),
):
"""List events in [from, to). Defaults: no bound."""
where = ["1=1"]
params: List[Any] = []
if from_:
where.append("start_at >= %s")
params.append(from_)
if to:
where.append("start_at < %s")
params.append(to)
if klub_id is not None:
where.append("klub_id = %s")
params.append(klub_id)
if savez_id is not None:
where.append("savez_id = %s")
params.append(savez_id)
sql = (
"SELECT id, title, start_at, end_at, location, description, event_type, color, "
"klub_id, savez_id, created_by, created_at, updated_at "
"FROM pgz_sport.kalendar_events "
"WHERE " + " AND ".join(where) + " "
"ORDER BY start_at ASC LIMIT %s"
)
params.append(limit)
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, params)
rows = _rows(cur.fetchall())
return {"rows": rows, "count": len(rows)}
@router.get("/events/{eid}")
def get_event(eid: int, user=Depends(_require_user_optional)):
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"SELECT * FROM pgz_sport.kalendar_events WHERE id=%s",
(eid,),
)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Event not found")
return _row(r)
@router.post("/events")
def create_event(payload: EventIn, user=Depends(_require_user)):
p = payload.dict()
_validate_payload(p)
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"""
INSERT INTO pgz_sport.kalendar_events
(title, start_at, end_at, location, description, event_type, color,
klub_id, savez_id, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id, title, start_at, end_at, location, description, event_type, color,
klub_id, savez_id, created_by, created_at, updated_at
""",
(
p["title"],
p["start_at"],
p.get("end_at"),
p.get("location"),
p.get("description"),
p.get("event_type") or "event",
p.get("color") or "b",
p.get("klub_id"),
p.get("savez_id"),
user.get("user_id"),
),
)
new_row = cur.fetchone()
cn.commit()
return _row(new_row)
@router.put("/events/{eid}")
def update_event(eid: int, payload: EventPatch, user=Depends(_require_user)):
p = {k: v for k, v in payload.dict().items() if v is not None}
if not p:
raise HTTPException(400, "No fields to update")
_validate_payload(p)
with _conn() as cn, cn.cursor() as cur:
# Permission: admins can edit anything, others only their own
cur.execute(
"SELECT created_by FROM pgz_sport.kalendar_events WHERE id=%s",
(eid,),
)
existing = cur.fetchone()
if not existing:
raise HTTPException(404, "Event not found")
if not _is_admin(user) and existing["created_by"] not in (user.get("user_id"), None):
raise HTTPException(403, "Insufficient privileges")
sets = ", ".join(f"{k}=%s" for k in p.keys())
params = list(p.values()) + [eid]
cur.execute(
f"UPDATE pgz_sport.kalendar_events SET {sets} WHERE id=%s "
f"RETURNING id, title, start_at, end_at, location, description, event_type, color, "
f"klub_id, savez_id, created_by, created_at, updated_at",
params,
)
out = cur.fetchone()
cn.commit()
return _row(out)
@router.delete("/events/{eid}")
def delete_event(eid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"SELECT created_by FROM pgz_sport.kalendar_events WHERE id=%s",
(eid,),
)
existing = cur.fetchone()
if not existing:
raise HTTPException(404, "Event not found")
if not _is_admin(user) and existing["created_by"] not in (user.get("user_id"), None):
raise HTTPException(403, "Insufficient privileges")
cur.execute("DELETE FROM pgz_sport.kalendar_events WHERE id=%s", (eid,))
cn.commit()
return {"deleted": eid}