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>
114 lines
3.9 KiB
Python
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
|