handin-website/prompts/slice-1-database-models.md
2026-03-02 20:09:27 +01:00

7.7 KiB

Slice 1: Database + Models

Role

You are a backend Python developer implementing the SQLite persistence layer and Pydantic models 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 (database.py, models.py) and one test file (test_database.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] not List[X])
  • All function signatures must have type hints (params and return)
  • Use UUID from uuid for ID fields, datetime from datetime for timestamps
  • Max 50 lines per function; extract helpers if longer
  • Guard clauses before logic (fail early, no nested conditionals)
  • No else after return/raise
  • Max 3 levels of indentation
  • Imports grouped: stdlib → third-party → local (absolute imports only: from app.module import ...)
  • No Any types. No # type: ignore without explanation
  • Pydantic models use PascalCase + suffix convention (e.g., SegmentCreateRequest, SegmentResponse)
  • Comments explain WHY, not WHAT

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/tests/conftest.py

import os
import tempfile
from collections.abc import Generator
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

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":  # noqa: F821
    from app.config import Settings, 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"}

Existing empty __init__.py files

  • backend/app/__init__.py
  • backend/app/routes/__init__.py
  • backend/app/services/__init__.py
  • backend/tests/__init__.py

Step 1: Write Tests (backend/tests/test_database.py)

Write these tests FIRST, before any production code. Use the db and tmp_data_dir fixtures from conftest.

Required test cases:

  1. test_init_db_creates_tables — Call init_db on a fresh path. Connect and verify both site and segments tables exist (query sqlite_master).

  2. test_init_db_idempotent — Call init_db twice on the same path. No error raised. Tables still exist with correct schema.

  3. test_wal_mode_enabled — After init_db, connect and run PRAGMA journal_mode. Assert the result is "wal".

  4. test_get_connection_busy_timeout — Call get_connection. Run PRAGMA busy_timeout on the returned connection. Assert the value is 5000.

  5. test_segments_table_schema — After init, verify the segments table has all expected columns: id, type, sort_order, title, content, metadata, created_at, updated_at.

  6. test_site_table_schema — After init, verify the site table has columns: key, value.

Test conventions:

  • Each test function takes tmp_data_dir: Path as parameter (from fixture)
  • Tests call init_db and get_connection directly (imported from app.database)
  • Use the db fixture where a pre-initialized database is needed
  • No mocking — use real SQLite on temp directories

Step 2: Implement backend/app/database.py

Required functions:

init_db(db_path: Path) -> None

  • Takes a Path to the SQLite database file
  • Creates parent directories if they don't exist (db_path.parent.mkdir(parents=True, exist_ok=True))
  • Connects to SQLite at db_path
  • Sets PRAGMA journal_mode=WAL
  • Sets PRAGMA busy_timeout=5000
  • Creates tables with CREATE TABLE IF NOT EXISTS:
CREATE TABLE IF NOT EXISTS site (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL
);

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
);

CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order);
  • Closes the connection when done

get_connection(db_path: Path) -> sqlite3.Connection

  • Returns a sqlite3.Connection with row_factory = sqlite3.Row
  • Sets PRAGMA busy_timeout=5000 on each new connection
  • Does NOT set WAL mode (that's a one-time setup in init_db)

Step 3: Implement backend/app/models.py

Required models:

Enums

class SegmentType(StrEnum):
    MARKDOWN = "markdown"
    PDF = "pdf"
    VIDEO = "video"
    AUDIO = "audio"
    IFRAME = "iframe"
    GALLERY = "gallery"

Domain Models

  • Segmentid: UUID, type: SegmentType, sort_order: int, title: str, content: str = "", metadata: dict[str, object] = Field(default_factory=dict), created_at: datetime, updated_at: datetime

Request Models

  • SegmentCreateRequesttype: SegmentType, title: str, content: str = "", metadata: dict[str, object] = Field(default_factory=dict)
  • SegmentUpdateRequesttitle: str | None = None, content: str | None = None, metadata: dict[str, object] | None = None
  • ReorderRequestsegment_ids: list[UUID]
  • SiteUpdateRequesttitle: str

Response Models

  • SegmentResponseid: UUID, type: SegmentType, sort_order: int, title: str, content: str, metadata: dict[str, object], created_at: datetime, updated_at: datetime
  • SiteResponsetitle: str

Use Field(default_factory=...) where needed. Use from __future__ import annotations if it helps with forward references, but prefer Python 3.12+ syntax.

Verification

After implementation, run these commands and ensure they all succeed:

uv run pytest backend/tests/test_database.py -v
uv run mypy --strict backend/app/models.py backend/app/database.py
uv run ruff check backend/app/models.py backend/app/database.py

All tests must pass. Zero mypy errors. Zero ruff errors.

Files to Create/Modify

Action File
CREATE backend/tests/test_database.py
CREATE backend/app/database.py
CREATE backend/app/models.py

Do NOT modify any other files. Do NOT modify conftest.py, config.py, or main.py.