from __future__ import annotations import json import re from datetime import datetime from enum import Enum from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator PROJECT_NAME_MAX_LENGTH = 200 PROJECT_DESCRIPTION_MAX_LENGTH = 4000 PROJECT_METADATA_MAX_BYTES = 16 * 1024 class ProjectStatus(str, Enum): ACTIVE = "active" ARCHIVED = "archived" def normalize_project_name(value: str) -> str: normalized = re.sub(r"\s+", " ", value).strip() if not normalized: raise ValueError("Project name cannot be blank") if len(normalized) > PROJECT_NAME_MAX_LENGTH: raise ValueError(f"Project name cannot exceed {PROJECT_NAME_MAX_LENGTH} characters") return normalized def validate_metadata_size(value: dict[str, Any]) -> dict[str, Any]: try: encoded = json.dumps( value, ensure_ascii=False, separators=(",", ":"), allow_nan=False ).encode("utf-8") except (TypeError, ValueError) as exc: raise ValueError("Project metadata must be valid JSON") from exc if len(encoded) > PROJECT_METADATA_MAX_BYTES: raise ValueError(f"Project metadata cannot exceed {PROJECT_METADATA_MAX_BYTES} bytes") return value class ProjectCreate(BaseModel): model_config = ConfigDict(extra="forbid") name: str description: str | None = Field(default=None, max_length=PROJECT_DESCRIPTION_MAX_LENGTH) thumbnail_asset_id: str | None = Field(default=None, min_length=1, max_length=36) metadata: dict[str, Any] = Field(default_factory=dict) _normalize_name = field_validator("name")(normalize_project_name) _validate_metadata = field_validator("metadata")(validate_metadata_size) class ProjectUpdate(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = None description: str | None = Field(default=None, max_length=PROJECT_DESCRIPTION_MAX_LENGTH) thumbnail_asset_id: str | None = Field(default=None, min_length=1, max_length=36) metadata: dict[str, Any] | None = None status: ProjectStatus | None = None @field_validator("name") @classmethod def normalize_name(cls, value: str | None) -> str | None: if value is None: return value return normalize_project_name(value) @field_validator("metadata") @classmethod def validate_metadata(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: return None if value is None else validate_metadata_size(value) @model_validator(mode="after") def validate_patch(self) -> ProjectUpdate: if not self.model_fields_set: raise ValueError("At least one project field must be supplied") if "name" in self.model_fields_set and self.name is None: raise ValueError("Project name cannot be null") if "metadata" in self.model_fields_set and self.metadata is None: raise ValueError("Project metadata cannot be null") if "status" in self.model_fields_set and self.status is None: raise ValueError("Project status cannot be null") return self class ProjectResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str workspace_id: str name: str description: str | None status: ProjectStatus thumbnail_asset_id: str | None brand_kit_id: str | None = None brand_kit_version_id: str | None = None metadata: dict[str, Any] created_by: str created_at: datetime updated_at: datetime archived_at: datetime | None class ProjectListResponse(BaseModel): items: list[ProjectResponse] next_cursor: str | None = None limit: int = Field(ge=1, le=100) class ProjectAssetAttach(BaseModel): model_config = ConfigDict(extra="forbid") asset_id: str = Field(min_length=1, max_length=36) class ProjectAssetResponse(BaseModel): id: str project_id: str request_id: str filename: str mime_type: str file_size: int metadata: dict[str, Any] created_by_user_id: str | None created_at: datetime updated_at: datetime class ProjectAssetListResponse(BaseModel): items: list[ProjectAssetResponse] class ProjectGenerationJobAttach(BaseModel): model_config = ConfigDict(extra="forbid") generation_job_id: str = Field(min_length=1, max_length=36) class ProjectGenerationJobResponse(BaseModel): id: str project_id: str job_type: Literal["generation"] = "generation" generation_request_id: str provider: str status: str output_asset_id: str | None error_code: str | None created_at: datetime started_at: datetime | None completed_at: datetime | None updated_at: datetime attached_at: datetime class ProjectGenerationJobListResponse(BaseModel): items: list[ProjectGenerationJobResponse]