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>
184 lines
5.8 KiB
Python
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
|