# 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]` 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 - Comments explain WHY, not WHAT ## Step 0: Verify Previous Slice Before writing any new code, verify that the slice-1 implementation is healthy. Run: ```bash 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` ```python 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` ```python 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` ```python 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` ```python 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` ```python 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) ```toml [tool.mypy] strict = true python_version = "3.12" mypy_path = "backend" packages = ["app"] plugins = ["pydantic.mypy"] ``` ### 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_services.py`) Write these tests FIRST, before any production code. Use the `db` fixture from conftest. **Required test cases for segment service:** 1. **`test_create_segment`** — Create a segment via `create_segment(db_path, request)`. Assert it returns a `Segment` with a valid UUID, `sort_order == 0`, matching title/type/content, and `created_at == updated_at`. 2. **`test_create_segment_auto_increments_sort_order`** — Create three segments. Assert their `sort_order` values are `0`, `1`, `2` respectively. 3. **`test_list_segments_empty`** — On a fresh DB, `list_segments` returns an empty list. 4. **`test_list_segments_ordered`** — Create three segments, then list. Assert they come back in `sort_order` ascending order. 5. **`test_get_segment_found`** — Create a segment, then retrieve it by ID with `get_segment`. Assert it matches. 6. **`test_get_segment_not_found`** — Call `get_segment` with a random UUID. Assert it returns `None`. 7. **`test_update_segment_title`** — Create a segment, then update only its title via `update_segment(db_path, id, request)`. Assert title changed, content unchanged, and `updated_at > created_at`. 8. **`test_update_segment_not_found`** — Call `update_segment` with a random UUID. Assert it returns `None`. 9. **`test_delete_segment`** — Create a segment, delete it with `delete_segment(db_path, id)`. Assert returns `True`. Assert `get_segment` returns `None`. 10. **`test_delete_segment_not_found`** — Call `delete_segment` with a random UUID. Assert returns `False`. 11. **`test_reorder_segments`** — Create three segments (A, B, C). Call `reorder_segments(db_path, [C.id, A.id, B.id])`. List segments and assert the new sort order is C=0, A=1, B=2. 12. **`test_reorder_segments_invalid_ids`** — Call `reorder_segments` with a list containing a non-existent UUID. Assert it raises `ValueError`. **Required test cases for site service:** 13. **`test_get_site_title_default`** — On a fresh DB, `get_site_title(db_path)` returns `"Untitled Site"`. 14. **`test_update_and_get_site_title`** — Call `update_site_title(db_path, "My Course")`. Then call `get_site_title`. Assert it returns `"My Course"`. 15. **`test_update_site_title_overwrites`** — Update title twice with different values. Assert `get_site_title` returns the second value. **Test conventions:** - Each test function takes `db: Path` as parameter (from fixture) - Import functions from `app.services.segment_service` and `app.services.site_service` - No mocking — use real SQLite on temp directories - Use `time.sleep(0.01)` before update operations to ensure `updated_at` differs from `created_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_order` as the current max `sort_order + 1` (or `0` if no segments exist) - Set `created_at` and `updated_at` to `datetime.now(UTC)` formatted as ISO 8601 strings - Insert into the `segments` table (serialize `metadata` as JSON) - Return a `Segment` model instance ### `list_segments(db_path: Path) -> list[Segment]` - Query all segments ordered by `sort_order ASC` - Deserialize `metadata` from JSON string to dict - Return a list of `Segment` model instances ### `get_segment(db_path: Path, segment_id: UUID) -> Segment | None` - Query by ID - Return `Segment` if found, `None` otherwise - Deserialize `metadata` from JSON string to dict ### `update_segment(db_path: Path, segment_id: UUID, request: SegmentUpdateRequest) -> Segment | None` - Fetch existing segment first; return `None` if not found - Only update fields that are not `None` in the request - Always update `updated_at` to `datetime.now(UTC)` - If `metadata` is provided, serialize it as JSON - Return the updated `Segment` ### `delete_segment(db_path: Path, segment_id: UUID) -> bool` - Delete the row by ID - Return `True` if a row was deleted, `False` otherwise ### `reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]` - Validate that all provided IDs exist in the database; raise `ValueError` if any are missing - Update `sort_order` for each segment to match its index in the provided list - Update `updated_at` for 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.Row` to a `Segment` model instance - Parse `metadata` from JSON string - Parse `created_at` and `updated_at` from ISO 8601 strings - Parse `id` from string to `UUID` - Parse `type` from string to `SegmentType` ## Step 3: Implement `backend/app/services/site_service.py` **Required functions:** ### `get_site_title(db_path: Path) -> str` - Query the `site` table for `key = "title"` - Return the value if found, `"Untitled Site"` if not ### `update_site_title(db_path: Path, title: str) -> str` - Upsert (INSERT OR REPLACE) the `title` key in the `site` table - Return the new title ## Verification After implementation, run these commands and ensure they all succeed: ```bash 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: ```bash 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`.