"""Pydantic models for the HoffDesk Blog API. Matches the OpenAPI spec at shared/api-specs/blog/blog-api-spec.yaml. """ from datetime import datetime from typing import Optional from pydantic import BaseModel, Field, field_validator # ─── Common ────────────────────────────────────────────────────────────── SLUG_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" RESERVED_SLUGS = {"api", "admin", "category", "tag", "feed", "sitemap", "index"} VALID_STATUSES = {"draft", "published", "archived"} VALID_CATEGORIES = {"general", "engineering", "family", "projects", "behind-scenes", "homelab", "openclaw", "ai-news"} # ─── Response Models ───────────────────────────────────────────────────── class PostSummary(BaseModel): """Summary of a blog post for listings.""" slug: str title: str category: str author: str published_at: Optional[datetime] = None excerpt: Optional[str] = None cover_image: Optional[str] = None reading_time: Optional[int] = None featured: bool = False status: str = "draft" # draft, published, archived tags: list[str] = Field(default_factory=list) class PostDetail(PostSummary): """Full blog post with content.""" content_html: str content_md: str created_at: datetime updated_at: datetime class PostListResponse(BaseModel): """Paginated list of post summaries.""" posts: list[PostSummary] total: int page: int per_page: int total_pages: int class PostDetailResponse(BaseModel): """Single post detail response.""" post: PostDetail class CategoryInfo(BaseModel): """A category with post count.""" slug: str name: str description: Optional[str] = None post_count: int = 0 class TagInfo(BaseModel): """A tag with post count.""" name: str post_count: int = 0 class CategoryListResponse(BaseModel): """List of categories.""" categories: list[CategoryInfo] class TagListResponse(BaseModel): """List of tags.""" tags: list[TagInfo] class SearchResult(BaseModel): """Single search result.""" slug: str title: str excerpt: str category: str published_at: Optional[datetime] = None class SearchResponse(BaseModel): """Search results response.""" query: str results: list[SearchResult] total: int class DeletePostResponse(BaseModel): """Response for post deletion.""" message: str slug: str class RegenerateResponse(BaseModel): """Response for static HTML regeneration.""" message: str slug: str regenerated_at: datetime class RebuildResponse(BaseModel): """Response for full blog rebuild.""" message: str posts_processed: int started_at: datetime class DeployResponse(BaseModel): """Response for static site deployment.""" message: str files_deployed: int deployed_at: datetime destination: str # ─── Admin Preview Models ─────────────────────────────────────────────── class PreviewRequest(BaseModel): """Request to preview Markdown rendering.""" content_md: str class PreviewResponse(BaseModel): """Response with rendered HTML preview.""" content_html: str # ─── Admin Dashboard Models ────────────────────────────────────────────── class AdminStatsResponse(BaseModel): """Dashboard statistics for admin homepage.""" total: int published: int drafts: int images: int = 0 last_build_time: Optional[str] = None last_build_iso: Optional[str] = None class AdminRecentPost(BaseModel): """Recent post for admin activity feed.""" slug: str title: str category: str status: str updated_relative: str = "just now" class AdminRecentResponse(BaseModel): """Recent activity for admin dashboard.""" posts: list[AdminRecentPost] class AdminPostSummary(BaseModel): """Admin-specific post summary with excerpt.""" slug: str title: str category: str status: str updated_at: Optional[str] = None updated_relative: str = "just now" excerpt: Optional[str] = None class AdminPostListResponse(BaseModel): """Admin post list with pagination.""" posts: list[AdminPostSummary] pagination: dict = Field(default_factory=dict) class AdminImageInfo(BaseModel): """Image information for admin image manager.""" path: str filename: str relative_path: str size_bytes: int = 0 dimensions: Optional[str] = None modified: Optional[str] = None class AdminImagesResponse(BaseModel): """List of available images.""" images: list[AdminImageInfo] # ─── LocalAI Content Engine Models ──────────────────────────────────────── class TitleRequest(BaseModel): """Request to generate blog post titles.""" bullets: str category: Optional[str] = None class TitleResponse(BaseModel): """Generated title options.""" titles: list[str] class ExpandRequest(BaseModel): """Request to expand bullet points into prose.""" bullets: str section: Optional[str] = None tone: str = "technical" max_words: int = 300 class ExpandResponse(BaseModel): """Expanded content in Markdown.""" content_md: str class SEORequest(BaseModel): """Request to generate SEO metadata.""" title: str content_preview: str class SEOResponse(BaseModel): """SEO metadata for the post.""" excerpt: str tags: list[str] meta_description: str # ─── Request Models ─────────────────────────────────────────────────────── class CreatePostRequest(BaseModel): """Request to create a new blog post.""" title: str = Field(..., min_length=1, max_length=255) slug: Optional[str] = Field(None, pattern=SLUG_PATTERN) category: str = Field("general") tags: list[str] = Field(default_factory=list) author: str = Field("HoffDesk Team") excerpt: Optional[str] = None status: str = Field("draft") featured: bool = False cover_image: Optional[str] = None content_md: str = Field(..., min_length=1) @field_validator("status") @classmethod def validate_status(cls, v: str) -> str: if v not in VALID_STATUSES: raise ValueError(f"status must be one of {VALID_STATUSES}") return v @field_validator("slug") @classmethod def validate_slug_not_reserved(cls, v: Optional[str]) -> Optional[str]: if v and v in RESERVED_SLUGS: raise ValueError(f"slug '{v}' is reserved") return v class UpdatePostRequest(BaseModel): """Partial update for a blog post. Only included fields are updated.""" title: Optional[str] = Field(None, min_length=1, max_length=255) slug: Optional[str] = Field(None, pattern=SLUG_PATTERN) category: Optional[str] = None tags: Optional[list[str]] = None author: Optional[str] = None excerpt: Optional[str] = None featured: Optional[bool] = None cover_image: Optional[str] = None content_md: Optional[str] = None @field_validator("slug") @classmethod def validate_slug_not_reserved(cls, v: Optional[str]) -> Optional[str]: if v and v in RESERVED_SLUGS: raise ValueError(f"slug '{v}' is reserved") return v # ─── Content Brief v2 Models ────────────────────────────────────────────── class BriefAttempt(BaseModel): """A single attempt and why it failed.""" attempt: str = Field(..., min_length=1, max_length=1000) why_failed: str = Field(..., min_length=1, max_length=1000) class VoiceChecklist(BaseModel): """Voice checklist items — all must be true before generation.""" i_statements: bool = False temporal_markers: bool = False struggle_patterns: bool = False cost_mentions: bool = False admission_count: bool = False sounds_like_me: bool = False class BriefStatus: """Status constants for content briefs.""" DRAFT = "draft" PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" GENERATING = "generating" COMPLETED = "completed" VALID_BRIEF_STATUSES = {BriefStatus.DRAFT, BriefStatus.PENDING, BriefStatus.APPROVED, BriefStatus.REJECTED, BriefStatus.GENERATING, BriefStatus.COMPLETED} class CreateBriefRequest(BaseModel): """Request to create a new content brief.""" title: str = Field(..., min_length=1, max_length=255) struggle_angle: str = Field(..., min_length=1, max_length=2000) origin_story: str = Field(..., min_length=1, max_length=2000) attempts: list[BriefAttempt] = Field(default_factory=list, min_length=1, max_length=10) the_moment: str = Field(..., min_length=1, max_length=2000) the_fix: str = Field(..., min_length=1, max_length=2000) reflection: str = Field(..., min_length=1, max_length=2000) voice_checklist: VoiceChecklist = Field(default_factory=VoiceChecklist) style_reference: Optional[str] = None target_length: int = Field(default=1500, ge=500, le=5000) created_by: str = Field(default="hoffdesk-user") class UpdateBriefRequest(BaseModel): """Partial update for a content brief.""" title: Optional[str] = Field(None, min_length=1, max_length=255) struggle_angle: Optional[str] = Field(None, min_length=1, max_length=2000) origin_story: Optional[str] = Field(None, min_length=1, max_length=2000) attempts: Optional[list[BriefAttempt]] = Field(None, min_length=1, max_length=10) the_moment: Optional[str] = Field(None, min_length=1, max_length=2000) the_fix: Optional[str] = Field(None, min_length=1, max_length=2000) reflection: Optional[str] = Field(None, min_length=1, max_length=2000) voice_checklist: Optional[VoiceChecklist] = None style_reference: Optional[str] = None target_length: Optional[int] = Field(None, ge=500, le=5000) class BriefSummary(BaseModel): """Summary of a content brief for listings.""" id: str title: str status: str created_by: str created_at: datetime updated_at: datetime struggle_score: Optional[float] = None class BriefDetail(BriefSummary): """Full content brief detail.""" struggle_angle: str origin_story: str attempts: list[BriefAttempt] the_moment: str the_fix: str reflection: str voice_checklist: VoiceChecklist style_reference: Optional[str] = None target_length: int approved_at: Optional[datetime] = None approved_by: Optional[str] = None generation_job_id: Optional[str] = None content_output: Optional[str] = None content_html: Optional[str] = None class BriefListResponse(BaseModel): """Paginated list of brief summaries.""" briefs: list[BriefSummary] total: int page: int per_page: int total_pages: int class BriefDetailResponse(BaseModel): """Single brief detail response.""" brief: BriefDetail class SubmitBriefResponse(BaseModel): """Response when a brief is submitted for approval.""" id: str status: str message: str class ApproveBriefResponse(BaseModel): """Response when a brief is approved.""" id: str status: str message: str generation_job_id: Optional[str] = None class RejectBriefResponse(BaseModel): """Response when a brief is rejected.""" id: str status: str message: str class BriefOutputResponse(BaseModel): """Response with generated content output.""" id: str title: str content_md: str content_html: str struggle_score: Optional[float] = None generated_at: Optional[datetime] = None class StruggleScoreResponse(BaseModel): """Response with struggle score calculation.""" score: float max_score: float = 100.0 checks: dict interpretation: str class StyleReference(BaseModel): """A style reference from a published post.""" slug: str title: str excerpt: str class StyleReferenceListResponse(BaseModel): """List of available style references.""" styles: list[StyleReference] # ─── Error Model ────────────────────────────────────────────────────────── class ErrorResponse(BaseModel): """Standard error response.""" error: str code: str details: Optional[dict] = None