"""Localization & media-ref validators — Phase 11.5.""" from __future__ import annotations import re from typing import Any from app.models.types import ( LocalizationStatus, LocalizationTarget, MediaKind, MediaRefStatus, TextDirection, ) from app.validators.foundation import ValidationError, validate_non_empty from shared.exceptions import AppError LOCALE_RE = re.compile(r"^[a-z]{2}([_-][A-Za-z0-9]{2,8})?$") LOCALIZATION_STATUS_TRANSITIONS: dict[ LocalizationStatus, frozenset[LocalizationStatus] ] = { LocalizationStatus.DRAFT: frozenset( {LocalizationStatus.ACTIVE, LocalizationStatus.ARCHIVED} ), LocalizationStatus.ACTIVE: frozenset( {LocalizationStatus.DRAFT, LocalizationStatus.ARCHIVED} ), LocalizationStatus.ARCHIVED: frozenset(), } MEDIA_REF_STATUS_TRANSITIONS: dict[MediaRefStatus, frozenset[MediaRefStatus]] = { MediaRefStatus.DRAFT: frozenset({MediaRefStatus.ACTIVE, MediaRefStatus.ARCHIVED}), MediaRefStatus.ACTIVE: frozenset({MediaRefStatus.DRAFT, MediaRefStatus.ARCHIVED}), MediaRefStatus.ARCHIVED: frozenset(), } _FORBIDDEN_MEDIA_PAYLOAD_KEYS = frozenset( { "binary", "blob", "bytes", "content_bytes", "file_bytes", "base64", "data_uri", "raw_content", } ) def validate_locale_code(locale: str) -> str: locale = validate_non_empty(locale, "locale").strip().replace("_", "-") if not LOCALE_RE.match(locale): raise ValidationError( "locale نامعتبر است", {"field": "locale", "pattern": LOCALE_RE.pattern}, ) return locale def validate_text_direction( value: TextDirection | str | None, *, required: bool = True ) -> TextDirection | None: if value is None: if required: raise ValidationError( "text_direction الزامی است", {"field": "text_direction"} ) return None if isinstance(value, TextDirection): return value try: return TextDirection(str(value).strip().lower()) except ValueError as exc: raise ValidationError( "text_direction نامعتبر است", { "field": "text_direction", "allowed": [d.value for d in TextDirection], }, ) from exc def validate_localization_target( value: LocalizationTarget | str, ) -> LocalizationTarget: if isinstance(value, LocalizationTarget): return value try: return LocalizationTarget(str(value).strip().lower()) except ValueError as exc: raise ValidationError( "target_type نامعتبر است", { "field": "target_type", "allowed": [t.value for t in LocalizationTarget], }, ) from exc def validate_media_kind(value: MediaKind | str) -> MediaKind: if isinstance(value, MediaKind): return value try: return MediaKind(str(value).strip().lower()) except ValueError as exc: raise ValidationError( "kind نامعتبر است", {"field": "kind", "allowed": [k.value for k in MediaKind]}, ) from exc def validate_storage_file_ref(file_ref: str) -> str: cleaned = validate_non_empty(file_ref, "storage_file_ref") if len(cleaned) > 255: raise ValidationError( "storage_file_ref بیش از حد طولانی است", {"field": "storage_file_ref", "max_length": 255}, ) # Reject inline binary payloads masquerading as refs. lowered = cleaned.lower() if lowered.startswith("data:") or "base64," in lowered: raise ValidationError( "binary media در experience_db مجاز نیست؛ فقط Storage ref", {"field": "storage_file_ref"}, ) return cleaned def ensure_no_binary_media_payload(metadata_json: dict | None) -> dict | None: if metadata_json is None: return None if not isinstance(metadata_json, dict): raise ValidationError( "metadata_json باید object باشد", {"field": "metadata_json"} ) banned = _FORBIDDEN_MEDIA_PAYLOAD_KEYS.intersection(metadata_json.keys()) if banned: raise ValidationError( "payload باینری در media ref مجاز نیست", {"field": "metadata_json", "forbidden_keys": sorted(banned)}, ) return metadata_json def ensure_localization_status_transition( current: LocalizationStatus, target: LocalizationStatus ) -> None: if current == target: return allowed = LOCALIZATION_STATUS_TRANSITIONS.get(current, frozenset()) if target not in allowed: raise AppError( "گذار وضعیت localization مجاز نیست", error_code="invalid_localization_status_transition", status_code=409, details={"current": current.value, "target": target.value}, ) def ensure_media_ref_status_transition( current: MediaRefStatus, target: MediaRefStatus ) -> None: if current == target: return allowed = MEDIA_REF_STATUS_TRANSITIONS.get(current, frozenset()) if target not in allowed: raise AppError( "گذار وضعیت media ref مجاز نیست", error_code="invalid_media_ref_status_transition", status_code=409, details={"current": current.value, "target": target.value}, ) def ensure_content_json(value: Any) -> dict | None: if value is None: return None if not isinstance(value, dict): raise ValidationError( "content_json باید object باشد", {"field": "content_json"} ) return value