TorbatYar/backend/services/experience/app/validators/components.py
Mortezakoohjani 203671a7bf feat(experience): ship Experience Platform phases 11.0-11.10
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>
2026-07-27 11:43:10 +03:30

114 lines
3.9 KiB
Python

"""Component library validators — Phase 11.2."""
from __future__ import annotations
import re
from typing import Any
from app.models.types import (
ComponentKind,
ComponentStatus,
ComponentVersionStatus,
)
from app.validators.foundation import ValidationError, validate_non_empty
from shared.exceptions import AppError
SLOT_KEY_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-.]{0,99}$")
COMPONENT_STATUS_TRANSITIONS: dict[ComponentStatus, frozenset[ComponentStatus]] = {
ComponentStatus.DRAFT: frozenset(
{ComponentStatus.ACTIVE, ComponentStatus.ARCHIVED}
),
ComponentStatus.ACTIVE: frozenset(
{ComponentStatus.DEPRECATED, ComponentStatus.ARCHIVED}
),
ComponentStatus.DEPRECATED: frozenset(
{ComponentStatus.ACTIVE, ComponentStatus.ARCHIVED}
),
ComponentStatus.ARCHIVED: frozenset(),
}
VERSION_STATUS_TRANSITIONS: dict[
ComponentVersionStatus, frozenset[ComponentVersionStatus]
] = {
ComponentVersionStatus.DRAFT: frozenset({ComponentVersionStatus.PUBLISHED}),
ComponentVersionStatus.PUBLISHED: frozenset({ComponentVersionStatus.RETIRED}),
ComponentVersionStatus.RETIRED: frozenset(),
}
def validate_slot_key(slot_key: str) -> str:
slot_key = validate_non_empty(slot_key, "slot_key")
if not SLOT_KEY_RE.match(slot_key):
raise ValidationError(
"slot_key نامعتبر است",
{"field": "slot_key", "pattern": SLOT_KEY_RE.pattern},
)
return slot_key
def validate_component_kind(kind: ComponentKind | str) -> ComponentKind:
if isinstance(kind, ComponentKind):
return kind
try:
return ComponentKind(kind)
except ValueError as exc:
raise ValidationError(
"kind نامعتبر است",
{"field": "kind", "allowed": [k.value for k in ComponentKind]},
) from exc
def ensure_component_status_transition(
current: ComponentStatus, target: ComponentStatus
) -> None:
if current == target:
return
allowed = COMPONENT_STATUS_TRANSITIONS.get(current, frozenset())
if target not in allowed:
raise AppError(
"گذار وضعیت کامپوننت مجاز نیست",
error_code="invalid_component_status_transition",
status_code=409,
details={"current": current.value, "target": target.value},
)
def ensure_version_status_transition(
current: ComponentVersionStatus, target: ComponentVersionStatus
) -> None:
if current == target:
return
allowed = VERSION_STATUS_TRANSITIONS.get(current, frozenset())
if target not in allowed:
raise AppError(
"گذار وضعیت نسخه کامپوننت مجاز نیست",
error_code="invalid_component_version_status_transition",
status_code=409,
details={"current": current.value, "target": target.value},
)
def ensure_version_mutable(version: Any) -> None:
if getattr(version, "is_immutable", False) or getattr(
version, "status", None
) == ComponentVersionStatus.PUBLISHED:
raise AppError(
"نسخه منتشرشده قابل ویرایش نیست",
error_code="component_version_immutable",
status_code=409,
)
if getattr(version, "status", None) == ComponentVersionStatus.RETIRED:
raise AppError(
"نسخه بازنشسته‌شده قابل ویرایش نیست",
error_code="component_version_retired",
status_code=409,
)
def ensure_schema_payload(payload: dict | None, field: str) -> dict | None:
if payload is None:
return None
if not isinstance(payload, dict):
raise ValidationError(f"{field} باید شیء JSON باشد", {"field": field})
return payload