437 lines
16 KiB
Python
437 lines
16 KiB
Python
"""
|
|
📦 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),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("subscription:manage")),
|
|
) -> 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),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("subscription:manage")),
|
|
) -> 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 {current_user.id} ({current_user.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),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("subscription:manage")),
|
|
) -> 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 {current_user.id} ({current_user.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),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("subscription:manage")),
|
|
) -> 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 {current_user.id} ({current_user.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,
|
|
}
|