admin felület fejlesztése garázs, előfizeztési csomagok
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user