first commit

This commit is contained in:
Justin Visser 2026-03-02 20:09:27 +01:00
commit b47f825d54
83 changed files with 10016 additions and 0 deletions

View file

@ -0,0 +1,393 @@
# 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`.