Skip to content

Commit 57e7fec

Browse files
pgmclaude
andcommitted
feat(breadbox): add CMS endpoints for managing help/resource content
Adds GET/POST /cms/menu and GET/POST/DELETE /cms/posts/{id} endpoints for storing and serving markdown-formatted documentation pages with a hierarchical menu structure. Write/delete endpoints are admin-only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dd3e365 commit 57e7fec

File tree

9 files changed

+627
-1
lines changed

9 files changed

+627
-1
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""add cms posts and menu tables
2+
3+
Revision ID: 0c0dd1a8925c
4+
Revises: a1b2c3d4e5f6
5+
Create Date: 2026-04-06 10:45:49.640391
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0c0dd1a8925c"
14+
down_revision = "a1b2c3d4e5f6"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.create_table(
22+
"cms_menu",
23+
sa.Column("id", sa.String(length=36), nullable=False),
24+
sa.Column("slug", sa.String(), nullable=False),
25+
sa.Column("title", sa.String(), nullable=False),
26+
sa.Column("parent_id", sa.String(length=36), nullable=True),
27+
sa.Column("order_index", sa.Integer(), nullable=False),
28+
sa.ForeignKeyConstraint(
29+
["parent_id"],
30+
["cms_menu.id"],
31+
name=op.f("fk_cms_menu_parent_id_cms_menu"),
32+
ondelete="CASCADE",
33+
),
34+
sa.PrimaryKeyConstraint("id", name=op.f("pk_cms_menu")),
35+
)
36+
op.create_table(
37+
"cms_post",
38+
sa.Column("id", sa.String(length=36), nullable=False),
39+
sa.Column("slug", sa.String(), nullable=False),
40+
sa.Column("title", sa.String(), nullable=False),
41+
sa.Column("content", sa.Text(), nullable=False),
42+
sa.Column("content_hash", sa.String(), nullable=False),
43+
sa.Column(
44+
"created_at",
45+
sa.DateTime(),
46+
server_default=sa.text("(CURRENT_TIMESTAMP)"),
47+
nullable=True,
48+
),
49+
sa.Column(
50+
"updated_at",
51+
sa.DateTime(),
52+
server_default=sa.text("(CURRENT_TIMESTAMP)"),
53+
nullable=True,
54+
),
55+
sa.PrimaryKeyConstraint("id", name=op.f("pk_cms_post")),
56+
sa.UniqueConstraint("slug", name=op.f("uq_cms_post_slug")),
57+
)
58+
op.create_table(
59+
"cms_menu_post",
60+
sa.Column("menu_id", sa.String(length=36), nullable=False),
61+
sa.Column("post_id", sa.String(length=36), nullable=False),
62+
sa.Column("order_index", sa.Integer(), nullable=False),
63+
sa.ForeignKeyConstraint(
64+
["menu_id"],
65+
["cms_menu.id"],
66+
name=op.f("fk_cms_menu_post_menu_id_cms_menu"),
67+
ondelete="CASCADE",
68+
),
69+
sa.ForeignKeyConstraint(
70+
["post_id"],
71+
["cms_post.id"],
72+
name=op.f("fk_cms_menu_post_post_id_cms_post"),
73+
ondelete="CASCADE",
74+
),
75+
sa.PrimaryKeyConstraint("menu_id", "post_id", name=op.f("pk_cms_menu_post")),
76+
)
77+
# ### end Alembic commands ###
78+
79+
80+
def downgrade():
81+
# ### commands auto generated by Alembic - please adjust! ###
82+
op.drop_table("cms_menu_post")
83+
op.drop_table("cms_post")
84+
op.drop_table("cms_menu")
85+
# ### end Alembic commands ###

breadbox/breadbox/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .metadata import router as metadata_router
1616
from .temp import router as temp_router
1717
from .health_check import router as health_check_router
18+
from .cms import router as cms_router
1819
from breadbox.schemas.custom_http_exception import ERROR_RESPONSES
1920

2021
api_router = APIRouter(responses=ERROR_RESPONSES) # type: ignore
@@ -32,3 +33,4 @@
3233
api_router.include_router(metadata_router)
3334
api_router.include_router(health_check_router)
3435
api_router.include_router(temp_router)
36+
api_router.include_router(cms_router)

breadbox/breadbox/api/cms.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from typing import List, Union
2+
3+
from fastapi import APIRouter, Depends
4+
5+
from breadbox.api.dependencies import get_admin_user, get_db_with_user, get_user
6+
from breadbox.crud import cms as cms_crud
7+
from breadbox.db.session import SessionWithUser
8+
from breadbox.schemas.cms import MenuIn, MenuOut, PostIn, PostOut, PostSummaryOut
9+
from breadbox.db.util import transaction
10+
11+
router = APIRouter(prefix="/cms", tags=["cms"])
12+
13+
14+
@router.get("/menu", operation_id="get_cms_menu", response_model=List[MenuOut])
15+
def get_menu(
16+
db: SessionWithUser = Depends(get_db_with_user), user: str = Depends(get_user),
17+
):
18+
return cms_crud.get_menu(db)
19+
20+
21+
@router.post("/menu", operation_id="set_cms_menu", response_model=List[MenuOut])
22+
def set_menu(
23+
body: List[MenuIn],
24+
db: SessionWithUser = Depends(get_db_with_user),
25+
user: str = Depends(get_admin_user),
26+
):
27+
with transaction(db):
28+
return cms_crud.set_menu(db, body)
29+
30+
31+
@router.get(
32+
"/posts",
33+
operation_id="get_cms_posts",
34+
response_model=List[Union[PostOut, PostSummaryOut]],
35+
)
36+
def get_posts(
37+
include_content: bool = False,
38+
db: SessionWithUser = Depends(get_db_with_user),
39+
user: str = Depends(get_user),
40+
):
41+
return cms_crud.get_posts(db, include_content)
42+
43+
44+
@router.get("/posts/{post_id}", operation_id="get_cms_post", response_model=PostOut)
45+
def get_post(
46+
post_id: str,
47+
db: SessionWithUser = Depends(get_db_with_user),
48+
user: str = Depends(get_user),
49+
):
50+
return cms_crud.get_post(db, post_id)
51+
52+
53+
@router.post("/posts/{post_id}", operation_id="upsert_cms_post", response_model=PostOut)
54+
def upsert_post(
55+
post_id: str,
56+
body: PostIn,
57+
db: SessionWithUser = Depends(get_db_with_user),
58+
user: str = Depends(get_admin_user),
59+
):
60+
with transaction(db):
61+
return cms_crud.upsert_post(db, post_id, body)
62+
63+
64+
@router.delete("/posts/{post_id}", operation_id="delete_cms_post", status_code=204)
65+
def delete_post(
66+
post_id: str,
67+
db: SessionWithUser = Depends(get_db_with_user),
68+
user: str = Depends(get_admin_user),
69+
):
70+
with transaction(db):
71+
cms_crud.delete_post(db, post_id)

breadbox/breadbox/api/dependencies.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from ..crud import dataset as dataset_crud
44
from breadbox.db.session import SessionLocalWithUser, SessionWithUser
5-
from breadbox.config import get_settings
5+
from breadbox.config import Settings, get_settings
66
from breadbox.schemas.custom_http_exception import DatasetNotFoundError
77
import os
88
from breadbox.utils.caching import create_caching_caller
@@ -51,6 +51,13 @@ def get_user(request: Request) -> str:
5151
return user
5252

5353

54+
def get_admin_user(request: Request, settings: Settings = Depends(get_settings)) -> str:
55+
user = get_user(request)
56+
if user not in settings.admin_users:
57+
raise HTTPException(403, "Admin access required")
58+
return user
59+
60+
5461
def get_cache():
5562
settings = get_settings()
5663
return create_caching_caller(settings.redis_host)

breadbox/breadbox/crud/cms.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from typing import List, Optional, Union
2+
3+
from sqlalchemy.orm import Session
4+
5+
from breadbox.models.cms import CmsMenu, CmsMenuPost, CmsPost
6+
from breadbox.schemas.cms import MenuIn, MenuOut, PostIn, PostOut, PostSummaryOut
7+
from breadbox.schemas.custom_http_exception import ResourceNotFoundError
8+
9+
10+
def _build_menu_out(menu: CmsMenu) -> MenuOut:
11+
posts = [link.post.slug for link in menu.post_links]
12+
child_menus = [_build_menu_out(child) for child in menu.children]
13+
return MenuOut(
14+
slug=menu.slug, title=menu.title, child_menus=child_menus, posts=posts
15+
)
16+
17+
18+
def get_menu(db: Session) -> List[MenuOut]:
19+
roots = (
20+
db.query(CmsMenu)
21+
.filter(CmsMenu.parent_id == None)
22+
.order_by(CmsMenu.order_index)
23+
.all()
24+
)
25+
return [_build_menu_out(root) for root in roots]
26+
27+
28+
def _insert_menu_nodes(
29+
db: Session, items: List[MenuIn], parent_id: Optional[str], post_slug_to_id: dict,
30+
) -> None:
31+
for order_index, item in enumerate(items):
32+
node = CmsMenu(
33+
slug=item.slug,
34+
title=item.title,
35+
parent_id=parent_id,
36+
order_index=order_index,
37+
)
38+
db.add(node)
39+
db.flush() # get node.id
40+
41+
for post_order, slug in enumerate(item.posts):
42+
post_id = post_slug_to_id.get(slug)
43+
if post_id is not None:
44+
link = CmsMenuPost(
45+
menu_id=node.id, post_id=post_id, order_index=post_order
46+
)
47+
db.add(link)
48+
49+
_insert_menu_nodes(db, item.child_menus, node.id, post_slug_to_id)
50+
51+
52+
def set_menu(db: Session, menu_data: List[MenuIn]) -> List[MenuOut]:
53+
# Delete all existing menu rows (cascade handles children and post_links)
54+
db.query(CmsMenu).filter(CmsMenu.parent_id == None).delete(
55+
synchronize_session=False
56+
)
57+
58+
# Build slug→id map for posts
59+
posts = db.query(CmsPost).all()
60+
post_slug_to_id = {p.slug: p.id for p in posts}
61+
62+
_insert_menu_nodes(db, menu_data, None, post_slug_to_id)
63+
db.flush()
64+
65+
return get_menu(db)
66+
67+
68+
def get_posts(
69+
db: Session, include_content: bool
70+
) -> List[Union[PostOut, PostSummaryOut]]:
71+
posts = db.query(CmsPost).all()
72+
if include_content:
73+
return [PostOut.model_validate(p) for p in posts]
74+
else:
75+
return [PostSummaryOut.model_validate(p) for p in posts]
76+
77+
78+
def get_post(db: Session, post_id: str) -> PostOut:
79+
post = db.query(CmsPost).filter(CmsPost.id == post_id).first()
80+
if post is None:
81+
raise ResourceNotFoundError(f"Post '{post_id}' not found")
82+
return PostOut.model_validate(post)
83+
84+
85+
def upsert_post(db: Session, post_id: str, data: PostIn) -> PostOut:
86+
post = db.query(CmsPost).filter(CmsPost.id == post_id).first()
87+
if post is None:
88+
post = CmsPost(
89+
id=post_id,
90+
slug=data.slug,
91+
title=data.title,
92+
content=data.content,
93+
content_hash=data.content_hash,
94+
)
95+
db.add(post)
96+
else:
97+
post.slug = data.slug
98+
post.title = data.title
99+
post.content = data.content
100+
post.content_hash = data.content_hash
101+
102+
db.flush()
103+
db.refresh(post)
104+
return PostOut.model_validate(post)
105+
106+
107+
def delete_post(db: Session, post_id: str) -> None:
108+
post = db.query(CmsPost).filter(CmsPost.id == post_id).first()
109+
if post is None:
110+
raise ResourceNotFoundError(f"Post '{post_id}' not found")
111+
db.delete(post)
112+
db.flush()

breadbox/breadbox/db/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
PredictiveModelConfig,
1515
PredictiveModelResult,
1616
)
17+
from breadbox.models.cms import CmsPost, CmsMenu, CmsMenuPost

breadbox/breadbox/models/cms.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import uuid
2+
from typing import List, Optional
3+
4+
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
5+
from sqlalchemy.orm import Mapped, mapped_column, relationship
6+
from sqlalchemy.sql import func
7+
8+
from breadbox.db.base_class import Base
9+
10+
11+
class CmsPost(Base):
12+
__tablename__ = "cms_post"
13+
14+
id: Mapped[str] = mapped_column(
15+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
16+
)
17+
slug: Mapped[str] = mapped_column(String, unique=True, nullable=False)
18+
title: Mapped[str] = mapped_column(String, nullable=False)
19+
content: Mapped[str] = mapped_column(Text, nullable=False)
20+
content_hash: Mapped[str] = mapped_column(String, nullable=False)
21+
created_at: Mapped[Optional[DateTime]] = mapped_column(
22+
DateTime, server_default=func.now()
23+
)
24+
updated_at: Mapped[Optional[DateTime]] = mapped_column(
25+
DateTime, server_default=func.now(), onupdate=func.now()
26+
)
27+
28+
29+
class CmsMenu(Base):
30+
__tablename__ = "cms_menu"
31+
32+
id: Mapped[str] = mapped_column(
33+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
34+
)
35+
slug: Mapped[str] = mapped_column(String, nullable=False)
36+
title: Mapped[str] = mapped_column(String, nullable=False)
37+
parent_id: Mapped[Optional[str]] = mapped_column(
38+
String(36), ForeignKey("cms_menu.id", ondelete="CASCADE"), nullable=True
39+
)
40+
order_index: Mapped[int] = mapped_column(Integer, nullable=False)
41+
42+
children: Mapped[List["CmsMenu"]] = relationship(
43+
"CmsMenu",
44+
back_populates="parent",
45+
order_by="CmsMenu.order_index",
46+
cascade="all, delete-orphan",
47+
)
48+
parent: Mapped[Optional["CmsMenu"]] = relationship(
49+
"CmsMenu", back_populates="children", remote_side=[id]
50+
)
51+
post_links: Mapped[List["CmsMenuPost"]] = relationship(
52+
"CmsMenuPost", order_by="CmsMenuPost.order_index", cascade="all, delete-orphan",
53+
)
54+
55+
56+
class CmsMenuPost(Base):
57+
__tablename__ = "cms_menu_post"
58+
59+
menu_id: Mapped[str] = mapped_column(
60+
String(36), ForeignKey("cms_menu.id", ondelete="CASCADE"), primary_key=True,
61+
)
62+
post_id: Mapped[str] = mapped_column(
63+
String(36), ForeignKey("cms_post.id", ondelete="CASCADE"), primary_key=True,
64+
)
65+
order_index: Mapped[int] = mapped_column(Integer, nullable=False)
66+
67+
post: Mapped["CmsPost"] = relationship("CmsPost")

0 commit comments

Comments
 (0)