Phase 1: Foundation - Backend Models and API
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Create Section and NavLink backend models, database collections, and CRUD API endpoints.
Architecture: Follow existing patterns from Story/Project models and handlers.
Tech Stack: FastAPI, Pydantic, MongoDB (motor), pytest
Prerequisite: Ensure make dev-backend works with local MongoDB via docker-compose.
Task 0: Verify Local Development Environment
Files:
- Check:
docker-compose.yml - Check:
.env
Step 1: Start MongoDB container
Run: docker-compose up -d mongo
Expected: Container starts successfully
Step 2: Verify MongoDB is accessible
Run: docker-compose ps
Expected: mongo container is “Up”
Step 3: Test backend starts
Run: make dev-backend
Expected: Uvicorn starts on port 5001, connects to MongoDB
Step 4: Commit (if any fixes needed)
Only if changes were required to make dev-backend work.
Task 1: Section Model
Files:
- Create:
backend/models/section.py - Modify:
backend/models/__init__.py - Test:
backend/tests/test_models.py
Step 1: Write the failing test
Add to backend/tests/test_models.py:
class TestSectionBase:
"""Tests for SectionBase model."""
def test_valid_section_base(self):
"""Test creating a valid SectionBase."""
section = SectionBase(
title="Blog",
slug="blog",
display_type="feed",
content_type="story",
nav_visibility="main",
sort_order=0,
)
assert section.title == "Blog"
assert section.slug == "blog"
assert section.display_type == "feed"
assert section.content_type == "story"
assert section.nav_visibility == "main"
assert section.sort_order == 0
assert section.parent_id is None
assert section.is_published is True
def test_section_base_with_parent(self):
"""Test creating a section with parent_id."""
section = SectionBase(
title="Photography",
slug="photography",
display_type="gallery",
content_type="image",
nav_visibility="main",
sort_order=1,
parent_id="507f1f77bcf86cd799439011",
)
assert section.parent_id == "507f1f77bcf86cd799439011"
def test_section_base_title_required(self):
"""Test that title is required."""
with pytest.raises(ValidationError):
SectionBase(
slug="blog",
display_type="feed",
content_type="story",
nav_visibility="main",
sort_order=0,
)
def test_section_base_invalid_display_type(self):
"""Test that invalid display_type is rejected."""
with pytest.raises(ValidationError):
SectionBase(
title="Blog",
slug="blog",
display_type="invalid",
content_type="story",
nav_visibility="main",
sort_order=0,
)
def test_section_base_invalid_nav_visibility(self):
"""Test that invalid nav_visibility is rejected."""
with pytest.raises(ValidationError):
SectionBase(
title="Blog",
slug="blog",
display_type="feed",
content_type="story",
nav_visibility="invalid",
sort_order=0,
)
class TestSectionCreate:
"""Tests for SectionCreate model."""
def test_valid_section_create(self):
"""Test creating a valid SectionCreate."""
section = SectionCreate(
title="Projects",
display_type="card-grid",
content_type="project",
nav_visibility="main",
sort_order=2,
)
assert section.title == "Projects"
assert section.slug is None # Generated by handler
class TestSectionUpdate:
"""Tests for SectionUpdate model."""
def test_section_update_partial(self):
"""Test updating with partial data."""
update = SectionUpdate(title="New Title")
assert update.title == "New Title"
assert update.display_type is None
assert update.is_published is None
def test_section_update_empty_valid(self):
"""Test that empty update is valid."""
update = SectionUpdate()
assert update.title is None
class TestSectionResponse:
"""Tests for SectionResponse model."""
def test_valid_section_response(self):
"""Test creating a valid SectionResponse."""
now = datetime.now(timezone.utc)
section = SectionResponse(
id="507f1f77bcf86cd799439011",
title="Blog",
slug="blog",
display_type="feed",
content_type="story",
nav_visibility="main",
sort_order=0,
is_published=True,
createdDate=now,
updatedDate=now,
)
assert section.id == "507f1f77bcf86cd799439011"
assert section.createdDate.tzinfo is not NoneStep 2: Run test to verify it fails
Run: make test
Expected: FAIL with “cannot import name ‘SectionBase’”
Step 3: Write the Section model
Create backend/models/section.py:
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
DisplayType = Literal["feed", "card-grid", "static-page", "gallery"]
ContentType = Literal["story", "project", "page", "image"]
NavVisibility = Literal["main", "secondary", "hidden"]
class SectionBase(BaseModel):
"""Base model for section data."""
title: str = Field(..., min_length=1, max_length=200)
slug: str | None = Field(None, min_length=1, max_length=200)
parent_id: str | None = Field(None)
display_type: DisplayType
content_type: ContentType
nav_visibility: NavVisibility = "main"
sort_order: int = Field(0, ge=0)
is_published: bool = True
class SectionCreate(BaseModel):
"""Model for creating a new section."""
title: str = Field(..., min_length=1, max_length=200)
parent_id: str | None = Field(None)
display_type: DisplayType
content_type: ContentType
nav_visibility: NavVisibility = "main"
sort_order: int = Field(0, ge=0)
is_published: bool = True
class SectionUpdate(BaseModel):
"""Model for updating a section (all fields optional)."""
title: str | None = Field(None, min_length=1, max_length=200)
parent_id: str | None = Field(None)
display_type: DisplayType | None = None
content_type: ContentType | None = None
nav_visibility: NavVisibility | None = None
sort_order: int | None = Field(None, ge=0)
is_published: bool | None = None
class SectionResponse(SectionBase):
"""Model for section API responses."""
id: str
slug: str
createdDate: datetime
updatedDate: datetime
user_id: str | None = None
@field_validator("createdDate", "updatedDate")
def ensure_utc(cls, value: datetime | None) -> datetime | None:
"""Ensure datetime values are in UTC timezone."""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
model_config = ConfigDict(from_attributes=True)Step 4: Add import to test file
Add to top of backend/tests/test_models.py:
from models.section import (
SectionBase,
SectionCreate,
SectionResponse,
SectionUpdate,
)Step 5: Run test to verify it passes
Run: make test
Expected: All section model tests PASS
Step 6: Commit
git add backend/models/section.py backend/tests/test_models.py
git commit -m "feat(backend): add Section model with validation
- SectionBase, SectionCreate, SectionUpdate, SectionResponse
- DisplayType: feed, card-grid, static-page, gallery
- ContentType: story, project, page, image
- NavVisibility: main, secondary, hidden
- Tests for all model variants
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 2: NavLink Model
Files:
- Create:
backend/models/navlink.py - Modify:
backend/tests/test_models.py
Step 1: Write the failing test
Add to backend/tests/test_models.py:
class TestNavLinkBase:
"""Tests for NavLinkBase model."""
def test_valid_navlink_base(self):
"""Test creating a valid NavLinkBase."""
link = NavLinkBase(
label="GitHub",
url="https://github.com/user",
sort_order=0,
)
assert link.label == "GitHub"
assert link.url == "https://github.com/user"
assert link.sort_order == 0
assert link.is_published is True
def test_navlink_internal_url(self):
"""Test creating a navlink with internal path."""
link = NavLinkBase(
label="About",
url="/about",
sort_order=1,
)
assert link.url == "/about"
def test_navlink_label_required(self):
"""Test that label is required."""
with pytest.raises(ValidationError):
NavLinkBase(url="/about", sort_order=0)
class TestNavLinkCreate:
"""Tests for NavLinkCreate model."""
def test_valid_navlink_create(self):
"""Test creating a valid NavLinkCreate."""
link = NavLinkCreate(
label="Contact",
url="/contact",
sort_order=2,
)
assert link.label == "Contact"
class TestNavLinkUpdate:
"""Tests for NavLinkUpdate model."""
def test_navlink_update_partial(self):
"""Test updating with partial data."""
update = NavLinkUpdate(label="New Label")
assert update.label == "New Label"
assert update.url is None
class TestNavLinkResponse:
"""Tests for NavLinkResponse model."""
def test_valid_navlink_response(self):
"""Test creating a valid NavLinkResponse."""
now = datetime.now(timezone.utc)
link = NavLinkResponse(
id="507f1f77bcf86cd799439011",
label="GitHub",
url="https://github.com/user",
sort_order=0,
is_published=True,
createdDate=now,
updatedDate=now,
)
assert link.id == "507f1f77bcf86cd799439011"Step 2: Run test to verify it fails
Run: make test
Expected: FAIL with “cannot import name ‘NavLinkBase’”
Step 3: Write the NavLink model
Create backend/models/navlink.py:
from datetime import datetime, timezone
from pydantic import BaseModel, ConfigDict, Field, field_validator
class NavLinkBase(BaseModel):
"""Base model for navigation link data."""
label: str = Field(..., min_length=1, max_length=100)
url: str = Field(..., min_length=1, max_length=500)
sort_order: int = Field(0, ge=0)
is_published: bool = True
class NavLinkCreate(NavLinkBase):
"""Model for creating a new navigation link."""
pass
class NavLinkUpdate(BaseModel):
"""Model for updating a navigation link (all fields optional)."""
label: str | None = Field(None, min_length=1, max_length=100)
url: str | None = Field(None, min_length=1, max_length=500)
sort_order: int | None = Field(None, ge=0)
is_published: bool | None = None
class NavLinkResponse(NavLinkBase):
"""Model for navigation link API responses."""
id: str
createdDate: datetime
updatedDate: datetime
@field_validator("createdDate", "updatedDate")
def ensure_utc(cls, value: datetime | None) -> datetime | None:
"""Ensure datetime values are in UTC timezone."""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
model_config = ConfigDict(from_attributes=True)Step 4: Add import to test file
Add to top of backend/tests/test_models.py:
from models.navlink import (
NavLinkBase,
NavLinkCreate,
NavLinkResponse,
NavLinkUpdate,
)Step 5: Run test to verify it passes
Run: make test
Expected: All navlink model tests PASS
Step 6: Commit
git add backend/models/navlink.py backend/tests/test_models.py
git commit -m "feat(backend): add NavLink model for hamburger menu
- NavLinkBase, NavLinkCreate, NavLinkUpdate, NavLinkResponse
- Supports internal paths and external URLs
- Tests for all model variants
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 3: Database Collections
Files:
- Modify:
backend/database.py
Step 1: Add collection getters
Add to backend/database.py after line 122 (after get_reactions_collection):
async def get_sections_collection() -> AsyncIOMotorCollection:
db = await get_db()
return db["sections"]
async def get_navlinks_collection() -> AsyncIOMotorCollection:
db = await get_db()
return db["navlinks"]Step 2: Add indexes in ensure_indexes function
Add to backend/database.py inside ensure_indexes() function, after reactions indexes (around line 220):
# Sections indexes
sections = db["sections"]
if not await safe_create_index(sections, "slug", unique=True):
failed_indexes.append("sections.slug")
await safe_create_index(sections, "parent_id")
await safe_create_index(sections, [("nav_visibility", 1), ("sort_order", 1)])
await safe_create_index(sections, [("is_published", 1), ("sort_order", 1)])
# NavLinks indexes
navlinks = db["navlinks"]
await safe_create_index(navlinks, [("sort_order", 1)])
await safe_create_index(navlinks, "is_published")Step 3: Run backend to verify indexes are created
Run: make dev-backend
Expected: Server starts, no index creation errors in logs
Step 4: Commit
git add backend/database.py
git commit -m "feat(backend): add sections and navlinks collections
- Collection getters for sections and navlinks
- Indexes for slug (unique), parent_id, nav_visibility, sort_order
- NavLinks indexes for sort_order and is_published
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 4: Sections Handler - GET endpoints
Files:
- Create:
backend/handlers/sections.py - Modify:
backend/app.py - Create:
backend/tests/test_sections_api.py
Step 1: Write the failing test for GET /sections
Create backend/tests/test_sections_api.py:
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from bson import ObjectId
from httpx import ASGITransport, AsyncClient
from app import app as test_app
from database import get_sections_collection
@pytest.fixture
def mock_sections_collection():
"""Mock collection for sections testing."""
mock = MagicMock()
mock.find_one = AsyncMock()
mock.count_documents = AsyncMock()
mock.insert_one = AsyncMock()
mock.update_one = AsyncMock()
mock.find = MagicMock()
return mock
@pytest.fixture
def override_sections_database(mock_sections_collection):
"""Override the sections collection to use mocks."""
async def get_mock_sections_collection():
return mock_sections_collection
test_app.dependency_overrides[get_sections_collection] = get_mock_sections_collection
yield mock_sections_collection
test_app.dependency_overrides.pop(get_sections_collection, None)
@pytest_asyncio.fixture
async def sections_async_client(override_sections_database):
"""Async test client for sections tests."""
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as ac:
yield ac
@pytest.fixture
def sample_section_doc():
"""Sample section document as stored in MongoDB."""
fixed_datetime = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
return {
"_id": ObjectId("507f1f77bcf86cd799439011"),
"title": "Blog",
"slug": "blog",
"parent_id": None,
"display_type": "feed",
"content_type": "story",
"nav_visibility": "main",
"sort_order": 0,
"is_published": True,
"createdDate": fixed_datetime,
"updatedDate": fixed_datetime,
"user_id": "user123",
}
class TestGetSections:
"""Tests for GET /sections endpoint."""
@pytest.mark.asyncio
async def test_get_sections_success(
self, sections_async_client, mock_sections_collection, sample_section_doc
):
"""Test successful retrieval of sections."""
mock_cursor = MagicMock()
mock_cursor.to_list = AsyncMock(return_value=[sample_section_doc])
mock_sections_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
mock_cursor
)
mock_sections_collection.count_documents = AsyncMock(return_value=1)
response = await sections_async_client.get("/sections")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "Blog"
assert data["items"][0]["slug"] == "blog"
@pytest.mark.asyncio
async def test_get_sections_empty(
self, sections_async_client, mock_sections_collection
):
"""Test getting sections when none exist."""
mock_cursor = MagicMock()
mock_cursor.to_list = AsyncMock(return_value=[])
mock_sections_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
mock_cursor
)
mock_sections_collection.count_documents = AsyncMock(return_value=0)
response = await sections_async_client.get("/sections")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert len(data["items"]) == 0
@pytest.mark.asyncio
async def test_get_sections_pagination(
self, sections_async_client, mock_sections_collection, sample_section_doc
):
"""Test sections pagination."""
mock_cursor = MagicMock()
mock_cursor.to_list = AsyncMock(return_value=[sample_section_doc])
mock_sections_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
mock_cursor
)
mock_sections_collection.count_documents = AsyncMock(return_value=10)
response = await sections_async_client.get("/sections?limit=5&offset=5")
assert response.status_code == 200
data = response.json()
assert data["limit"] == 5
assert data["offset"] == 5Step 2: Run test to verify it fails
Run: make test
Expected: FAIL - no route for /sections
Step 3: Create sections handler with GET endpoints
Create backend/handlers/sections.py:
from datetime import datetime, timezone
from bson import ObjectId
from database import get_sections_collection
from decorators.auth import check_write_permission, requires_auth
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from glogger import logger
from models.section import SectionCreate, SectionResponse, SectionUpdate
from models.user import UserInfo
from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import ValidationError
from utils import find_many_and_convert, find_one_and_convert, generate_unique_slug
router = APIRouter()
@router.get("/sections")
async def get_sections(
request: Request,
response: Response,
limit: int = Query(10, ge=1, le=50),
offset: int = Query(0, ge=0),
include_unpublished: bool = Query(False),
parent_id: str | None = Query(None),
nav_visibility: str | None = Query(None),
collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
"""Get all sections with pagination and filtering."""
try:
query: dict = {"deleted": {"$ne": True}}
if not include_unpublished:
query["is_published"] = True
if parent_id is not None:
query["parent_id"] = parent_id if parent_id else None
if nav_visibility:
query["nav_visibility"] = nav_visibility
logger.info_with_context(
"Fetching sections",
{
"limit": limit,
"offset": offset,
"include_unpublished": include_unpublished,
"parent_id": parent_id,
"nav_visibility": nav_visibility,
},
)
total = await collection.count_documents(query)
sort = [("sort_order", 1), ("createdDate", -1)]
sections = await find_many_and_convert(
collection,
query,
SectionResponse,
sort=sort,
limit=limit,
skip=offset,
)
logger.info_with_context(
"Successfully fetched sections",
{"count": len(sections), "total": total},
)
return {"items": sections, "total": total, "limit": limit, "offset": offset}
except Exception as e:
logger.exception_with_context(
"Error fetching sections",
{"error": str(e)},
)
logger.log_request_response(request, error=e)
raise HTTPException(
status_code=500,
detail={
"message": "Error fetching sections",
"error_type": type(e).__name__,
},
)
@router.get("/sections/{section_id}", response_model=SectionResponse)
async def get_section(
request: Request,
section_id: str,
collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
"""Get a section by ID."""
try:
if not ObjectId.is_valid(section_id):
raise HTTPException(status_code=400, detail="Invalid section ID format")
logger.info_with_context("Fetching section by ID", {"section_id": section_id})
section = await find_one_and_convert(
collection,
{"_id": ObjectId(section_id), "deleted": {"$ne": True}},
SectionResponse,
)
if not section:
raise HTTPException(status_code=404, detail="Section not found")
return section
except HTTPException:
raise
except Exception as e:
logger.exception_with_context(
"Error fetching section",
{"section_id": section_id, "error": str(e)},
)
raise HTTPException(
status_code=500,
detail={
"message": "Error fetching section",
"error_type": type(e).__name__,
},
)
@router.get("/sections/by-slug/{slug}", response_model=SectionResponse)
async def get_section_by_slug(
request: Request,
slug: str,
collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
"""Get a section by slug."""
try:
logger.info_with_context("Fetching section by slug", {"slug": slug})
section = await find_one_and_convert(
collection,
{"slug": slug, "deleted": {"$ne": True}},
SectionResponse,
)
if not section:
raise HTTPException(status_code=404, detail="Section not found")
return section
except HTTPException:
raise
except Exception as e:
logger.exception_with_context(
"Error fetching section by slug",
{"slug": slug, "error": str(e)},
)
raise HTTPException(
status_code=500,
detail={
"message": "Error fetching section",
"error_type": type(e).__name__,
},
)Step 4: Register router in app.py
Add to backend/app.py imports (around line 12):
from handlers.sections import router as sections_routerAdd to route registration (around line 229):
app.include_router(sections_router)Step 5: Run test to verify it passes
Run: make test
Expected: GET /sections tests PASS
Step 6: Commit
git add backend/handlers/sections.py backend/app.py backend/tests/test_sections_api.py
git commit -m "feat(backend): add sections GET endpoints
- GET /sections with pagination, filtering by parent_id and nav_visibility
- GET /sections/{section_id} by ID
- GET /sections/by-slug/{slug} by slug
- Tests for list and pagination
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 5: Sections Handler - POST endpoint
Files:
- Modify:
backend/handlers/sections.py - Modify:
backend/tests/test_sections_api.py - Modify:
backend/tests/conftest.py
Step 1: Write the failing test for POST /sections
Add to backend/tests/test_sections_api.py:
class TestCreateSection:
"""Tests for POST /sections endpoint."""
@pytest.mark.asyncio
async def test_create_section_success(
self,
sections_async_client,
mock_sections_collection,
sample_section_doc,
mock_auth_valid,
):
"""Test successful section creation."""
mock_sections_collection.insert_one = AsyncMock(
return_value=MagicMock(inserted_id=sample_section_doc["_id"])
)
mock_sections_collection.find_one = AsyncMock(return_value=sample_section_doc)
response = await sections_async_client.post(
"/sections",
json={
"title": "Blog",
"display_type": "feed",
"content_type": "story",
"nav_visibility": "main",
"sort_order": 0,
},
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Blog"
assert data["slug"] == "blog"
@pytest.mark.asyncio
async def test_create_section_unauthorized(
self, sections_async_client, mock_sections_collection
):
"""Test section creation without auth."""
response = await sections_async_client.post(
"/sections",
json={
"title": "Blog",
"display_type": "feed",
"content_type": "story",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_create_section_invalid_display_type(
self,
sections_async_client,
mock_sections_collection,
mock_auth_valid,
):
"""Test section creation with invalid display_type."""
response = await sections_async_client.post(
"/sections",
json={
"title": "Blog",
"display_type": "invalid",
"content_type": "story",
},
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 422Step 2: Run test to verify it fails
Run: make test
Expected: FAIL - POST endpoint not implemented
Step 3: Add POST endpoint to sections handler
Add to backend/handlers/sections.py:
@router.post("/sections", response_model=SectionResponse, status_code=201)
@requires_auth
async def create_section(
request: Request,
section: SectionCreate,
collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
"""Create a new section."""
try:
user: UserInfo = request.state.user
logger.info_with_context(
"Creating new section",
{
"title": section.title,
"display_type": section.display_type,
"content_type": section.content_type,
"user_id": user.id,
},
)
current_time = datetime.now(timezone.utc)
slug = await generate_unique_slug(collection, section.title)
document = {
**section.model_dump(),
"slug": slug,
"createdDate": current_time,
"updatedDate": current_time,
"user_id": user.id,
}
result = await collection.insert_one(document)
section_id = str(result.inserted_id)
logger.info_with_context(
"Inserted section document",
{"section_id": section_id, "slug": slug},
)
created_section = await find_one_and_convert(
collection, {"_id": result.inserted_id}, SectionResponse
)
if not created_section:
raise HTTPException(
status_code=500, detail="Failed to retrieve created section"
)
logger.info_with_context(
"Section created successfully",
{"section_id": section_id, "slug": slug},
)
return created_section
except ValidationError as e:
error_details = e.errors() if hasattr(e, "errors") else str(e)
logger.error_with_context(
"Section validation error",
{"error": str(e), "details": error_details},
)
raise HTTPException(
status_code=400,
detail={
"message": "Validation error",
"error_type": "ValidationError",
"error_details": error_details,
},
)
except Exception as e:
logger.exception_with_context(
"Error creating section",
{"error": str(e)},
)
raise HTTPException(
status_code=500,
detail={
"message": "Error creating section",
"error_type": type(e).__name__,
},
)Step 4: Run test to verify it passes
Run: make test
Expected: POST /sections tests PASS
Step 5: Commit
git add backend/handlers/sections.py backend/tests/test_sections_api.py
git commit -m "feat(backend): add sections POST endpoint
- POST /sections creates new section with auth
- Auto-generates unique slug from title
- Validates display_type and content_type
- Tests for success, auth, and validation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 6: Sections Handler - PUT and DELETE endpoints
Files:
- Modify:
backend/handlers/sections.py - Modify:
backend/tests/test_sections_api.py
Step 1: Write the failing tests
Add to backend/tests/test_sections_api.py:
class TestUpdateSection:
"""Tests for PUT /sections/{section_id} endpoint."""
@pytest.mark.asyncio
async def test_update_section_success(
self,
sections_async_client,
mock_sections_collection,
sample_section_doc,
mock_auth_valid,
):
"""Test successful section update."""
mock_sections_collection.find_one = AsyncMock(return_value=sample_section_doc)
mock_sections_collection.update_one = AsyncMock(
return_value=MagicMock(modified_count=1)
)
response = await sections_async_client.put(
"/sections/507f1f77bcf86cd799439011",
json={"title": "Updated Blog"},
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Blog" # Mock returns original
@pytest.mark.asyncio
async def test_update_section_not_found(
self,
sections_async_client,
mock_sections_collection,
mock_auth_valid,
):
"""Test updating non-existent section."""
mock_sections_collection.find_one = AsyncMock(return_value=None)
response = await sections_async_client.put(
"/sections/507f1f77bcf86cd799439011",
json={"title": "Updated Blog"},
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 404
class TestDeleteSection:
"""Tests for DELETE /sections/{section_id} endpoint."""
@pytest.mark.asyncio
async def test_delete_section_success(
self,
sections_async_client,
mock_sections_collection,
sample_section_doc,
mock_auth_valid,
):
"""Test successful section deletion."""
mock_sections_collection.find_one = AsyncMock(return_value=sample_section_doc)
mock_sections_collection.update_one = AsyncMock(
return_value=MagicMock(modified_count=1)
)
response = await sections_async_client.delete(
"/sections/507f1f77bcf86cd799439011",
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_delete_section_not_found(
self,
sections_async_client,
mock_sections_collection,
mock_auth_valid,
):
"""Test deleting non-existent section."""
mock_sections_collection.find_one = AsyncMock(return_value=None)
response = await sections_async_client.delete(
"/sections/507f1f77bcf86cd799439011",
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 404Step 2: Run test to verify it fails
Run: make test
Expected: FAIL - PUT and DELETE endpoints not implemented
Step 3: Add PUT and DELETE endpoints
Add to backend/handlers/sections.py:
@router.put("/sections/{section_id}", response_model=SectionResponse)
@requires_auth
async def update_section(
request: Request,
section_id: str,
section: SectionUpdate,
collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
"""Update a section."""
try:
user: UserInfo = request.state.user
if not ObjectId.is_valid(section_id):
raise HTTPException(status_code=400, detail="Invalid section ID format")
logger.info_with_context(
"Updating section",
{"section_id": section_id, "user_id": user.id},
)
existing_section = await find_one_and_convert(
collection,
{"_id": ObjectId(section_id), "deleted": {"$ne": True}},
SectionResponse,
)
if not existing_section:
raise HTTPException(status_code=404, detail="Section not found")
if not check_write_permission(user, existing_section.user_id):
raise HTTPException(
status_code=403,
detail="You don't have permission to edit this section",
)
current_time = datetime.now(timezone.utc)
update_data = {k: v for k, v in section.model_dump().items() if v is not None}
update_data["updatedDate"] = current_time
if section.title and existing_section.title != section.title:
update_data["slug"] = await generate_unique_slug(
collection, section.title, ObjectId(section_id)
)
result = await collection.update_one(
{"_id": ObjectId(section_id)}, {"$set": update_data}
)
if result.modified_count == 0:
raise HTTPException(status_code=500, detail="Failed to update section")
updated_section = await find_one_and_convert(
collection, {"_id": ObjectId(section_id)}, SectionResponse
)
if not updated_section:
raise HTTPException(
status_code=500, detail="Failed to retrieve updated section"
)
logger.info_with_context(
"Section updated successfully",
{"section_id": section_id},
)
return updated_section
except HTTPException:
raise
except ValidationError as e:
logger.error_with_context(
"Section validation error",
{"error": str(e)},
)
raise HTTPException(
status_code=400,
detail={
"message": "Validation error",
"error_type": "ValidationError",
},
)
except Exception as e:
logger.exception_with_context(
"Error updating section",
{"section_id": section_id, "error": str(e)},
)
raise HTTPException(
status_code=500,
detail={
"message": "Error updating section",
"error_type": type(e).__name__,
},
)
@router.delete("/sections/{section_id}", status_code=204)
@requires_auth
async def delete_section(
request: Request,
section_id: str,
collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
"""Soft delete a section."""
try:
user: UserInfo = request.state.user
if not ObjectId.is_valid(section_id):
raise HTTPException(status_code=400, detail="Invalid section ID format")
logger.info_with_context(
"Soft deleting section",
{"section_id": section_id, "user_id": user.id},
)
existing_section = await find_one_and_convert(
collection,
{"_id": ObjectId(section_id), "deleted": {"$ne": True}},
SectionResponse,
)
if not existing_section:
raise HTTPException(status_code=404, detail="Section not found")
if not check_write_permission(user, existing_section.user_id):
raise HTTPException(
status_code=403,
detail="You don't have permission to delete this section",
)
result = await collection.update_one(
{"_id": ObjectId(section_id)}, {"$set": {"deleted": True}}
)
if result.modified_count == 0:
raise HTTPException(status_code=500, detail="Failed to delete section")
logger.info_with_context(
"Section soft deleted successfully",
{"section_id": section_id},
)
except HTTPException:
raise
except Exception as e:
logger.exception_with_context(
"Error deleting section",
{"section_id": section_id, "error": str(e)},
)
raise HTTPException(
status_code=500,
detail={
"message": "Error deleting section",
"error_type": type(e).__name__,
},
)Step 4: Run test to verify it passes
Run: make test
Expected: PUT and DELETE tests PASS
Step 5: Commit
git add backend/handlers/sections.py backend/tests/test_sections_api.py
git commit -m "feat(backend): add sections PUT and DELETE endpoints
- PUT /sections/{id} updates section with auth and permission check
- DELETE /sections/{id} soft deletes with auth and permission check
- Regenerates slug if title changes
- Tests for success and not found cases
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 7: NavLinks Handler (Complete CRUD)
Files:
- Create:
backend/handlers/navlinks.py - Modify:
backend/app.py - Create:
backend/tests/test_navlinks_api.py
Step 1: Write the failing tests
Create backend/tests/test_navlinks_api.py:
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from bson import ObjectId
from httpx import ASGITransport, AsyncClient
from app import app as test_app
from database import get_navlinks_collection
@pytest.fixture
def mock_navlinks_collection():
"""Mock collection for navlinks testing."""
mock = MagicMock()
mock.find_one = AsyncMock()
mock.count_documents = AsyncMock()
mock.insert_one = AsyncMock()
mock.update_one = AsyncMock()
mock.find = MagicMock()
return mock
@pytest.fixture
def override_navlinks_database(mock_navlinks_collection):
"""Override the navlinks collection to use mocks."""
async def get_mock_navlinks_collection():
return mock_navlinks_collection
test_app.dependency_overrides[get_navlinks_collection] = get_mock_navlinks_collection
yield mock_navlinks_collection
test_app.dependency_overrides.pop(get_navlinks_collection, None)
@pytest_asyncio.fixture
async def navlinks_async_client(override_navlinks_database):
"""Async test client for navlinks tests."""
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as ac:
yield ac
@pytest.fixture
def sample_navlink_doc():
"""Sample navlink document as stored in MongoDB."""
fixed_datetime = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
return {
"_id": ObjectId("507f1f77bcf86cd799439011"),
"label": "GitHub",
"url": "https://github.com/user",
"sort_order": 0,
"is_published": True,
"createdDate": fixed_datetime,
"updatedDate": fixed_datetime,
}
class TestGetNavLinks:
"""Tests for GET /navlinks endpoint."""
@pytest.mark.asyncio
async def test_get_navlinks_success(
self, navlinks_async_client, mock_navlinks_collection, sample_navlink_doc
):
"""Test successful retrieval of navlinks."""
mock_cursor = MagicMock()
mock_cursor.to_list = AsyncMock(return_value=[sample_navlink_doc])
mock_navlinks_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
mock_cursor
)
mock_navlinks_collection.count_documents = AsyncMock(return_value=1)
response = await navlinks_async_client.get("/navlinks")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["label"] == "GitHub"
class TestCreateNavLink:
"""Tests for POST /navlinks endpoint."""
@pytest.mark.asyncio
async def test_create_navlink_success(
self,
navlinks_async_client,
mock_navlinks_collection,
sample_navlink_doc,
mock_auth_valid,
):
"""Test successful navlink creation."""
mock_navlinks_collection.insert_one = AsyncMock(
return_value=MagicMock(inserted_id=sample_navlink_doc["_id"])
)
mock_navlinks_collection.find_one = AsyncMock(return_value=sample_navlink_doc)
response = await navlinks_async_client.post(
"/navlinks",
json={
"label": "GitHub",
"url": "https://github.com/user",
"sort_order": 0,
},
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 201
data = response.json()
assert data["label"] == "GitHub"
class TestUpdateNavLink:
"""Tests for PUT /navlinks/{navlink_id} endpoint."""
@pytest.mark.asyncio
async def test_update_navlink_success(
self,
navlinks_async_client,
mock_navlinks_collection,
sample_navlink_doc,
mock_auth_valid,
):
"""Test successful navlink update."""
mock_navlinks_collection.find_one = AsyncMock(return_value=sample_navlink_doc)
mock_navlinks_collection.update_one = AsyncMock(
return_value=MagicMock(modified_count=1)
)
response = await navlinks_async_client.put(
"/navlinks/507f1f77bcf86cd799439011",
json={"label": "Updated Label"},
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 200
class TestDeleteNavLink:
"""Tests for DELETE /navlinks/{navlink_id} endpoint."""
@pytest.mark.asyncio
async def test_delete_navlink_success(
self,
navlinks_async_client,
mock_navlinks_collection,
sample_navlink_doc,
mock_auth_valid,
):
"""Test successful navlink deletion."""
mock_navlinks_collection.find_one = AsyncMock(return_value=sample_navlink_doc)
mock_navlinks_collection.delete_one = AsyncMock(
return_value=MagicMock(deleted_count=1)
)
response = await navlinks_async_client.delete(
"/navlinks/507f1f77bcf86cd799439011",
headers={"Authorization": "Bearer valid_token"},
)
assert response.status_code == 204Step 2: Run test to verify it fails
Run: make test
Expected: FAIL - no route for /navlinks
Step 3: Create navlinks handler
Create backend/handlers/navlinks.py:
from datetime import datetime, timezone
from bson import ObjectId
from database import get_navlinks_collection
from decorators.auth import requires_auth
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from glogger import logger
from models.navlink import NavLinkCreate, NavLinkResponse, NavLinkUpdate
from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import ValidationError
from utils import find_many_and_convert, find_one_and_convert
router = APIRouter()
@router.get("/navlinks")
async def get_navlinks(
request: Request,
response: Response,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
include_unpublished: bool = Query(False),
collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
"""Get all navigation links with pagination."""
try:
query: dict = {}
if not include_unpublished:
query["is_published"] = True
logger.info_with_context(
"Fetching navlinks",
{"limit": limit, "offset": offset},
)
total = await collection.count_documents(query)
sort = [("sort_order", 1)]
navlinks = await find_many_and_convert(
collection,
query,
NavLinkResponse,
sort=sort,
limit=limit,
skip=offset,
)
return {"items": navlinks, "total": total, "limit": limit, "offset": offset}
except Exception as e:
logger.exception_with_context("Error fetching navlinks", {"error": str(e)})
raise HTTPException(
status_code=500,
detail={"message": "Error fetching navlinks", "error_type": type(e).__name__},
)
@router.get("/navlinks/{navlink_id}", response_model=NavLinkResponse)
async def get_navlink(
request: Request,
navlink_id: str,
collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
"""Get a navigation link by ID."""
try:
if not ObjectId.is_valid(navlink_id):
raise HTTPException(status_code=400, detail="Invalid navlink ID format")
navlink = await find_one_and_convert(
collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
)
if not navlink:
raise HTTPException(status_code=404, detail="NavLink not found")
return navlink
except HTTPException:
raise
except Exception as e:
logger.exception_with_context("Error fetching navlink", {"error": str(e)})
raise HTTPException(
status_code=500,
detail={"message": "Error fetching navlink", "error_type": type(e).__name__},
)
@router.post("/navlinks", response_model=NavLinkResponse, status_code=201)
@requires_auth
async def create_navlink(
request: Request,
navlink: NavLinkCreate,
collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
"""Create a new navigation link."""
try:
logger.info_with_context("Creating new navlink", {"label": navlink.label})
current_time = datetime.now(timezone.utc)
document = {
**navlink.model_dump(),
"createdDate": current_time,
"updatedDate": current_time,
}
result = await collection.insert_one(document)
created_navlink = await find_one_and_convert(
collection, {"_id": result.inserted_id}, NavLinkResponse
)
if not created_navlink:
raise HTTPException(
status_code=500, detail="Failed to retrieve created navlink"
)
return created_navlink
except ValidationError as e:
raise HTTPException(status_code=400, detail={"message": "Validation error"})
except Exception as e:
logger.exception_with_context("Error creating navlink", {"error": str(e)})
raise HTTPException(
status_code=500,
detail={"message": "Error creating navlink", "error_type": type(e).__name__},
)
@router.put("/navlinks/{navlink_id}", response_model=NavLinkResponse)
@requires_auth
async def update_navlink(
request: Request,
navlink_id: str,
navlink: NavLinkUpdate,
collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
"""Update a navigation link."""
try:
if not ObjectId.is_valid(navlink_id):
raise HTTPException(status_code=400, detail="Invalid navlink ID format")
existing = await find_one_and_convert(
collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
)
if not existing:
raise HTTPException(status_code=404, detail="NavLink not found")
current_time = datetime.now(timezone.utc)
update_data = {k: v for k, v in navlink.model_dump().items() if v is not None}
update_data["updatedDate"] = current_time
result = await collection.update_one(
{"_id": ObjectId(navlink_id)}, {"$set": update_data}
)
if result.modified_count == 0:
raise HTTPException(status_code=500, detail="Failed to update navlink")
updated = await find_one_and_convert(
collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
)
return updated
except HTTPException:
raise
except Exception as e:
logger.exception_with_context("Error updating navlink", {"error": str(e)})
raise HTTPException(
status_code=500,
detail={"message": "Error updating navlink", "error_type": type(e).__name__},
)
@router.delete("/navlinks/{navlink_id}", status_code=204)
@requires_auth
async def delete_navlink(
request: Request,
navlink_id: str,
collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
"""Delete a navigation link (hard delete)."""
try:
if not ObjectId.is_valid(navlink_id):
raise HTTPException(status_code=400, detail="Invalid navlink ID format")
existing = await find_one_and_convert(
collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
)
if not existing:
raise HTTPException(status_code=404, detail="NavLink not found")
result = await collection.delete_one({"_id": ObjectId(navlink_id)})
if result.deleted_count == 0:
raise HTTPException(status_code=500, detail="Failed to delete navlink")
logger.info_with_context("NavLink deleted", {"navlink_id": navlink_id})
except HTTPException:
raise
except Exception as e:
logger.exception_with_context("Error deleting navlink", {"error": str(e)})
raise HTTPException(
status_code=500,
detail={"message": "Error deleting navlink", "error_type": type(e).__name__},
)Step 4: Register router in app.py
Add to backend/app.py imports:
from handlers.navlinks import router as navlinks_routerAdd to route registration:
app.include_router(navlinks_router)Step 5: Run test to verify it passes
Run: make test
Expected: All navlinks tests PASS
Step 6: Commit
git add backend/handlers/navlinks.py backend/app.py backend/tests/test_navlinks_api.py
git commit -m "feat(backend): add navlinks CRUD endpoints
- GET /navlinks with pagination
- GET /navlinks/{id} by ID
- POST /navlinks creates with auth
- PUT /navlinks/{id} updates with auth
- DELETE /navlinks/{id} hard deletes with auth
- Tests for all endpoints
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Task 8: Format and Final Verification
Step 1: Run formatter
Run: make format
Expected: Code formatted successfully
Step 2: Run all tests
Run: make test
Expected: All tests PASS
Step 3: Run backend manually and test endpoints
Run: make dev-backend
Test in another terminal:
# Get sections (should return empty)
curl http://localhost:5001/sections
# Get navlinks (should return empty)
curl http://localhost:5001/navlinksExpected: Both return {"items": [], "total": 0, ...}
Step 4: Final commit if any formatting changes
git add -A
git commit -m "chore: format code
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"Phase 1 Complete Checklist
- Local MongoDB works with
make dev-backend - Section model with validation
- NavLink model with validation
- Database collections with indexes
- Sections CRUD endpoints (GET, POST, PUT, DELETE)
- NavLinks CRUD endpoints (GET, POST, PUT, DELETE)
- All tests passing
- Code formatted
Next Phase: Phase 2 - Migration Infrastructure (seed initial sections, backfill section_id)