f7b5114f58
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)
299 lines
9.6 KiB
Python
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}
|