from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from jinja2 import Environment
# ---- Helper dataclasses for nested schema fields ----
client_info: Optional[str] = None
client_role: Optional[str] = None
collaborator_info: Optional[str] = None
role: Optional[str] = None
ProjectV15 model aligned with the YAML schema.
- title: string (required)
- short_title: string (required)
- slug: string (required, kebab-case)
- thumbnail: image (required)
- client: object { client_info, client_role } (optional)
- collabs: list of { collaborator_info, role } (optional)
- date: date (optional) - parsed into datetime
- summary: string (optional)
- body: rich-text content (optional) – represented here as list[str] paragraphs
- gallery: list of image paths (optional)
- videos: list of video URLs (optional)
- awards: list of award references (optional)
- external_url: string (optional)
- order: number (default 100)
- featured: boolean (default False)
- seo: dictionary or SEO component (optional)
client: Optional[ClientRef] = None
collabs: List[CollaboratorRef] = field(default_factory=list)
date: Optional[datetime] = None
summary: Optional[str] = None
# Represent rich-text as list of paragraphs (adapt if your CMS returns blocks)
body: Optional[List[str]] = None
tag: Optional[str] = None
gallery: List[str] = field(default_factory=list)
videos: List[str] = field(default_factory=list)
awards: List[str] = field(default_factory=list)
external_url: Optional[str] = None
seo: Dict[str, Any] = field(default_factory=dict)
# --- post-init normalization ---
# Ensure order / featured types
self.order = int(self.order)
self.featured = bool(self.featured)
if self.date is not None:
self.date = self._parse_date(self.date)
# Normalize sequences (defensive against None)
self.collabs = list(self.collabs or [])
self.gallery = list(self.gallery or [])
self.videos = list(self.videos or [])
self.awards = list(self.awards or [])
# Normalize body: allow string, list[str], or None
if isinstance(self.body, str):
# Keep your existing behavior: split on blank lines
self.body = [p for p in self.body.split("\n\n") if p.strip()]
self.seo = dict(self.seo or {})
def _parse_date(date_input: Any) -> Optional[datetime]:
- string in formats like "dd-MM-yyyy" or "YYYY-MM-DD"
- datetime.date or datetime.datetime
Returns datetime if possible, else None.
if isinstance(date_input, datetime):
if hasattr(date_input, "strftime"): # datetime.date or similar
# Convert to datetime at midnight
if isinstance(date_input, str):
for fmt in ("%d-%m-%Y", "%d-%m-%Y %H:%M:%S", "%Y-%m-%d"):
return datetime.strptime(date_input, fmt)
# --- factory from CMS dict ---
def from_pages_cms(cls, data: Dict[str, Any]) -> "ProjectV15":
Build a ProjectV15 from the CMS dict that directly follows the YAML schema.
# client: { client_info, client_role }
raw_client = data.get("client")
client_obj: Optional[ClientRef] = None
if isinstance(raw_client, dict):
client_info=raw_client.get("client_info"),
client_role=raw_client.get("client_role"),
# collabs: list of { collaborator_info, role }
raw_collabs = data.get("collabs") or []
collab_objs: List[CollaboratorRef] = []
collaborator_info=c.get("collaborator_info"),
# videos: list of strings (URLs)
videos = data.get("videos") or []
# awards: reference list – assume list of strings/ids
awards = data.get("awards") or []
raw_body = data.get("body")
# If CMS sends a long string, keep your old behavior (split by blank line)
if isinstance(raw_body, str):
body = [p for p in raw_body.split("\n\n") if p.strip()]
# Could be list[blocks] or already list[str]; just pass through
short_title=data["short_title"],
thumbnail=data["thumbnail"],
summary=data.get("summary"),
gallery=data.get("gallery") or [],
external_url=data.get("external_url"),
order=data.get("order", 100),
featured=data.get("featured", False),
seo=data.get("seo") or {},
def to_dict(self) -> Dict[str, Any]:
Serialize back to a dict matching the CMS/YAML structure as closely as possible.
"client_info": self.client.client_info,
"client_role": self.client.client_role,
if self.client is not None
"collaborator_info": c.collaborator_info,
"short_title": self.short_title,
"thumbnail": self.thumbnail,
"date": self.date.isoformat() if isinstance(self.date, datetime) else self.date,
"external_url": self.external_url,
"featured": self.featured,
web_info: "WebsiteInfo", # type: ignore[name-defined]
template_path: str = "ulc_project_template_v15.html.j2",
Renders the project to HTML using a Jinja2 template.
template = env.get_template(template_path)
# For the template, you might still want just the collaborator names:
c.collaborator_info for c in self.collabs if c.collaborator_info
company_full_name=web_info.company_full_name,
company_description=web_info.company_description,
navigation=web_info.navigation,
main_logo=web_info.main_logo,
project_title=self.title,
project_summary=self.summary,
collaborators=collaborator_names,
def __str__(self) -> str:
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)