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

184 lines
5.8 KiB
Python

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