TorbatYar/backend/services/experience/app/validators/themes.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

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,
)