"""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}, )