admin felület fejlesztése garázs, előfizeztési csomagok

This commit is contained in:
Roo
2026-06-25 01:58:04 +00:00
parent 80a5d67f79
commit 52011606ff
334 changed files with 46636 additions and 1606 deletions

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import json
import logging
from copy import deepcopy
from datetime import date
from typing import Any, Dict, List, Optional
@@ -38,6 +39,47 @@ 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
# =============================================================================
@@ -149,6 +191,8 @@ async def create_package(
- 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(
@@ -160,6 +204,22 @@ async def create_package(
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:
@@ -169,6 +229,10 @@ async def create_package(
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()
@@ -176,7 +240,9 @@ async def create_package(
logger.info(
f"Admin {admin.id} ({admin.email}) created subscription tier: "
f"name={tier.name}, id={tier.id}"
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)
@@ -203,9 +269,12 @@ async def update_package(
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 `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)
@@ -238,22 +307,66 @@ async def update_package(
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
# ── 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"]
# 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 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"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)