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>
271 lines
9.7 KiB
Python
271 lines
9.7 KiB
Python
"""Theme & layout validators — Phase 11.3 (versioned)."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from app.models.types import (
|
|
DirectionMode,
|
|
LayoutKind,
|
|
LayoutStatus,
|
|
LayoutVersionStatus,
|
|
ThemeStatus,
|
|
ThemeVersionStatus,
|
|
)
|
|
from app.validators.foundation import ValidationError
|
|
from shared.exceptions import AppError
|
|
|
|
THEME_STATUS_TRANSITIONS: dict[ThemeStatus, frozenset[ThemeStatus]] = {
|
|
ThemeStatus.DRAFT: frozenset({ThemeStatus.ACTIVE, ThemeStatus.ARCHIVED}),
|
|
ThemeStatus.ACTIVE: frozenset({ThemeStatus.DEPRECATED, ThemeStatus.ARCHIVED}),
|
|
ThemeStatus.DEPRECATED: frozenset({ThemeStatus.ACTIVE, ThemeStatus.ARCHIVED}),
|
|
ThemeStatus.ARCHIVED: frozenset(),
|
|
}
|
|
|
|
LAYOUT_STATUS_TRANSITIONS: dict[LayoutStatus, frozenset[LayoutStatus]] = {
|
|
LayoutStatus.DRAFT: frozenset({LayoutStatus.ACTIVE, LayoutStatus.ARCHIVED}),
|
|
LayoutStatus.ACTIVE: frozenset({LayoutStatus.DEPRECATED, LayoutStatus.ARCHIVED}),
|
|
LayoutStatus.DEPRECATED: frozenset({LayoutStatus.ACTIVE, LayoutStatus.ARCHIVED}),
|
|
LayoutStatus.ARCHIVED: frozenset(),
|
|
}
|
|
|
|
THEME_VERSION_STATUS_TRANSITIONS: dict[
|
|
ThemeVersionStatus, frozenset[ThemeVersionStatus]
|
|
] = {
|
|
ThemeVersionStatus.DRAFT: frozenset({ThemeVersionStatus.PUBLISHED}),
|
|
ThemeVersionStatus.PUBLISHED: frozenset({ThemeVersionStatus.RETIRED}),
|
|
ThemeVersionStatus.RETIRED: frozenset(),
|
|
}
|
|
|
|
LAYOUT_VERSION_STATUS_TRANSITIONS: dict[
|
|
LayoutVersionStatus, frozenset[LayoutVersionStatus]
|
|
] = {
|
|
LayoutVersionStatus.DRAFT: frozenset({LayoutVersionStatus.PUBLISHED}),
|
|
LayoutVersionStatus.PUBLISHED: frozenset({LayoutVersionStatus.RETIRED}),
|
|
LayoutVersionStatus.RETIRED: frozenset(),
|
|
}
|
|
|
|
|
|
def validate_direction(value: DirectionMode | str | None) -> DirectionMode | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, DirectionMode):
|
|
return value
|
|
try:
|
|
return DirectionMode(value)
|
|
except ValueError as exc:
|
|
raise ValidationError(
|
|
"direction_mode نامعتبر است",
|
|
{"field": "direction_mode", "allowed": [d.value for d in DirectionMode]},
|
|
) from exc
|
|
|
|
|
|
# Backwards-compatible alias used by some callers/tests.
|
|
validate_direction_mode = validate_direction
|
|
|
|
|
|
def validate_layout_kind(value: LayoutKind | str) -> LayoutKind:
|
|
if isinstance(value, LayoutKind):
|
|
return value
|
|
try:
|
|
return LayoutKind(value)
|
|
except ValueError as exc:
|
|
raise ValidationError(
|
|
"kind نامعتبر است",
|
|
{"field": "kind", "allowed": [k.value for k in LayoutKind]},
|
|
) from exc
|
|
|
|
|
|
def ensure_theme_status_transition(current: ThemeStatus, target: ThemeStatus) -> None:
|
|
if current == target:
|
|
return
|
|
allowed = THEME_STATUS_TRANSITIONS.get(current, frozenset())
|
|
if target not in allowed:
|
|
raise AppError(
|
|
"گذار وضعیت تم مجاز نیست",
|
|
error_code="invalid_theme_status_transition",
|
|
status_code=409,
|
|
details={"current": current.value, "target": target.value},
|
|
)
|
|
|
|
|
|
def ensure_layout_status_transition(
|
|
current: LayoutStatus, target: LayoutStatus
|
|
) -> None:
|
|
if current == target:
|
|
return
|
|
allowed = LAYOUT_STATUS_TRANSITIONS.get(current, frozenset())
|
|
if target not in allowed:
|
|
raise AppError(
|
|
"گذار وضعیت لایهبندی مجاز نیست",
|
|
error_code="invalid_layout_status_transition",
|
|
status_code=409,
|
|
details={"current": current.value, "target": target.value},
|
|
)
|
|
|
|
|
|
def ensure_theme_version_status_transition(
|
|
current: ThemeVersionStatus, target: ThemeVersionStatus
|
|
) -> None:
|
|
if current == target:
|
|
return
|
|
allowed = THEME_VERSION_STATUS_TRANSITIONS.get(current, frozenset())
|
|
if target not in allowed:
|
|
raise AppError(
|
|
"گذار وضعیت نسخه تم مجاز نیست",
|
|
error_code="invalid_theme_version_status_transition",
|
|
status_code=409,
|
|
details={"current": current.value, "target": target.value},
|
|
)
|
|
|
|
|
|
def ensure_layout_version_status_transition(
|
|
current: LayoutVersionStatus, target: LayoutVersionStatus
|
|
) -> None:
|
|
if current == target:
|
|
return
|
|
allowed = LAYOUT_VERSION_STATUS_TRANSITIONS.get(current, frozenset())
|
|
if target not in allowed:
|
|
raise AppError(
|
|
"گذار وضعیت نسخه لایهبندی مجاز نیست",
|
|
error_code="invalid_layout_version_status_transition",
|
|
status_code=409,
|
|
details={"current": current.value, "target": target.value},
|
|
)
|
|
|
|
|
|
def ensure_theme_version_mutable(version: Any) -> None:
|
|
status = getattr(version, "status", None)
|
|
if status != ThemeVersionStatus.DRAFT:
|
|
raise AppError(
|
|
"فقط نسخه پیشنویس تم قابل ویرایش است",
|
|
error_code="theme_version_not_mutable",
|
|
status_code=409,
|
|
details={"status": getattr(status, "value", status)},
|
|
)
|
|
|
|
|
|
def ensure_layout_version_mutable(version: Any) -> None:
|
|
status = getattr(version, "status", None)
|
|
if status != LayoutVersionStatus.DRAFT:
|
|
raise AppError(
|
|
"فقط نسخه پیشنویس لایهبندی قابل ویرایش است",
|
|
error_code="layout_version_not_mutable",
|
|
status_code=409,
|
|
details={"status": getattr(status, "value", status)},
|
|
)
|
|
|
|
|
|
def ensure_json_object(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
|
|
|
|
|
|
def ensure_json_structure(
|
|
payload: list | dict | None, field: str
|
|
) -> list | dict | None:
|
|
if payload is None:
|
|
return None
|
|
if not isinstance(payload, (list, dict)):
|
|
raise ValidationError(
|
|
f"{field} باید آرایه یا شیء JSON باشد", {"field": field}
|
|
)
|
|
return payload
|
|
|
|
|
|
def ensure_tokens_payload(payload: dict | None, field: str = "tokens") -> dict | None:
|
|
if payload is None:
|
|
return None
|
|
if not isinstance(payload, dict) or not payload:
|
|
raise ValidationError(
|
|
f"{field} باید شیء JSON غیرخالی باشد", {"field": field}
|
|
)
|
|
return payload
|
|
|
|
|
|
def ensure_shell_payload(payload: dict | None, field: str) -> dict | None:
|
|
return ensure_json_object(payload, field)
|
|
|
|
|
|
def ensure_regions_payload(payload: list | None, field: str = "regions") -> list | None:
|
|
if payload is None:
|
|
return None
|
|
if not isinstance(payload, list) or not payload:
|
|
raise ValidationError(f"{field} باید آرایه غیرخالی باشد", {"field": field})
|
|
for item in payload:
|
|
if not isinstance(item, dict) or "key" not in item:
|
|
raise ValidationError(
|
|
f"هر عنصر {field} باید دارای کلید key باشد", {"field": field}
|
|
)
|
|
return payload
|
|
|
|
|
|
def ensure_assignable_theme(theme: Any) -> None:
|
|
status = getattr(theme, "status", None)
|
|
if status != ThemeStatus.ACTIVE:
|
|
raise AppError(
|
|
"فقط تم فعال قابل انتساب است",
|
|
error_code="theme_not_active",
|
|
status_code=409,
|
|
details={"status": getattr(status, "value", status)},
|
|
)
|
|
if getattr(theme, "is_deleted", False):
|
|
raise AppError(
|
|
"تم حذفشده قابل انتساب نیست",
|
|
error_code="theme_deleted",
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
def ensure_assignable_layout(layout: Any) -> None:
|
|
status = getattr(layout, "status", None)
|
|
if status != LayoutStatus.ACTIVE:
|
|
raise AppError(
|
|
"فقط لایهبندی فعال قابل انتساب است",
|
|
error_code="layout_not_active",
|
|
status_code=409,
|
|
details={"status": getattr(status, "value", status)},
|
|
)
|
|
if getattr(layout, "is_deleted", False):
|
|
raise AppError(
|
|
"لایهبندی حذفشده قابل انتساب نیست",
|
|
error_code="layout_deleted",
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
def ensure_assignable_theme_version(version: Any) -> None:
|
|
status = getattr(version, "status", None)
|
|
if status != ThemeVersionStatus.PUBLISHED:
|
|
raise AppError(
|
|
"فقط نسخه منتشرشده تم قابل انتساب است",
|
|
error_code="theme_version_not_published",
|
|
status_code=409,
|
|
details={"status": getattr(status, "value", status)},
|
|
)
|
|
if getattr(version, "is_deleted", False):
|
|
raise AppError(
|
|
"نسخه حذفشده قابل انتساب نیست",
|
|
error_code="theme_version_deleted",
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
def ensure_assignable_layout_version(version: Any) -> None:
|
|
status = getattr(version, "status", None)
|
|
if status != LayoutVersionStatus.PUBLISHED:
|
|
raise AppError(
|
|
"فقط نسخه منتشرشده لایهبندی قابل انتساب است",
|
|
error_code="layout_version_not_published",
|
|
status_code=409,
|
|
details={"status": getattr(status, "value", status)},
|
|
)
|
|
if getattr(version, "is_deleted", False):
|
|
raise AppError(
|
|
"نسخه حذفشده قابل انتساب نیست",
|
|
error_code="layout_version_deleted",
|
|
status_code=409,
|
|
)
|