12 KiB
Slice 2: Segment & Site Service Layer
Role
You are a backend Python developer implementing the service layer (business logic + persistence) for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
Objective
Create two production files (services/segment_service.py, services/site_service.py) and one test file (tests/test_services.py). All tests must pass. Both production files must pass mypy --strict.
Coding Standards (mandatory)
- Python 3.12+ features (type parameter syntax,
StrEnum,list[X]notList[X]) - All function signatures must have type hints (params and return)
- Use
UUIDfromuuidfor ID fields,datetimefromdatetimefor timestamps - Max 50 lines per function; extract helpers if longer
- Guard clauses before logic (fail early, no nested conditionals)
- No
elseafterreturn/raise - Max 3 levels of indentation
- Imports grouped: stdlib → third-party → local (absolute imports only:
from app.module import ...) - No
Anytypes. No# type: ignorewithout explanation - Comments explain WHY, not WHAT
Step 0: Verify Previous Slice
Before writing any new code, verify that the slice-1 implementation is healthy. Run:
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/database.py backend/app/models.py backend/app/config.py
uv run ruff check backend/
All commands must exit cleanly with zero errors. If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
Context: Existing Files
backend/app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_token: str
data_dir: str = "./data"
max_upload_bytes: int = 100_000_000
allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"
model_config = {"env_prefix": "HANDIN_"}
@lru_cache
def get_settings() -> Settings:
return Settings()
backend/app/database.py
import sqlite3
from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS site (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)"
)
conn.commit()
finally:
conn.close()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn
backend/app/models.py
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import BaseModel, Field
class SegmentType(StrEnum):
MARKDOWN = "markdown"
PDF = "pdf"
VIDEO = "video"
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
class Segment(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
class SegmentUpdateRequest(BaseModel):
title: str | None = None
content: str | None = None
metadata: dict[str, object] | None = None
class ReorderRequest(BaseModel):
segment_ids: list[UUID]
class SiteUpdateRequest(BaseModel):
title: str
class SegmentResponse(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str
metadata: dict[str, object]
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str
backend/tests/conftest.py
import os
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
TEST_ADMIN_TOKEN = "test-secret-token"
@pytest.fixture
def tmp_data_dir() -> Generator[Path, None, None]:
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
(data_dir / "assets").mkdir()
yield data_dir
@pytest.fixture
def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]:
original_env = os.environ.copy()
os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN
os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir)
from app.config import get_settings
get_settings.cache_clear()
yield
os.environ.clear()
os.environ.update(original_env)
get_settings.cache_clear()
@pytest.fixture
def settings(_env_settings: None) -> Settings:
from app.config import get_settings
s = get_settings()
assert isinstance(s, Settings)
return s
@pytest.fixture
def db(tmp_data_dir: Path) -> Path:
"""Return path to an initialized SQLite database in the temp dir."""
from app.database import init_db
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
return db_path
@pytest.fixture
def client(_env_settings: None) -> TestClient:
from app.main import app
return TestClient(app)
backend/app/main.py
from fastapi import FastAPI
app = FastAPI(title="Handin Website")
@app.get("/api/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
pyproject.toml (relevant sections)
[tool.mypy]
strict = true
python_version = "3.12"
mypy_path = "backend"
packages = ["app"]
plugins = ["pydantic.mypy"]
Existing empty __init__.py files
backend/app/__init__.pybackend/app/routes/__init__.pybackend/app/services/__init__.pybackend/tests/__init__.py
Step 1: Write Tests (backend/tests/test_services.py)
Write these tests FIRST, before any production code. Use the db fixture from conftest.
Required test cases for segment service:
-
test_create_segment— Create a segment viacreate_segment(db_path, request). Assert it returns aSegmentwith a valid UUID,sort_order == 0, matching title/type/content, andcreated_at == updated_at. -
test_create_segment_auto_increments_sort_order— Create three segments. Assert theirsort_ordervalues are0,1,2respectively. -
test_list_segments_empty— On a fresh DB,list_segmentsreturns an empty list. -
test_list_segments_ordered— Create three segments, then list. Assert they come back insort_orderascending order. -
test_get_segment_found— Create a segment, then retrieve it by ID withget_segment. Assert it matches. -
test_get_segment_not_found— Callget_segmentwith a random UUID. Assert it returnsNone. -
test_update_segment_title— Create a segment, then update only its title viaupdate_segment(db_path, id, request). Assert title changed, content unchanged, andupdated_at > created_at. -
test_update_segment_not_found— Callupdate_segmentwith a random UUID. Assert it returnsNone. -
test_delete_segment— Create a segment, delete it withdelete_segment(db_path, id). Assert returnsTrue. Assertget_segmentreturnsNone. -
test_delete_segment_not_found— Calldelete_segmentwith a random UUID. Assert returnsFalse. -
test_reorder_segments— Create three segments (A, B, C). Callreorder_segments(db_path, [C.id, A.id, B.id]). List segments and assert the new sort order is C=0, A=1, B=2. -
test_reorder_segments_invalid_ids— Callreorder_segmentswith a list containing a non-existent UUID. Assert it raisesValueError.
Required test cases for site service:
-
test_get_site_title_default— On a fresh DB,get_site_title(db_path)returns"Untitled Site". -
test_update_and_get_site_title— Callupdate_site_title(db_path, "My Course"). Then callget_site_title. Assert it returns"My Course". -
test_update_site_title_overwrites— Update title twice with different values. Assertget_site_titlereturns the second value.
Test conventions:
- Each test function takes
db: Pathas parameter (from fixture) - Import functions from
app.services.segment_serviceandapp.services.site_service - No mocking — use real SQLite on temp directories
- Use
time.sleep(0.01)before update operations to ensureupdated_atdiffers fromcreated_at
Step 2: Implement backend/app/services/segment_service.py
Required functions:
create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment
- Generate a new
uuid4()for the segment ID - Compute
sort_orderas the current maxsort_order + 1(or0if no segments exist) - Set
created_atandupdated_attodatetime.now(UTC)formatted as ISO 8601 strings - Insert into the
segmentstable (serializemetadataas JSON) - Return a
Segmentmodel instance
list_segments(db_path: Path) -> list[Segment]
- Query all segments ordered by
sort_order ASC - Deserialize
metadatafrom JSON string to dict - Return a list of
Segmentmodel instances
get_segment(db_path: Path, segment_id: UUID) -> Segment | None
- Query by ID
- Return
Segmentif found,Noneotherwise - Deserialize
metadatafrom JSON string to dict
update_segment(db_path: Path, segment_id: UUID, request: SegmentUpdateRequest) -> Segment | None
- Fetch existing segment first; return
Noneif not found - Only update fields that are not
Nonein the request - Always update
updated_attodatetime.now(UTC) - If
metadatais provided, serialize it as JSON - Return the updated
Segment
delete_segment(db_path: Path, segment_id: UUID) -> bool
- Delete the row by ID
- Return
Trueif a row was deleted,Falseotherwise
reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]
- Validate that all provided IDs exist in the database; raise
ValueErrorif any are missing - Update
sort_orderfor each segment to match its index in the provided list - Update
updated_atfor all reordered segments - Use a transaction (all updates succeed or none do)
- Return the newly ordered list of segments
Helper function (private):
_row_to_segment(row: sqlite3.Row) -> Segment
- Convert a
sqlite3.Rowto aSegmentmodel instance - Parse
metadatafrom JSON string - Parse
created_atandupdated_atfrom ISO 8601 strings - Parse
idfrom string toUUID - Parse
typefrom string toSegmentType
Step 3: Implement backend/app/services/site_service.py
Required functions:
get_site_title(db_path: Path) -> str
- Query the
sitetable forkey = "title" - Return the value if found,
"Untitled Site"if not
update_site_title(db_path: Path, title: str) -> str
- Upsert (INSERT OR REPLACE) the
titlekey in thesitetable - Return the new title
Verification
After implementation, run these commands and ensure they all succeed:
uv run pytest backend/tests/test_services.py -v
uv run mypy --strict backend/app/services/segment_service.py backend/app/services/site_service.py
uv run ruff check backend/app/services/segment_service.py backend/app/services/site_service.py
Then run the full verification suite to ensure nothing is broken and all files pass strict checks:
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/ backend/tests/
uv run ruff check backend/
All tests must pass. Zero mypy errors across the entire backend. Zero ruff errors.
Files to Create/Modify
| Action | File |
|---|---|
| CREATE | backend/tests/test_services.py |
| CREATE | backend/app/services/segment_service.py |
| CREATE | backend/app/services/site_service.py |
Do NOT modify any other files. Do NOT modify conftest.py, config.py, main.py, database.py, or models.py.