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>
187 lines
6.3 KiB
Python
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},
|
|
)
|