#!/usr/bin/env python3 # =================================================================== # Fajl: routers/kalendar_router.py | v1.0.0 | 05.05.2026 # Autor: Damir Radulic / 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}