admin frontend elkezdése, járművek tisztázása, frontend fejleszts

This commit is contained in:
Roo
2026-06-15 18:52:38 +00:00
parent ef8df9608c
commit 213ba3b0f1
80 changed files with 11615 additions and 2304 deletions

View File

@@ -0,0 +1,319 @@
"""
📦 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,
}