📄 models.py 12,988 bytes Apr 22, 2026 📋 Raw

"""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