Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete. Co-authored-by: Cursor <cursoragent@cursor.com>
47 lines
1.7 KiB
Python
47 lines
1.7 KiB
Python
"""Bundle and licensing boundary validation — Phase 11.8."""
|
|
from typing import Any
|
|
|
|
from app.validators.foundation import ValidationError, validate_non_empty
|
|
from shared.exceptions import AppError
|
|
|
|
FORBIDDEN_OWNERSHIP_KEYS = frozenset(
|
|
{"plan_engine", "subscription_engine", "billing_ledger", "core_plan_id"}
|
|
)
|
|
|
|
|
|
def validate_metadata(value: Any) -> dict | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, dict):
|
|
raise ValidationError("metadata_json باید object باشد", {"field": "metadata_json"})
|
|
banned = FORBIDDEN_OWNERSHIP_KEYS.intersection(value)
|
|
if banned:
|
|
raise ValidationError(
|
|
"مالکیت plan/subscription/billing متعلق به Core است",
|
|
{"field": "metadata_json", "forbidden_keys": sorted(banned)},
|
|
)
|
|
return value
|
|
|
|
|
|
def validate_entitlement_ref(value: str | None, *, required: bool = False) -> str | None:
|
|
if value is None:
|
|
if required:
|
|
raise ValidationError("core_entitlement_ref الزامی است")
|
|
return None
|
|
cleaned = validate_non_empty(value, "core_entitlement_ref")
|
|
if len(cleaned) > 255:
|
|
raise ValidationError("core_entitlement_ref بیش از حد طولانی است")
|
|
return cleaned
|
|
|
|
|
|
def ensure_transition(current, target, transitions: dict, label: str) -> None:
|
|
if current == target:
|
|
return
|
|
if target not in transitions.get(current, frozenset()):
|
|
raise AppError(
|
|
f"گذار وضعیت {label} مجاز نیست",
|
|
error_code=f"invalid_{label}_status_transition",
|
|
status_code=409,
|
|
details={"current": current.value, "target": target.value},
|
|
)
|