""" 📦 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 copy import deepcopy 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() # ============================================================================= # P0 CRITICAL FIX: Recursive Deep Merge for JSONB fields # ============================================================================= def deep_merge_dict(base: dict, override: dict) -> dict: """ Recursive deep merge of two dictionaries. P0 CRITICAL FIX: Prevents data loss when updating nested JSONB fields. Unlike a simple dict.update(), this function recursively merges nested dictionaries instead of replacing them. Rules: - If both values are dicts, recurse into them. - If the override value is None, the base value is preserved. - Otherwise, the override value replaces the base value (scalars, lists, etc.). Example: base = {"pricing_zones": {"HU": {...}, "DEFAULT": {...}}} override = {"pricing_zones": {"DEFAULT": {"monthly_price": 5.0}}} Result: {"pricing_zones": {"HU": {...}, "DEFAULT": {"monthly_price": 5.0}}} The HU zone is PRESERVED because it was not in the override. """ result = deepcopy(base) for key, override_val in override.items(): if override_val is None: # Preserve existing value when override is explicitly None continue if key in result and isinstance(result[key], dict) and isinstance(override_val, dict): # Recursive merge for nested dicts result[key] = deep_merge_dict(result[key], override_val) else: # Scalar, list, or new key — replace/add result[key] = deepcopy(override_val) return result # ============================================================================= # 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. - Singleton szabály: ha is_default_fallback=True, minden más csomag False lesz. - Singleton szabály: ha trial_days_on_signup>0, minden más csomag 0-ra áll. """ # 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}'", ) # ── P0 SINGLETON RULE: is_default_fallback ── if payload.is_default_fallback: await db.execute( update(SubscriptionTier) .where(SubscriptionTier.is_default_fallback == True) .values(is_default_fallback=False) ) # ── P0 SINGLETON RULE: trial_days_on_signup ── if payload.trial_days_on_signup > 0: await db.execute( update(SubscriptionTier) .where(SubscriptionTier.trial_days_on_signup > 0) .values(trial_days_on_signup=0) ) # 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, tier_level=payload.tier_level, feature_capabilities=payload.feature_capabilities, is_default_fallback=payload.is_default_fallback, trial_days_on_signup=payload.trial_days_on_signup, ) 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}, " f"is_default_fallback={tier.is_default_fallback}, " f"trial_days_on_signup={tier.trial_days_on_signup}" ) 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` JSONB oszlop **deep merge** szemantikával frissül: a meglévő kulcsok (pl. pricing_zones.HU, lifecycle.available_until) NEM vesznek el, ha a payload nem tartalmazza őket. - A lifecycle.is_public false-ra állításával a csomag elrejthető. - Singleton szabály: ha is_default_fallback=True, minden más csomag False lesz. - Singleton szabály: ha trial_days_on_signup>0, minden más csomag 0-ra áll. """ # 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: # ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ── # Previously: tier.rules = new_rules (wholesale replacement) # This caused data loss: pricing_zones.HU, lifecycle.available_until, etc. # were silently destroyed when the frontend sent incomplete payloads. # # Now: deep_merge_dict() recursively merges the incoming rules into # the existing tier.rules, preserving all keys not present in the payload. new_rules = update_data["rules"] if isinstance(new_rules, dict): merged_rules = deep_merge_dict(tier.rules or {}, new_rules) tier.rules = merged_rules else: 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"] if "tier_level" in update_data and update_data["tier_level"] is not None: tier.tier_level = update_data["tier_level"] if "feature_capabilities" in update_data and update_data["feature_capabilities"] is not None: tier.feature_capabilities = update_data["feature_capabilities"] # ── P0 SINGLETON RULE: is_default_fallback ── if "is_default_fallback" in update_data and update_data["is_default_fallback"] is True: # Clear fallback on all other tiers first await db.execute( update(SubscriptionTier) .where( SubscriptionTier.is_default_fallback == True, SubscriptionTier.id != tier_id, ) .values(is_default_fallback=False) ) tier.is_default_fallback = True elif "is_default_fallback" in update_data and update_data["is_default_fallback"] is False: tier.is_default_fallback = False # ── P0 SINGLETON RULE: trial_days_on_signup ── if "trial_days_on_signup" in update_data and update_data["trial_days_on_signup"] is not None: if update_data["trial_days_on_signup"] > 0: # Clear trial on all other tiers first await db.execute( update(SubscriptionTier) .where( SubscriptionTier.trial_days_on_signup > 0, SubscriptionTier.id != tier_id, ) .values(trial_days_on_signup=0) ) tier.trial_days_on_signup = update_data["trial_days_on_signup"] 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}, " f"is_default_fallback={tier.is_default_fallback}, " f"trial_days_on_signup={tier.trial_days_on_signup}" ) 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, }