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

187 lines
6.3 KiB
Python

"""Publishing / SEO / PWA validators — Phase 11.7."""
from __future__ import annotations
from typing import Any
from app.models.types import (
DomainEdgeStatus,
PublishReleaseStatus,
PublishTarget,
PwaStatus,
RobotsStatus,
SeoStatus,
SeoTarget,
SitemapStatus,
)
from app.validators.foundation import ValidationError, validate_non_empty
from shared.exceptions import AppError
_FORBIDDEN_EDGE_KEYS = frozenset(
{
"nginx_config",
"ssl_private_key",
"cert_pem",
"private_key",
"dns_engine",
"ssl_engine",
"edge_engine",
}
)
PUBLISH_STATUS_TRANSITIONS: dict[
PublishReleaseStatus, frozenset[PublishReleaseStatus]
] = {
PublishReleaseStatus.PENDING: frozenset(
{
PublishReleaseStatus.PUBLISHED,
PublishReleaseStatus.FAILED,
PublishReleaseStatus.ROLLED_BACK,
}
),
PublishReleaseStatus.PUBLISHED: frozenset({PublishReleaseStatus.ROLLED_BACK}),
PublishReleaseStatus.FAILED: frozenset(
{PublishReleaseStatus.PENDING, PublishReleaseStatus.ROLLED_BACK}
),
PublishReleaseStatus.ROLLED_BACK: frozenset(),
}
EDGE_STATUS_TRANSITIONS: dict[DomainEdgeStatus, frozenset[DomainEdgeStatus]] = {
DomainEdgeStatus.PENDING: frozenset(
{
DomainEdgeStatus.PROVISIONING,
DomainEdgeStatus.READY,
DomainEdgeStatus.FAILED,
DomainEdgeStatus.RETIRED,
}
),
DomainEdgeStatus.PROVISIONING: frozenset(
{DomainEdgeStatus.READY, DomainEdgeStatus.FAILED, DomainEdgeStatus.RETIRED}
),
DomainEdgeStatus.READY: frozenset(
{DomainEdgeStatus.FAILED, DomainEdgeStatus.RETIRED}
),
DomainEdgeStatus.FAILED: frozenset(
{DomainEdgeStatus.PENDING, DomainEdgeStatus.PROVISIONING, DomainEdgeStatus.RETIRED}
),
DomainEdgeStatus.RETIRED: frozenset(),
}
_SIMPLE = {
SeoStatus: {
SeoStatus.DRAFT: frozenset({SeoStatus.ACTIVE, SeoStatus.ARCHIVED}),
SeoStatus.ACTIVE: frozenset({SeoStatus.DRAFT, SeoStatus.ARCHIVED}),
SeoStatus.ARCHIVED: frozenset(),
},
SitemapStatus: {
SitemapStatus.DRAFT: frozenset({SitemapStatus.ACTIVE, SitemapStatus.ARCHIVED}),
SitemapStatus.ACTIVE: frozenset({SitemapStatus.DRAFT, SitemapStatus.ARCHIVED}),
SitemapStatus.ARCHIVED: frozenset(),
},
RobotsStatus: {
RobotsStatus.DRAFT: frozenset({RobotsStatus.ACTIVE, RobotsStatus.ARCHIVED}),
RobotsStatus.ACTIVE: frozenset({RobotsStatus.DRAFT, RobotsStatus.ARCHIVED}),
RobotsStatus.ARCHIVED: frozenset(),
},
PwaStatus: {
PwaStatus.DRAFT: frozenset({PwaStatus.READY, PwaStatus.ARCHIVED}),
PwaStatus.READY: frozenset({PwaStatus.DRAFT, PwaStatus.ARCHIVED}),
PwaStatus.ARCHIVED: frozenset(),
},
}
def ensure_json_object(value: Any, field: str) -> dict | None:
if value is None:
return None
if not isinstance(value, dict):
raise ValidationError(f"{field} باید object باشد", {"field": field})
banned = _FORBIDDEN_EDGE_KEYS.intersection(value.keys())
if banned:
raise ValidationError(
"کلیدهای مالکیت DNS/SSL/edge در payload مجاز نیست",
{"field": field, "forbidden_keys": sorted(banned)},
)
return value
def validate_external_ref(value: str | None, field: str) -> str | None:
if value is None:
return None
cleaned = validate_non_empty(value, field)
if len(cleaned) > 255:
raise ValidationError(
f"{field} بیش از حد طولانی است", {"field": field, "max_length": 255}
)
lowered = cleaned.lower()
if lowered.startswith("data:") or "-----begin" in lowered:
raise ValidationError(
"محتوای باینری/PEM در experience_db مجاز نیست؛ فقط ref",
{"field": field},
)
return cleaned
def validate_publish_target(value: PublishTarget | str) -> PublishTarget:
if isinstance(value, PublishTarget):
return value
try:
return PublishTarget(str(value).strip().lower())
except ValueError as exc:
raise ValidationError(
"target_type نامعتبر است",
{"field": "target_type", "allowed": [t.value for t in PublishTarget]},
) from exc
def validate_seo_target(value: SeoTarget | str) -> SeoTarget:
if isinstance(value, SeoTarget):
return value
try:
return SeoTarget(str(value).strip().lower())
except ValueError as exc:
raise ValidationError(
"target_type نامعتبر است",
{"field": "target_type", "allowed": [t.value for t in SeoTarget]},
) from exc
def ensure_publish_status_transition(
current: PublishReleaseStatus, target: PublishReleaseStatus
) -> None:
if current == target:
return
if target not in PUBLISH_STATUS_TRANSITIONS.get(current, frozenset()):
raise AppError(
"گذار وضعیت انتشار مجاز نیست",
error_code="invalid_publish_status_transition",
status_code=409,
details={"current": current.value, "target": target.value},
)
def ensure_edge_status_transition(
current: DomainEdgeStatus, target: DomainEdgeStatus
) -> None:
if current == target:
return
if target not in EDGE_STATUS_TRANSITIONS.get(current, frozenset()):
raise AppError(
"گذار وضعیت edge دامنه مجاز نیست",
error_code="invalid_domain_edge_status_transition",
status_code=409,
details={"current": current.value, "target": target.value},
)
def ensure_simple_status_transition(current, target, *, label: str) -> None:
if current == target:
return
table = _SIMPLE.get(type(current), {})
if target not in table.get(current, frozenset()):
raise AppError(
f"گذار وضعیت {label} مجاز نیست",
error_code=f"invalid_{label}_status_transition",
status_code=409,
details={"current": current.value, "target": target.value},
)