""" 📦 Admin Package Manager API SubscriptionTier csomagok adminisztrációs végpontjai. Minden végpont védve van a Depends(get_current_admin) dependency-vel. Végpontok: GET /admin/packages/ — Listázza az összes csomagot POST /admin/packages/ — Új csomag létrehozása PATCH /admin/packages/{id} — Csomag frissítése (pl. rules JSONB, név) DELETE /admin/packages/{id} — Csomag logikai törlése (lifecycle.is_public=false) """ from __future__ import annotations import json import logging from datetime import date from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Date, cast, func, select, update, delete as sa_delete from sqlalchemy.ext.asyncio import AsyncSession from app.api import deps from app.db.session import get_db from app.models.core_logic import SubscriptionTier from app.models.identity import User from app.schemas.subscription import ( SubscriptionTierCreate, SubscriptionTierListResponse, SubscriptionTierResponse, SubscriptionTierUpdate, SubscriptionRulesModel, ) logger = logging.getLogger("admin-packages") router = APIRouter() # ============================================================================= # GET / — Listázza az összes SubscriptionTier rekordot # ============================================================================= @router.get( "", response_model=SubscriptionTierListResponse, summary="Csomagok listázása", description="Visszaadja az összes előfizetési csomagot (SubscriptionTier).", ) async def list_packages( skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"), limit: int = Query(100, ge=1, le=500, description="Maximum visszaadott rekordok száma"), include_hidden: bool = Query( False, description="Ha True, a nem publikus (lifecycle.is_public=false) csomagokat is visszaadja", ), type_filter: Optional[str] = Query( None, description="Szűrés típus szerint: 'private' vagy 'corporate' (rules->>'type')", ), date_from: Optional[str] = Query( None, description="Szűrés kezdő dátumtól (available_from a lifecycle-ben, ISO formátum: YYYY-MM-DD)", ), date_until: Optional[str] = Query( None, description="Szűrés záró dátumig (available_until a lifecycle-ben, ISO formátum: YYYY-MM-DD)", ), db: AsyncSession = Depends(get_db), admin: User = Depends(deps.get_current_admin), ) -> SubscriptionTierListResponse: """ Összes előfizetési csomag listázása. Alapértelmezetten csak a publikus csomagokat adja vissza (ahol a rules->'lifecycle'->>'is_public' != 'false'). Az `include_hidden=True` paraméterrel az összes rekord lekérhető. Szűrési lehetőségek: - `type_filter`: 'private' vagy 'corporate' a rules->>'type' mező alapján - `date_from`: Csak azokat a csomagokat adja vissza, amelyek available_from >= date_from - `date_until`: Csak azokat a csomagokat adja vissza, amelyek available_until <= date_until """ stmt = select(SubscriptionTier).order_by(SubscriptionTier.id) if not include_hidden: # Csak a publikus csomagok (ahol nincs lifecycle.is_public=false) stmt = stmt.where( ~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"]) ) # ── Típus szűrő (rules->>'type') ── if type_filter: type_filter_lower = type_filter.strip().lower() if type_filter_lower in ("private", "corporate"): stmt = stmt.where( SubscriptionTier.rules["type"].as_string() == type_filter_lower ) # ── Dátum szűrők (lifecycle JSONB mezők) ── # A JSONB mezőkben tárolt dátumokat cast(Date)-té alakítjuk a helyes összehasonlításhoz if date_from: stmt = stmt.where( cast(SubscriptionTier.rules["lifecycle"]["available_from"].as_string(), Date) >= date_from ) if date_until: stmt = stmt.where( cast(SubscriptionTier.rules["lifecycle"]["available_until"].as_string(), Date) <= date_until ) # Teljes számolás count_stmt = select(func.count()).select_from(stmt.subquery()) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # Lapozás stmt = stmt.offset(skip).limit(limit) result = await db.execute(stmt) tiers = result.scalars().all() return SubscriptionTierListResponse( total=total, tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers], ) # ============================================================================= # POST / — Új csomag létrehozása # ============================================================================= @router.post( "", response_model=SubscriptionTierResponse, status_code=status.HTTP_201_CREATED, summary="Új csomag létrehozása", description="Létrehoz egy új előfizetési csomagot a megadott rules JSONB-vel.", ) async def create_package( payload: SubscriptionTierCreate, db: AsyncSession = Depends(get_db), admin: User = Depends(deps.get_current_admin), ) -> SubscriptionTierResponse: """ Új előfizetési csomag létrehozása. - A `name` mező egyedi kell legyen (unique constraint). - A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni. - Alapértelmezetten a lifecycle.is_public = True lesz beállítva. """ # Ellenőrizzük, hogy létezik-e már ilyen nevű csomag existing = await db.execute( select(SubscriptionTier).where(SubscriptionTier.name == payload.name) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'", ) # Alapértelmezett lifecycle beállítás, ha nincs megadva rules_dict = payload.rules.model_dump() if rules_dict.get("lifecycle") is None: rules_dict["lifecycle"] = {"is_public": True} tier = SubscriptionTier( name=payload.name, rules=rules_dict, is_custom=payload.is_custom, ) db.add(tier) await db.commit() await db.refresh(tier) logger.info( f"Admin {admin.id} ({admin.email}) created subscription tier: " f"name={tier.name}, id={tier.id}" ) return SubscriptionTierResponse.model_validate(tier) # ============================================================================= # PATCH /{tier_id} — Csomag frissítése # ============================================================================= @router.patch( "/{tier_id}", response_model=SubscriptionTierResponse, summary="Csomag frissítése", description="Frissíti egy meglévő csomag adatait (név, rules JSONB, is_custom).", ) async def update_package( tier_id: int, payload: SubscriptionTierUpdate, db: AsyncSession = Depends(get_db), admin: User = Depends(deps.get_current_admin), ) -> SubscriptionTierResponse: """ Meglévő előfizetési csomag részleges frissítése. - Csak a megadott mezők frissülnek (PATCH szemantika). - A `rules` mezőben a teljes SubscriptionRulesModel-t át kell adni, ha módosítani szeretnéd (a JSONB oszlop teljes egészében lecserélődik). - A lifecycle.is_public false-ra állításával a csomag elrejthető. """ # Betöltjük a meglévő rekordot stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id) result = await db.execute(stmt) tier = result.scalar_one_or_none() if not tier: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Csomag nem található ID-vel: {tier_id}", ) # Részleges frissítés update_data = payload.model_dump(exclude_unset=True) if "name" in update_data and update_data["name"] is not None: # Ellenőrizzük az egyediséget (kivéve, ha ugyanaz a név) if update_data["name"] != tier.name: existing = await db.execute( select(SubscriptionTier).where( SubscriptionTier.name == update_data["name"], SubscriptionTier.id != tier_id, ) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Már létezik csomag ezzel a névvel: '{update_data['name']}'", ) tier.name = update_data["name"] if "rules" in update_data and update_data["rules"] is not None: # A meglévő rules-ból megtartjuk a lifecycle-t, ha nem adtak újat new_rules = update_data["rules"] # Biztosítjuk a JSON kompatibilis szerializációt (model_dump(mode='json') már megtörtént) if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"): new_rules["lifecycle"] = tier.rules["lifecycle"] tier.rules = new_rules if "is_custom" in update_data and update_data["is_custom"] is not None: tier.is_custom = update_data["is_custom"] await db.commit() await db.refresh(tier) logger.info( f"Admin {admin.id} ({admin.email}) updated subscription tier: " f"id={tier.id}, name={tier.name}" ) return SubscriptionTierResponse.model_validate(tier) # ============================================================================= # DELETE /{tier_id} — Csomag logikai törlése (soft-delete) # ============================================================================= @router.delete( "/{tier_id}", status_code=status.HTTP_200_OK, summary="Csomag logikai törlése", description="Nem törli fizikailag, csak a lifecycle.is_public = false értékre állítja.", ) async def delete_package( tier_id: int, db: AsyncSession = Depends(get_db), admin: User = Depends(deps.get_current_admin), ) -> Dict[str, Any]: """ Csomag logikai törlése (soft-delete / kivezetés). A rekord NEM kerül törlésre az adatbázisból! Ehelyett a `rules` JSONB `lifecycle.is_public` mezője `false`-ra áll, így a csomag eltűnik a frontend listákból, de a meglévő előfizetések továbbra is hivatkozhatnak rá. Ha a csomag már nem publikus, akkor is 200 OK-val tér vissza (idempotens művelet). """ stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id) result = await db.execute(stmt) tier = result.scalar_one_or_none() if not tier: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Csomag nem található ID-vel: {tier_id}", ) # Logikai törlés: lifecycle.is_public = false current_rules = dict(tier.rules) if tier.rules else {} if "lifecycle" not in current_rules: current_rules["lifecycle"] = {} current_rules["lifecycle"]["is_public"] = False tier.rules = current_rules await db.commit() await db.refresh(tier) logger.info( f"Admin {admin.id} ({admin.email}) deactivated subscription tier: " f"id={tier.id}, name={tier.name}" ) return { "status": "success", "message": f"Csomag '{tier.name}' (ID: {tier.id}) inaktíválva.", "tier_id": tier.id, "is_public": False, }