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