320 lines
11 KiB
Python
320 lines
11 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 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,
|
|
}
|