first commit
This commit is contained in:
commit
b47f825d54
83 changed files with 10016 additions and 0 deletions
241
prompts/slice-1-database-models.md
Normal file
241
prompts/slice-1-database-models.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# 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`
|
||||
```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/tests/conftest.py`
|
||||
```python
|
||||
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`
|
||||
```python
|
||||
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`:
|
||||
|
||||
```sql
|
||||
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
|
||||
```python
|
||||
class SegmentType(StrEnum):
|
||||
MARKDOWN = "markdown"
|
||||
PDF = "pdf"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
IFRAME = "iframe"
|
||||
GALLERY = "gallery"
|
||||
```
|
||||
|
||||
### Domain Models
|
||||
- **`Segment`** — `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`
|
||||
|
||||
### Request Models
|
||||
- **`SegmentCreateRequest`** — `type: SegmentType`, `title: str`, `content: str = ""`, `metadata: dict[str, object] = Field(default_factory=dict)`
|
||||
- **`SegmentUpdateRequest`** — `title: str | None = None`, `content: str | None = None`, `metadata: dict[str, object] | None = None`
|
||||
- **`ReorderRequest`** — `segment_ids: list[UUID]`
|
||||
- **`SiteUpdateRequest`** — `title: str`
|
||||
|
||||
### Response Models
|
||||
- **`SegmentResponse`** — `id: UUID`, `type: SegmentType`, `sort_order: int`, `title: str`, `content: str`, `metadata: dict[str, object]`, `created_at: datetime`, `updated_at: datetime`
|
||||
- **`SiteResponse`** — `title: 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:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
393
prompts/slice-2-segment-service.md
Normal file
393
prompts/slice-2-segment-service.md
Normal 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`.
|
||||
670
prompts/slice-3-api-routes.md
Normal file
670
prompts/slice-3-api-routes.md
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
# Slice 3: Auth + API Routes + Concurrency
|
||||
|
||||
## Role
|
||||
|
||||
You are a backend Python developer implementing the auth dependency, all API route handlers, and concurrency tests for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
|
||||
|
||||
## Objective
|
||||
|
||||
Create five production files (`auth.py`, `routes/auth.py`, `routes/segments.py`, `routes/site.py`) and modify one (`main.py`). Create two test files (`tests/test_routes.py`, `tests/test_concurrent.py`). All tests must pass. All 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 Slices
|
||||
|
||||
Before writing any new code, verify that slices 1 and 2 are healthy. Run:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests/ -v
|
||||
uv run mypy --strict backend/app/
|
||||
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/app/services/segment_service.py`
|
||||
```python
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.database import get_connection
|
||||
from app.models import Segment, SegmentCreateRequest, SegmentType, SegmentUpdateRequest
|
||||
|
||||
|
||||
def _row_to_segment(row: sqlite3.Row) -> Segment:
|
||||
return Segment(
|
||||
id=UUID(row["id"]),
|
||||
type=SegmentType(row["type"]),
|
||||
sort_order=row["sort_order"],
|
||||
title=row["title"],
|
||||
content=row["content"],
|
||||
metadata=json.loads(row["metadata"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
)
|
||||
|
||||
|
||||
def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM segments"
|
||||
).fetchone()
|
||||
sort_order: int = row["next_order"] if row else 0
|
||||
|
||||
segment_id = uuid4()
|
||||
now = datetime.now(UTC).isoformat()
|
||||
metadata_json = json.dumps(request.metadata)
|
||||
|
||||
cols = "id, type, sort_order, title, content, metadata, created_at, updated_at"
|
||||
conn.execute(
|
||||
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(str(segment_id), request.type.value, sort_order, request.title,
|
||||
request.content, metadata_json, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return Segment(
|
||||
id=segment_id,
|
||||
type=request.type,
|
||||
sort_order=sort_order,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
metadata=request.metadata,
|
||||
created_at=datetime.fromisoformat(now),
|
||||
updated_at=datetime.fromisoformat(now),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_segments(db_path: Path) -> list[Segment]:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM segments ORDER BY sort_order ASC"
|
||||
).fetchall()
|
||||
return [_row_to_segment(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_segment(db_path: Path, segment_id: UUID) -> Segment | None:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM segments WHERE id = ?", (str(segment_id),)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_segment(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_segment(
|
||||
db_path: Path, segment_id: UUID, request: SegmentUpdateRequest
|
||||
) -> Segment | None:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM segments WHERE id = ?", (str(segment_id),)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
title = request.title if request.title is not None else existing["title"]
|
||||
content = request.content if request.content is not None else existing["content"]
|
||||
metadata_json = (
|
||||
json.dumps(request.metadata) if request.metadata is not None
|
||||
else existing["metadata"]
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE segments SET title = ?, content = ?, metadata = ?, updated_at = ?
|
||||
WHERE id = ?""",
|
||||
(title, content, metadata_json, now, str(segment_id)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return get_segment(db_path, segment_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_segment(db_path: Path, segment_id: UUID) -> bool:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM segments WHERE id = ?", (str(segment_id),)
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
existing_ids = {
|
||||
row["id"]
|
||||
for row in conn.execute("SELECT id FROM segments").fetchall()
|
||||
}
|
||||
requested_ids = {str(sid) for sid in segment_ids}
|
||||
missing = requested_ids - existing_ids
|
||||
if missing:
|
||||
raise ValueError(f"Segment IDs not found: {missing}")
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
for index, sid in enumerate(segment_ids):
|
||||
conn.execute(
|
||||
"UPDATE segments SET sort_order = ?, updated_at = ? WHERE id = ?",
|
||||
(index, now, str(sid)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return list_segments(db_path)
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### `backend/app/services/site_service.py`
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
|
||||
def get_site_title(db_path: Path) -> str:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM site WHERE key = ?", ("title",)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return "Untitled Site"
|
||||
return str(row["value"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_site_title(db_path: Path, title: str) -> str:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO site (key, value) VALUES (?, ?)",
|
||||
("title", title),
|
||||
)
|
||||
conn.commit()
|
||||
return title
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### `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"}
|
||||
```
|
||||
|
||||
### `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)
|
||||
```
|
||||
|
||||
### `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`
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- The routes layer is thin: parse request → call service → return response
|
||||
- Authentication uses a shared token (`Settings.admin_token`) checked via a FastAPI dependency
|
||||
- Auth accepts token via **two methods**: `?token=` query param (checked first) OR `Authorization: Bearer <token>` header (fallback)
|
||||
- All admin endpoints (create, update, delete, reorder segments; update site title) require auth
|
||||
- Public endpoints (list segments, get segment, get site title, health) require no authentication
|
||||
- The database path is derived from `Settings.data_dir` via a FastAPI dependency
|
||||
- The `main.py` lifespan must call `init_db()` to ensure the database exists on startup
|
||||
- Register the `/reorder` route **before** `/{segment_id}` to avoid path conflict
|
||||
|
||||
## Step 1: Write Tests
|
||||
|
||||
Write ALL tests FIRST, before any production code. Use the `client` fixture from conftest.
|
||||
|
||||
### `backend/tests/test_routes.py`
|
||||
|
||||
**Helper constants at module level:**
|
||||
```python
|
||||
from tests.conftest import TEST_ADMIN_TOKEN
|
||||
|
||||
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
|
||||
```
|
||||
|
||||
**Segment route tests:**
|
||||
|
||||
1. **`test_list_segments_empty`** — `GET /api/segments` returns `200` with an empty JSON list.
|
||||
|
||||
2. **`test_create_segment`** — `POST /api/segments` with `BEARER_HEADERS` and body `{"type": "markdown", "title": "Intro"}`. Assert `201`, response has `id`, `title == "Intro"`, `type == "markdown"`, `sort_order == 0`.
|
||||
|
||||
3. **`test_create_segment_unauthorized`** — `POST /api/segments` without auth headers. Assert `401`.
|
||||
|
||||
4. **`test_create_segment_wrong_token`** — `POST /api/segments` with `Authorization: Bearer wrong-token`. Assert `401`.
|
||||
|
||||
5. **`test_create_segment_via_query_param`** — `POST /api/segments?token={TEST_ADMIN_TOKEN}` (no headers). Assert `201`.
|
||||
|
||||
6. **`test_get_segment`** — Create a segment via POST, then `GET /api/segments/{id}`. Assert `200` and matching data.
|
||||
|
||||
7. **`test_get_segment_not_found`** — `GET /api/segments/{random_uuid}`. Assert `404`.
|
||||
|
||||
8. **`test_update_segment`** — Create a segment, then `PATCH /api/segments/{id}` with `BEARER_HEADERS` and `{"title": "Updated"}`. Assert `200` and `title == "Updated"`.
|
||||
|
||||
9. **`test_update_segment_not_found`** — `PATCH /api/segments/{random_uuid}` with `BEARER_HEADERS`. Assert `404`.
|
||||
|
||||
10. **`test_update_segment_unauthorized`** — `PATCH /api/segments/{id}` without auth. Assert `401`.
|
||||
|
||||
11. **`test_delete_segment`** — Create a segment, then `DELETE /api/segments/{id}` with `BEARER_HEADERS`. Assert `204`. Confirm `GET /api/segments/{id}` returns `404`.
|
||||
|
||||
12. **`test_delete_segment_not_found`** — `DELETE /api/segments/{random_uuid}` with `BEARER_HEADERS`. Assert `404`.
|
||||
|
||||
13. **`test_delete_segment_unauthorized`** — `DELETE /api/segments/{id}` without auth. Assert `401`.
|
||||
|
||||
14. **`test_reorder_segments`** — Create three segments (A, B, C). `PUT /api/segments/reorder` with `BEARER_HEADERS` and `{"segment_ids": [C.id, A.id, B.id]}`. Assert `200`. List segments and verify new order is C, A, B.
|
||||
|
||||
15. **`test_reorder_segments_invalid_ids`** — `PUT /api/segments/reorder` with `BEARER_HEADERS` and a non-existent UUID in the list. Assert `400`.
|
||||
|
||||
16. **`test_reorder_segments_unauthorized`** — `PUT /api/segments/reorder` without auth. Assert `401`.
|
||||
|
||||
**Site route tests:**
|
||||
|
||||
17. **`test_get_site_title_default`** — `GET /api/site`. Assert `200` and `title == "Untitled Site"`.
|
||||
|
||||
18. **`test_update_site_title`** — `PUT /api/site` with `BEARER_HEADERS` and `{"title": "My Course"}`. Assert `200` and `title == "My Course"`. Then `GET /api/site` confirms it.
|
||||
|
||||
19. **`test_update_site_title_unauthorized`** — `PUT /api/site` without auth. Assert `401`.
|
||||
|
||||
**Auth route tests:**
|
||||
|
||||
20. **`test_auth_verify_valid_bearer`** — `GET /api/auth/verify` with `BEARER_HEADERS`. Assert `200` and body `{"valid": true}`.
|
||||
|
||||
21. **`test_auth_verify_valid_query_param`** — `GET /api/auth/verify?token={TEST_ADMIN_TOKEN}`. Assert `200` and body `{"valid": true}`.
|
||||
|
||||
22. **`test_auth_verify_invalid`** — `GET /api/auth/verify` with wrong token. Assert `401`.
|
||||
|
||||
23. **`test_auth_verify_no_token`** — `GET /api/auth/verify` with no auth. Assert `401`.
|
||||
|
||||
**Health test:**
|
||||
|
||||
24. **`test_health`** — `GET /api/health`. Assert `200` and `status == "ok"`.
|
||||
|
||||
**Test conventions:**
|
||||
- Each test function takes `client: TestClient` as parameter (from fixture)
|
||||
- No mocking — use real HTTP calls via TestClient against real SQLite on temp directories
|
||||
- Create segments via `client.post(...)` within tests rather than calling service functions directly
|
||||
|
||||
### `backend/tests/test_concurrent.py`
|
||||
|
||||
Use the `client` fixture. Import `TEST_ADMIN_TOKEN` from conftest.
|
||||
|
||||
1. **`test_concurrent_creates`** — Use `concurrent.futures.ThreadPoolExecutor` to fire 10 concurrent `POST /api/segments` requests (all with auth). Assert all 10 return `201`. Assert `GET /api/segments` returns 10 segments with 10 unique IDs and `sort_order` values `0` through `9` (no gaps, no duplicates).
|
||||
|
||||
2. **`test_concurrent_create_and_reorder`** — Create 3 segments sequentially. Then concurrently: create 2 more segments AND reorder the original 3. Assert no errors (all requests return 2xx). Assert final DB state is consistent (5 total segments, all have valid `sort_order` values).
|
||||
|
||||
**Test conventions:**
|
||||
- Use `concurrent.futures.ThreadPoolExecutor(max_workers=10)` for thread-based concurrency
|
||||
- Each thread gets its own `TestClient` call (the underlying SQLite handles concurrency via WAL + busy_timeout)
|
||||
|
||||
## Step 2: Implement `backend/app/auth.py`
|
||||
|
||||
**Single function (used as FastAPI dependency):**
|
||||
|
||||
### `require_admin(request: Request) -> None`
|
||||
- First check for `token` query parameter in `request.query_params`
|
||||
- If not present, check `Authorization` header for `Bearer <token>` format
|
||||
- Compare extracted token against `get_settings().admin_token`
|
||||
- Raise `HTTPException(status_code=401, detail="Invalid or missing token")` if:
|
||||
- No token found in either location
|
||||
- Token doesn't match
|
||||
- Import `Request` from `fastapi`, `HTTPException` from `fastapi`
|
||||
|
||||
## Step 3: Implement `backend/app/routes/auth.py`
|
||||
|
||||
Create a FastAPI `APIRouter` with `prefix="/api/auth"` and `tags=["auth"]`.
|
||||
|
||||
### `GET /verify` → `dict[str, bool]`
|
||||
- Depends on `require_admin` (if the dependency passes, token is valid)
|
||||
- Returns `{"valid": True}`
|
||||
|
||||
## Step 4: Implement `backend/app/routes/segments.py`
|
||||
|
||||
Create a FastAPI `APIRouter` with `prefix="/api/segments"` and `tags=["segments"]`.
|
||||
|
||||
**Shared dependency (define in this file):**
|
||||
|
||||
### `get_db_path() -> Path`
|
||||
- A FastAPI dependency (use `Depends`)
|
||||
- Reads `data_dir` from `get_settings()`
|
||||
- Computes `db_path = Path(data_dir) / "handin.db"`
|
||||
- Calls `init_db(db_path)` to ensure the database is initialized
|
||||
- Returns the `db_path`
|
||||
|
||||
**Endpoints (register `/reorder` BEFORE `/{segment_id}`):**
|
||||
|
||||
### `GET /` → `list[SegmentResponse]`
|
||||
- Public (no auth required)
|
||||
- Calls `segment_service.list_segments(db_path)`
|
||||
- Returns `200` with list of `SegmentResponse`
|
||||
|
||||
### `POST /` → `SegmentResponse`
|
||||
- Admin only (`Depends(require_admin)`)
|
||||
- Accepts `SegmentCreateRequest` as JSON body
|
||||
- Calls `segment_service.create_segment(db_path, request)`
|
||||
- Returns `201` with the created `SegmentResponse`
|
||||
|
||||
### `PUT /reorder` → `list[SegmentResponse]`
|
||||
- Admin only
|
||||
- Accepts `ReorderRequest` as JSON body
|
||||
- Calls `segment_service.reorder_segments(db_path, request.segment_ids)`
|
||||
- Catches `ValueError` and returns `400` with error detail
|
||||
- Returns `200` with the reordered list on success
|
||||
|
||||
### `GET /{segment_id}` → `SegmentResponse`
|
||||
- Public (no auth required)
|
||||
- Calls `segment_service.get_segment(db_path, segment_id)`
|
||||
- Returns `200` if found, raises `HTTPException(404)` if not
|
||||
|
||||
### `PATCH /{segment_id}` → `SegmentResponse`
|
||||
- Admin only
|
||||
- Accepts `SegmentUpdateRequest` as JSON body
|
||||
- Calls `segment_service.update_segment(db_path, segment_id, request)`
|
||||
- Returns `200` if found and updated, raises `HTTPException(404)` if not
|
||||
|
||||
### `DELETE /{segment_id}` → `None`
|
||||
- Admin only
|
||||
- Calls `segment_service.delete_segment(db_path, segment_id)`
|
||||
- Returns `204` if deleted, raises `HTTPException(404)` if not found
|
||||
|
||||
## Step 5: Implement `backend/app/routes/site.py`
|
||||
|
||||
Create a FastAPI `APIRouter` with `prefix="/api/site"` and `tags=["site"]`.
|
||||
|
||||
Reuse `get_db_path` from `segments.py` (or extract to a shared location if preferred).
|
||||
|
||||
### `GET /` → `SiteResponse`
|
||||
- Public (no auth required)
|
||||
- Calls `site_service.get_site_title(db_path)`
|
||||
- Returns `200` with `SiteResponse`
|
||||
|
||||
### `PUT /` → `SiteResponse`
|
||||
- Admin only (`Depends(require_admin)`)
|
||||
- Accepts `SiteUpdateRequest` as JSON body
|
||||
- Calls `site_service.update_site_title(db_path, request.title)`
|
||||
- Returns `200` with `SiteResponse`
|
||||
|
||||
## Step 6: Modify `backend/app/main.py`
|
||||
|
||||
Update `main.py` to:
|
||||
|
||||
- Add a **lifespan** context manager that calls `init_db()` on startup (compute `db_path` from `get_settings().data_dir`)
|
||||
- Import and register all three routers: `auth_router`, `segments_router`, `site_router`
|
||||
- Keep the existing `/api/health` endpoint
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation, run these commands and ensure they all succeed:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests/test_routes.py -v
|
||||
uv run pytest backend/tests/test_concurrent.py -v
|
||||
uv run mypy --strict backend/app/auth.py backend/app/routes/auth.py backend/app/routes/segments.py backend/app/routes/site.py backend/app/main.py
|
||||
uv run ruff check backend/app/auth.py backend/app/routes/ backend/app/main.py
|
||||
```
|
||||
|
||||
Then run the **full** verification suite to ensure nothing is broken:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests/ -v
|
||||
uv run mypy --strict backend/app/ backend/tests/
|
||||
uv run ruff check backend/
|
||||
```
|
||||
|
||||
All tests must pass (~40 total: 6 database + 15 service + 24 route + 2 concurrency). Zero mypy errors. Zero ruff errors.
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| CREATE | `backend/tests/test_routes.py` |
|
||||
| CREATE | `backend/tests/test_concurrent.py` |
|
||||
| CREATE | `backend/app/auth.py` |
|
||||
| CREATE | `backend/app/routes/auth.py` |
|
||||
| CREATE | `backend/app/routes/segments.py` |
|
||||
| CREATE | `backend/app/routes/site.py` |
|
||||
| MODIFY | `backend/app/main.py` |
|
||||
|
||||
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, `database.py`, `models.py`, or the service files.
|
||||
499
prompts/slice-4-assets-docker.md
Normal file
499
prompts/slice-4-assets-docker.md
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
# Slice 4: Asset Service + Routes + Docker
|
||||
|
||||
## Role
|
||||
|
||||
You are a backend Python developer implementing the asset upload/delete service, asset API routes, and Docker deployment for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
|
||||
|
||||
## Objective
|
||||
|
||||
Create three production files (`services/asset_service.py`, `routes/assets.py`, `Dockerfile`, `docker-compose.yml`) and modify one (`main.py`). Create one test file (`tests/test_assets.py`). All tests must pass. All 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
|
||||
- Use `Annotated[..., Depends(...)]` type aliases to satisfy ruff B008 (no function calls in default arguments)
|
||||
|
||||
## Step 0: Verify Previous Slices
|
||||
|
||||
Before writing any new code, verify that slices 1–3 are healthy. Run:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests/ -v
|
||||
uv run mypy --strict backend/app/
|
||||
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/auth.py`
|
||||
```python
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def require_admin(request: Request) -> None:
|
||||
token = request.query_params.get("token")
|
||||
if token is None:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header.removeprefix("Bearer ")
|
||||
|
||||
if token is None or token != get_settings().admin_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing token")
|
||||
```
|
||||
|
||||
### `backend/app/routes/segments.py` (for `get_db_path` and type alias patterns)
|
||||
```python
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
|
||||
from app.auth import require_admin
|
||||
from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.models import (
|
||||
ReorderRequest,
|
||||
SegmentCreateRequest,
|
||||
SegmentResponse,
|
||||
SegmentUpdateRequest,
|
||||
)
|
||||
from app.services import segment_service
|
||||
|
||||
router = APIRouter(prefix="/api/segments", tags=["segments"])
|
||||
|
||||
# Serialize writes to prevent sort_order race conditions in SQLite
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
data_dir = get_settings().data_dir
|
||||
db_path = Path(data_dir) / "handin.db"
|
||||
init_db(db_path)
|
||||
return db_path
|
||||
|
||||
|
||||
DbPath = Annotated[Path, Depends(get_db_path)]
|
||||
Admin = Annotated[None, Depends(require_admin)]
|
||||
|
||||
# ... endpoints follow using DbPath and Admin type aliases ...
|
||||
```
|
||||
|
||||
### `backend/app/main.py`
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.routes.auth import router as auth_router
|
||||
from app.routes.segments import router as segments_router
|
||||
from app.routes.site import router as site_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
db_path = Path(get_settings().data_dir) / "handin.db"
|
||||
init_db(db_path)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Handin Website", lifespan=lifespan)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(segments_router)
|
||||
app.include_router(site_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
### `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)
|
||||
```
|
||||
|
||||
### `pyproject.toml`
|
||||
```toml
|
||||
[project]
|
||||
name = "handin-website"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi==0.135.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-settings==2.13.1",
|
||||
"python-multipart==0.0.22",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest==9.0.2",
|
||||
"httpx==0.28.1",
|
||||
"ruff==0.15.4",
|
||||
"mypy==1.19.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["backend/tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 99
|
||||
src = ["backend"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app"]
|
||||
|
||||
[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`
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- Assets are uploaded as multipart files and stored as `data/assets/<uuid>.<ext>`
|
||||
- Original filename is NOT used on disk (prevents path traversal and collisions)
|
||||
- The extension is extracted from the original filename, validated against `Settings.allowed_upload_types`
|
||||
- File size is validated against `Settings.max_upload_bytes`
|
||||
- Assets are served via FastAPI's `StaticFiles` mount at `/api/assets`
|
||||
- Upload and delete endpoints require admin auth
|
||||
- The asset service is a pure function layer — it receives `assets_dir: Path` and operates on the filesystem
|
||||
- Docker: multi-stage build (Node frontend build → Python backend), single container serves everything
|
||||
|
||||
## Step 1: Write Tests
|
||||
|
||||
Write ALL tests FIRST, before any production code. Use the `client` fixture from conftest.
|
||||
|
||||
### `backend/tests/test_assets.py`
|
||||
|
||||
**Helper constants at module level:**
|
||||
```python
|
||||
from tests.conftest import TEST_ADMIN_TOKEN
|
||||
|
||||
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
|
||||
```
|
||||
|
||||
**Asset route tests:**
|
||||
|
||||
1. **`test_upload_asset`** — `POST /api/assets` with `BEARER_HEADERS` and a multipart file upload (`file` field, filename `test.pdf`, content `b"fake pdf content"`, content type `application/pdf`). Assert `201`. Response JSON has `filename` key. The `filename` value should NOT equal `test.pdf` (it's UUID-based). The `filename` should end with `.pdf`.
|
||||
|
||||
2. **`test_upload_asset_file_exists_on_disk`** — Upload a file via POST. Extract the returned `filename`. Use the `client` fixture's app settings to derive the assets directory (`Path(get_settings().data_dir) / "assets"`). Assert the file exists on disk at `assets_dir / filename`. Assert the file content matches what was uploaded.
|
||||
|
||||
3. **`test_upload_asset_disallowed_type`** — Upload a file with filename `malware.exe`. Assert `415` (Unsupported Media Type).
|
||||
|
||||
4. **`test_upload_asset_too_large`** — Upload a file larger than `max_upload_bytes`. Since the default is 100MB and creating that in a test is impractical, temporarily set `HANDIN_MAX_UPLOAD_BYTES` to a small value (e.g. `"100"`) in the test environment, clear and rebuild settings, then upload a file larger than 100 bytes. Assert `413` (Request Entity Too Large). Restore the environment after the test. Alternatively, use a fixture or monkeypatch approach — the key is that the test must verify the size limit works.
|
||||
|
||||
5. **`test_upload_asset_unauthorized`** — `POST /api/assets` without auth headers. Assert `401`.
|
||||
|
||||
6. **`test_delete_asset`** — Upload a file, extract the `filename`. Then `DELETE /api/assets/{filename}` with `BEARER_HEADERS`. Assert `204`. Verify the file no longer exists on disk.
|
||||
|
||||
7. **`test_delete_asset_not_found`** — `DELETE /api/assets/nonexistent.pdf` with `BEARER_HEADERS`. Assert `404`.
|
||||
|
||||
8. **`test_delete_asset_unauthorized`** — `DELETE /api/assets/somefile.pdf` without auth. Assert `401`.
|
||||
|
||||
9. **`test_serve_asset`** — Upload a file via the API. Then `GET /api/assets/{filename}` (no auth needed — public). Assert `200`. Assert the response body matches the uploaded content.
|
||||
|
||||
**Test conventions:**
|
||||
- Each test function takes `client: TestClient` as parameter (from fixture)
|
||||
- No mocking — use real HTTP calls via TestClient against real filesystem on temp directories
|
||||
- Use `from app.config import get_settings` within tests where you need `data_dir`
|
||||
|
||||
## Step 2: Implement `backend/app/services/asset_service.py`
|
||||
|
||||
Two functions:
|
||||
|
||||
### `save_asset(assets_dir: Path, filename: str, content: bytes, allowed_types: str, max_bytes: int) -> str`
|
||||
- Extract extension from `filename` (lowercase, without the dot)
|
||||
- Validate extension is in `allowed_types` (comma-separated string) — raise `ValueError("Unsupported file type")` if not
|
||||
- Validate `len(content)` does not exceed `max_bytes` — raise `ValueError("File too large")` if it does
|
||||
- Generate a UUID-based filename: `f"{uuid4()}.{ext}"`
|
||||
- Ensure `assets_dir` exists (`mkdir(parents=True, exist_ok=True)`)
|
||||
- Write `content` to `assets_dir / new_filename`
|
||||
- Return `new_filename`
|
||||
|
||||
### `delete_asset(assets_dir: Path, filename: str) -> bool`
|
||||
- Construct full path: `assets_dir / filename`
|
||||
- Validate the resolved path is within `assets_dir` (prevent path traversal) — return `False` if not
|
||||
- If the file exists, delete it and return `True`
|
||||
- Return `False` if the file doesn't exist
|
||||
|
||||
## Step 3: Implement `backend/app/routes/assets.py`
|
||||
|
||||
Create a FastAPI `APIRouter` with `prefix="/api/assets"` and `tags=["assets"]`.
|
||||
|
||||
**Shared dependency:**
|
||||
|
||||
### `get_assets_dir() -> Path`
|
||||
- Reads `data_dir` from `get_settings()`
|
||||
- Returns `Path(data_dir) / "assets"`
|
||||
|
||||
Use `Annotated` type aliases for dependencies (same pattern as `segments.py`).
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
### `POST /` → `dict[str, str]` (status 201)
|
||||
- Admin only (`Depends(require_admin)`)
|
||||
- Accepts `file: UploadFile` parameter
|
||||
- Reads the file content: `content = await file.read()`
|
||||
- Calls `asset_service.save_asset(assets_dir, file.filename or "upload", content, settings.allowed_upload_types, settings.max_upload_bytes)`
|
||||
- Catches `ValueError` — if message contains "File too large" return `413`, if "Unsupported file type" return `415`
|
||||
- Returns `{"filename": saved_filename}` with status `201`
|
||||
|
||||
### `DELETE /{filename}` → `Response` (status 204)
|
||||
- Admin only
|
||||
- Calls `asset_service.delete_asset(assets_dir, filename)`
|
||||
- Returns `204` if deleted, raises `HTTPException(404)` if not found
|
||||
|
||||
## Step 4: Modify `backend/app/main.py`
|
||||
|
||||
Update `main.py` to:
|
||||
|
||||
- Import and register the `assets_router`
|
||||
- Mount `StaticFiles` at `/api/assets` to serve uploaded files. This mount must be registered **after** the assets API router (so POST/DELETE are handled by the router, and GET falls through to StaticFiles)
|
||||
- The lifespan should also ensure the `assets` directory exists on startup (`(Path(data_dir) / "assets").mkdir(parents=True, exist_ok=True)`)
|
||||
|
||||
**Important:** The `StaticFiles` mount for assets serves files from `{data_dir}/assets/`. The API router handles POST and DELETE at `/api/assets`. To avoid conflicts, mount `StaticFiles` at a path like `/api/assets/file` or use a different strategy. One clean approach: register the assets API router with routes for upload (`POST /api/assets`) and delete (`DELETE /api/assets/{filename}`), then mount `StaticFiles(directory=assets_path)` at `/api/assets/file` for serving. Alternatively, keep the router at `/api/assets` and mount static files at `/api/assets` — FastAPI checks routers first, so `POST` and `DELETE` will be handled by the router, while `GET` requests for specific files will fall through to the static mount. Test which approach works and go with it.
|
||||
|
||||
## Step 5: Create `Dockerfile`
|
||||
|
||||
Multi-stage Dockerfile at the project root:
|
||||
|
||||
### Stage 1: Frontend build
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
```
|
||||
|
||||
### Stage 2: Python backend + built frontend
|
||||
```dockerfile
|
||||
FROM python:3.12-slim AS production
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
COPY backend/ backend/
|
||||
COPY --from=frontend-build /build/dist static/
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "backend"]
|
||||
```
|
||||
|
||||
## Step 6: Create `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- HANDIN_ADMIN_TOKEN=${HANDIN_ADMIN_TOKEN:?Set HANDIN_ADMIN_TOKEN}
|
||||
- HANDIN_DATA_DIR=/data
|
||||
volumes:
|
||||
- handin-data:/data
|
||||
|
||||
volumes:
|
||||
handin-data:
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation, run these commands and ensure they all succeed:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests/test_assets.py -v
|
||||
uv run mypy --strict backend/app/services/asset_service.py backend/app/routes/assets.py backend/app/main.py
|
||||
uv run ruff check backend/app/services/asset_service.py backend/app/routes/assets.py backend/app/main.py
|
||||
```
|
||||
|
||||
Then run the **full** verification suite to ensure nothing is broken:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests/ -v
|
||||
uv run mypy --strict backend/app/ backend/tests/
|
||||
uv run ruff check backend/
|
||||
```
|
||||
|
||||
All tests must pass (~56 total: 6 database + 15 service + 24 route + 2 concurrency + 9 asset). Zero mypy errors. Zero ruff errors.
|
||||
|
||||
Optionally verify Docker builds (only if Docker is available):
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| CREATE | `backend/tests/test_assets.py` |
|
||||
| CREATE | `backend/app/services/asset_service.py` |
|
||||
| CREATE | `backend/app/routes/assets.py` |
|
||||
| CREATE | `Dockerfile` |
|
||||
| CREATE | `docker-compose.yml` |
|
||||
| MODIFY | `backend/app/main.py` |
|
||||
323
prompts/slice-5-frontend-shell.md
Normal file
323
prompts/slice-5-frontend-shell.md
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
# Slice 5: Frontend Shell + Segment Renderers
|
||||
|
||||
## Role
|
||||
|
||||
You are a frontend TypeScript/Vue developer building the read-only public UI for a FastAPI-based academic submission website. The backend is complete — you are building the Vue 3 / Tailwind CSS v4 frontend that consumes its API.
|
||||
|
||||
## Objective
|
||||
|
||||
Create the read-only frontend shell: sidebar navigation, responsive layout, data fetching composable, TypeScript types, and all segment renderers. No admin functionality in this slice. All files must pass `vue-tsc --noEmit` (strict TypeScript).
|
||||
|
||||
## Architecture Reference
|
||||
|
||||
Read [PLAN.md](../PLAN.md) before starting. It contains the full architecture context: color system, layout specs, component hierarchy, and design decisions. Use it as the source of truth for any details not covered in this prompt.
|
||||
|
||||
## UI Decision-Making Policy
|
||||
|
||||
**When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use `AskUserQuestion` BEFORE implementing.** This is the prioritized method of resolution. Do not guess or pick defaults for subjective UI choices. Examples of when to ask:
|
||||
- Exact spacing/padding values not specified in the prompt
|
||||
- Animation/transition details (duration, easing, direction)
|
||||
- Empty state presentation (what to show when no segments exist)
|
||||
- Icon choices (hamburger style, close button style)
|
||||
- Typography sizing not explicitly stated
|
||||
- Any "or" choices in the prompt (e.g., "`<iframe>` or `<embed>`")
|
||||
- Hover/focus state details beyond what's specified
|
||||
|
||||
Only proceed without asking when the prompt or PLAN.md gives an unambiguous, specific instruction.
|
||||
|
||||
## Coding Standards (mandatory)
|
||||
|
||||
- Vue 3 `<script setup lang="ts">` single-file components
|
||||
- Strict TypeScript — no `any`, all props/emits typed
|
||||
- Tailwind CSS v4 utility classes (no separate CSS files per component)
|
||||
- Composables use `ref`/`computed`/`onMounted` from Vue — no Options API
|
||||
- Components are small and focused — one responsibility per file
|
||||
- Use `@/` path alias for imports (configured in vite.config.ts as `src/`)
|
||||
- No external state management library — use composables with reactive state
|
||||
|
||||
## Step 0: Verify Existing Setup
|
||||
|
||||
Before writing any code, verify the frontend builds:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Must exit cleanly. If it fails, fix before proceeding.
|
||||
|
||||
## Context: Backend API
|
||||
|
||||
The backend is running at `http://localhost:8000` (proxied via Vite dev server at `/api`).
|
||||
|
||||
### API Endpoints Used by This Slice
|
||||
|
||||
```
|
||||
GET /api/site → { "title": string }
|
||||
GET /api/segments → Array of SegmentResponse
|
||||
GET /api/assets/{file} → Static file (binary)
|
||||
```
|
||||
|
||||
### SegmentResponse Shape (from backend `models.py`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"type": "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery",
|
||||
"sort_order": 0,
|
||||
"title": "Section Title",
|
||||
"content": "markdown text or URL or empty",
|
||||
"metadata": {},
|
||||
"created_at": "2025-01-01T00:00:00",
|
||||
"updated_at": "2025-01-01T00:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Content conventions by type:**
|
||||
- `markdown` — `content` is raw markdown text
|
||||
- `pdf` — `content` is the asset URL (e.g., `/api/assets/uuid.pdf`)
|
||||
- `video` — `content` is the asset URL (e.g., `/api/assets/uuid.mp4`)
|
||||
- `audio` — `content` is the asset URL (e.g., `/api/assets/uuid.mp3`)
|
||||
- `iframe` — `content` is the embed URL (any external URL)
|
||||
- `gallery` — `metadata` contains `{ "images": ["/api/assets/uuid.png", ...] }`
|
||||
|
||||
### Existing Frontend Files
|
||||
|
||||
**`frontend/src/style.css`** (already has theme):
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #fffbeb;
|
||||
--color-primary-100: #fff3c4;
|
||||
--color-primary-200: #fce588;
|
||||
--color-primary-300: #ffcd00;
|
||||
--color-primary-400: #e6b800;
|
||||
--color-primary-500: #cc9900;
|
||||
--color-primary-600: #997300;
|
||||
--color-primary-700: #664d00;
|
||||
}
|
||||
```
|
||||
|
||||
**`frontend/vite.config.ts`** — has `@` alias to `src/`, API proxy to `localhost:8000`.
|
||||
|
||||
**`frontend/src/main.ts`** — mounts `App.vue` at `#app`.
|
||||
|
||||
**`frontend/src/App.vue`** — placeholder, will be replaced.
|
||||
|
||||
## UI Design Reference
|
||||
|
||||
### Color System
|
||||
- Primary: golden yellow `#ffcd00` (primary-300) for accents and active states
|
||||
- Neutrals: Tailwind `slate` — dark sidebar (`slate-900`), light content area (`slate-50`)
|
||||
- Active nav item: `primary-300` left border + subtle bg highlight
|
||||
|
||||
### Layout — Sidebar Navigation
|
||||
|
||||
**Desktop (≥1024px):**
|
||||
- Fixed left sidebar, `w-64`, `bg-slate-900`, full height
|
||||
- Sidebar header: site title styled in `primary-300`, font bold
|
||||
- Nav items: segment titles as anchor links, `text-slate-300` default, `text-white` on hover
|
||||
- Active segment: `border-l-2 border-primary-300 bg-slate-800` + `text-white`
|
||||
- Main content: `ml-64`, scrollable, segments as full-width cards on `bg-slate-50`
|
||||
|
||||
**Tablet (768–1023px):**
|
||||
- Sidebar off-screen by default, toggle button to slide it in as overlay
|
||||
|
||||
**Mobile (<768px):**
|
||||
- No sidebar visible. `MobileHeader` with hamburger icon at top
|
||||
- Hamburger opens slide-out overlay nav (full-height, `bg-slate-900`)
|
||||
- Close button or tap-outside to dismiss
|
||||
- Segments stack vertically, full-width
|
||||
|
||||
## Step 1: Create TypeScript Types
|
||||
|
||||
### `frontend/src/types/segment.ts`
|
||||
|
||||
```typescript
|
||||
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
type: SegmentType;
|
||||
sort_order: number;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
title: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Create Data Fetching Composable
|
||||
|
||||
### `frontend/src/composables/useSegments.ts`
|
||||
|
||||
Exports a composable function `useSegments()` that returns:
|
||||
- `segments: Ref<Segment[]>` — list of segments, ordered by `sort_order`
|
||||
- `siteTitle: Ref<string>` — site title (default: `"Handin"`)
|
||||
- `loading: Ref<boolean>` — true while initial fetch is in progress
|
||||
- `error: Ref<string | null>` — error message if fetch fails
|
||||
- `refresh: () => Promise<void>` — re-fetch both endpoints
|
||||
|
||||
On mount (`onMounted`), fetch both `GET /api/site` and `GET /api/segments` in parallel using `Promise.all`. Use native `fetch()` — no axios.
|
||||
|
||||
## Step 3: Create Layout Components
|
||||
|
||||
### `frontend/src/components/layout/AppShell.vue`
|
||||
|
||||
Top-level layout component. Structure:
|
||||
- Contains `SidebarNav` (desktop) and `MobileHeader` (mobile)
|
||||
- Main content area as a `<slot />`
|
||||
- Uses CSS breakpoints: sidebar visible `lg:block`, hidden below `lg`
|
||||
- Mobile header visible below `lg`, hidden at `lg+`
|
||||
|
||||
### `frontend/src/components/layout/SidebarNav.vue`
|
||||
|
||||
Props:
|
||||
- `segments: Segment[]`
|
||||
- `siteTitle: string`
|
||||
- `activeId: string | null`
|
||||
|
||||
Emits:
|
||||
- `navigate(id: string)` — when a nav item is clicked
|
||||
|
||||
Renders:
|
||||
- Site title in header area (`text-primary-300 font-bold text-lg`)
|
||||
- List of segment titles as clickable items
|
||||
- Active item has left border accent and bg highlight
|
||||
- Scroll overflow if many segments
|
||||
|
||||
### `frontend/src/components/layout/MobileHeader.vue`
|
||||
|
||||
Props:
|
||||
- `siteTitle: string`
|
||||
- `segments: Segment[]`
|
||||
- `activeId: string | null`
|
||||
|
||||
Manages its own open/closed state for the slide-out menu. Contains:
|
||||
- Top bar with site title and hamburger button
|
||||
- Slide-out overlay with same nav items as `SidebarNav`
|
||||
- Click outside or close button dismisses the overlay
|
||||
|
||||
## Step 4: Create Segment Renderers
|
||||
|
||||
### `frontend/src/components/segments/SegmentRenderer.vue`
|
||||
|
||||
Props: `segment: Segment`
|
||||
|
||||
A switch component that renders the correct sub-renderer based on `segment.type`. Use a `v-if`/`v-else-if` chain or a dynamic component approach.
|
||||
|
||||
### `frontend/src/components/segments/SegmentList.vue`
|
||||
|
||||
Props: `segments: Segment[]`
|
||||
|
||||
Renders a vertical list of segments, each wrapped in a card-like container. Each segment card:
|
||||
- Has an `id` attribute matching the segment ID (for anchor scroll)
|
||||
- White background, rounded, subtle shadow
|
||||
- Title as heading, then the renderer below
|
||||
|
||||
### Individual Renderers
|
||||
|
||||
Each takes a `segment: Segment` prop:
|
||||
|
||||
**`MarkdownSegment.vue`**
|
||||
- Render `segment.content` as HTML using `marked` library
|
||||
- Wrap output in a `prose` class div (Tailwind typography plugin)
|
||||
- Use `v-html` with the parsed markdown
|
||||
|
||||
**`PdfSegment.vue`**
|
||||
- Render an `<iframe>` or `<embed>` pointing to `segment.content` (the PDF URL)
|
||||
- Full-width, reasonable height (e.g., `h-[600px]` or `aspect-[4/3]`)
|
||||
|
||||
**`VideoSegment.vue`**
|
||||
- Render a `<video>` element with `controls`, `src` pointing to `segment.content`
|
||||
- Full-width, responsive
|
||||
|
||||
**`AudioSegment.vue`**
|
||||
- Render an `<audio>` element with `controls`, `src` pointing to `segment.content`
|
||||
- Full-width
|
||||
|
||||
**`IframeSegment.vue`**
|
||||
- Render an `<iframe>` with `src` from `segment.content`
|
||||
- Full-width, reasonable height, border-none
|
||||
- Add `sandbox` and `allow` attributes for security
|
||||
|
||||
**`GallerySegment.vue`**
|
||||
- Read image URLs from `segment.metadata.images` (cast to `string[]`)
|
||||
- Render as a responsive grid of `<img>` elements
|
||||
- Grid: 2 columns on mobile, 3 on tablet, 4 on desktop
|
||||
- Images have `object-cover`, rounded corners
|
||||
|
||||
## Step 5: Create HomePage View
|
||||
|
||||
### `frontend/src/views/HomePage.vue`
|
||||
|
||||
Wires everything together:
|
||||
- Uses `useSegments()` composable
|
||||
- Tracks `activeId` (string or null) — updates on scroll or nav click
|
||||
- Shows loading spinner/skeleton while `loading` is true
|
||||
- Shows error message if `error` is set
|
||||
- Renders `AppShell` with `SegmentList` in the main slot
|
||||
|
||||
**Scroll tracking:** Use `IntersectionObserver` to detect which segment is currently visible and update `activeId` for sidebar highlighting.
|
||||
|
||||
## Step 6: Update App.vue
|
||||
|
||||
Replace the placeholder `App.vue` with:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import HomePage from "@/views/HomePage.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomePage />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation, run:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Must succeed with zero errors. Then verify visually:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
Check:
|
||||
1. Sidebar renders with site title and segment nav items (populate via backend or verify empty state)
|
||||
2. Desktop: sidebar fixed left, content scrolls independently
|
||||
3. Mobile (narrow viewport): hamburger menu, slide-out nav works
|
||||
4. Each segment type renders correctly when data is present
|
||||
5. Active segment highlights in sidebar on scroll
|
||||
6. No TypeScript errors (`npm run type-check`)
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| CREATE | `frontend/src/types/segment.ts` |
|
||||
| CREATE | `frontend/src/composables/useSegments.ts` |
|
||||
| CREATE | `frontend/src/components/layout/AppShell.vue` |
|
||||
| CREATE | `frontend/src/components/layout/SidebarNav.vue` |
|
||||
| CREATE | `frontend/src/components/layout/MobileHeader.vue` |
|
||||
| CREATE | `frontend/src/components/segments/SegmentRenderer.vue` |
|
||||
| CREATE | `frontend/src/components/segments/SegmentList.vue` |
|
||||
| CREATE | `frontend/src/components/segments/MarkdownSegment.vue` |
|
||||
| CREATE | `frontend/src/components/segments/PdfSegment.vue` |
|
||||
| CREATE | `frontend/src/components/segments/VideoSegment.vue` |
|
||||
| CREATE | `frontend/src/components/segments/AudioSegment.vue` |
|
||||
| CREATE | `frontend/src/components/segments/IframeSegment.vue` |
|
||||
| CREATE | `frontend/src/components/segments/GallerySegment.vue` |
|
||||
| CREATE | `frontend/src/views/HomePage.vue` |
|
||||
| MODIFY | `frontend/src/App.vue` |
|
||||
366
prompts/slice-6-admin-ui.md
Normal file
366
prompts/slice-6-admin-ui.md
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
# Slice 6: Admin UI
|
||||
|
||||
## Role
|
||||
|
||||
You are a frontend TypeScript/Vue developer adding admin functionality to an existing read-only academic submission website. The backend API and read-only frontend are complete — you are adding token-based authentication, inline editing, asset upload, and drag-and-drop reorder.
|
||||
|
||||
## Objective
|
||||
|
||||
Create the full admin experience: token entry, inline segment editing (create/update/delete), asset uploading, and drag-and-drop segment reorder. Admin controls must be invisible when no token is active. All files must pass `vue-tsc --noEmit` (strict TypeScript).
|
||||
|
||||
## Architecture Reference
|
||||
|
||||
Read [PLAN.md](../PLAN.md) for full architecture context. **However, note these deviations from PLAN.md that were made during Slice 5 implementation:**
|
||||
|
||||
### Layout Change (IMPORTANT)
|
||||
|
||||
The layout was changed from a **sidebar** to a **horizontal top bar with tabs**:
|
||||
|
||||
- **Title bar:** `bg-primary-300` (golden yellow) with dark text, full width at top.
|
||||
- **Tab navigation:** Horizontal tab bar below the title, with `border-b-2 border-primary-400` on the active tab. Visible on `md+` screens.
|
||||
- **Mobile:** Hamburger dropdown (not slide-out sidebar). Opens a vertical list from the top with backdrop.
|
||||
- **Content area:** Centered `max-w-4xl` on white background.
|
||||
|
||||
The component **file names** from PLAN.md are preserved but their implementations differ:
|
||||
- `SidebarNav.vue` is actually a **horizontal tab bar** (desktop).
|
||||
- `MobileHeader.vue` is a **hamburger dropdown** (mobile).
|
||||
- `AppShell.vue` composes the title bar + tab nav + mobile header + main content slot.
|
||||
|
||||
### Backend Route Trailing Slashes
|
||||
|
||||
Backend collection routes use **trailing slashes**. Always include them in fetch URLs:
|
||||
- `GET /api/site/` — not `/api/site`
|
||||
- `GET /api/segments/` — not `/api/segments`
|
||||
- `POST /api/segments/` — not `/api/segments`
|
||||
- `POST /api/assets/` — not `/api/assets`
|
||||
- `PUT /api/segments/reorder` — no trailing slash (path route, not collection)
|
||||
- `GET /api/auth/verify` — no trailing slash
|
||||
- `PATCH /api/segments/{id}` — no trailing slash (uses PATCH, not PUT)
|
||||
- `DELETE /api/segments/{id}` — no trailing slash
|
||||
- `DELETE /api/assets/{filename}` — no trailing slash
|
||||
|
||||
Omitting trailing slashes on collection routes causes **307 redirects** which break in the Docker proxy setup (CORS errors).
|
||||
|
||||
## UI Decision-Making Policy
|
||||
|
||||
**When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use `AskUserQuestion` BEFORE implementing.** Do not guess or pick defaults for subjective UI choices. Examples:
|
||||
- How the "Add Segment" form should look (modal vs inline vs dropdown)
|
||||
- Editor layout for different segment types
|
||||
- Confirmation dialogs for delete actions
|
||||
- Token input styling and placement
|
||||
- Admin toolbar positioning relative to the top bar
|
||||
- Drag handle icon/style
|
||||
|
||||
Only proceed without asking when this prompt gives an unambiguous, specific instruction.
|
||||
|
||||
## Coding Standards (mandatory)
|
||||
|
||||
- Vue 3 `<script setup lang="ts">` single-file components
|
||||
- Strict TypeScript — no `any`, all props/emits typed
|
||||
- Tailwind CSS v4 utility classes (no separate CSS files per component)
|
||||
- Composables use `ref`/`computed`/`onMounted` from Vue — no Options API
|
||||
- Components are small and focused — one responsibility per file
|
||||
- Use `@/` path alias for imports (configured in vite.config.ts as `src/`)
|
||||
- No external state management library — use composables with reactive state
|
||||
- Use native `fetch()` — no axios
|
||||
|
||||
## Step 0: Verify Existing Setup
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Must exit cleanly. If it fails, fix before proceeding.
|
||||
|
||||
## Context: Backend API
|
||||
|
||||
### Auth Mechanism
|
||||
|
||||
- Single shared token set via `HANDIN_ADMIN_TOKEN` env var (in `.env` file, value: `changeme`).
|
||||
- Token accepted via `?token=<token>` query param OR `Authorization: Bearer <token>` header.
|
||||
- `GET /api/auth/verify` — returns `{ "valid": true }` if token is valid, 401 otherwise.
|
||||
- All write endpoints require the token (POST, PATCH, PUT, DELETE on segments/assets/site).
|
||||
|
||||
### API Endpoints Used by This Slice
|
||||
|
||||
```
|
||||
AUTH:
|
||||
GET /api/auth/verify → { "valid": true } or 401
|
||||
|
||||
SEGMENTS (admin):
|
||||
POST /api/segments/ → SegmentResponse (201)
|
||||
Body: { "type": SegmentType, "title": string, "content"?: string, "metadata"?: object }
|
||||
|
||||
PATCH /api/segments/{id} → SegmentResponse
|
||||
Body: { "title"?: string, "content"?: string, "metadata"?: object }
|
||||
NOTE: PATCH not PUT — only send changed fields
|
||||
|
||||
DELETE /api/segments/{id} → 204 No Content
|
||||
|
||||
PUT /api/segments/reorder → SegmentResponse[]
|
||||
Body: { "segment_ids": string[] }
|
||||
|
||||
ASSETS (admin):
|
||||
POST /api/assets/ → { "filename": "uuid.ext" } (201)
|
||||
Body: multipart/form-data with "file" field
|
||||
|
||||
DELETE /api/assets/{filename} → 204 No Content
|
||||
|
||||
SITE (admin):
|
||||
PUT /api/site/ → { "title": string }
|
||||
Body: { "title": string }
|
||||
```
|
||||
|
||||
### Existing Frontend Types
|
||||
|
||||
```typescript
|
||||
// frontend/src/types/segment.ts
|
||||
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
type: SegmentType;
|
||||
sort_order: number;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
title: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Existing Composable: useSegments
|
||||
|
||||
```typescript
|
||||
// frontend/src/composables/useSegments.ts
|
||||
// Returns: { segments, siteTitle, loading, error, refresh }
|
||||
// refresh() re-fetches both /api/site/ and /api/segments/
|
||||
// Call refresh() after any admin mutation to sync the UI
|
||||
```
|
||||
|
||||
### Existing Layout Structure
|
||||
|
||||
```
|
||||
AppShell.vue
|
||||
├── <header> — yellow title bar (bg-primary-300), shows siteTitle
|
||||
├── SidebarNav.vue — horizontal tab bar (hidden below md)
|
||||
├── MobileHeader.vue — hamburger dropdown (hidden at md+)
|
||||
└── <main> — slot for page content (max-w-4xl centered)
|
||||
```
|
||||
|
||||
### Existing Segment List
|
||||
|
||||
```vue
|
||||
<!-- SegmentList.vue renders each segment as: -->
|
||||
<section :id="segment.id" class="rounded-lg bg-white p-6 shadow-sm">
|
||||
<h2>{{ segment.title }}</h2>
|
||||
<SegmentRenderer :segment="segment" />
|
||||
</section>
|
||||
```
|
||||
|
||||
## Step 1: Create `useAdmin` Composable
|
||||
|
||||
### `frontend/src/composables/useAdmin.ts`
|
||||
|
||||
Manages admin authentication state. Exports a composable `useAdmin()` that returns:
|
||||
|
||||
- `token: Ref<string | null>` — current token (persisted in `sessionStorage`)
|
||||
- `isAdmin: Ref<boolean>` — true when token is verified
|
||||
- `verifying: Ref<boolean>` — true during verification request
|
||||
- `login(token: string): Promise<boolean>` — verify token via `GET /api/auth/verify`, persist if valid
|
||||
- `logout(): void` — clear token and admin state
|
||||
- `authHeaders: ComputedRef<Record<string, string>>` — returns `{ "Authorization": "Bearer <token>" }` when authenticated, empty object otherwise
|
||||
- `authQuery: ComputedRef<string>` — returns `?token=<token>` or empty string (useful for asset URLs)
|
||||
|
||||
On creation, check `sessionStorage` for an existing token and re-verify it silently.
|
||||
|
||||
**Important:** This composable must use module-level reactive state (defined outside the function) so that all components share the same auth state. The function just returns references to the shared state.
|
||||
|
||||
## Step 2: Create `useAssetUpload` Composable
|
||||
|
||||
### `frontend/src/composables/useAssetUpload.ts`
|
||||
|
||||
Handles file uploads. Exports `useAssetUpload()` that returns:
|
||||
|
||||
- `uploading: Ref<boolean>`
|
||||
- `error: Ref<string | null>`
|
||||
- `upload(file: File, authHeaders: Record<string, string>): Promise<string | null>` — uploads file to `POST /api/assets/`, returns the filename on success or null on failure
|
||||
|
||||
Use `FormData` with native `fetch()`. Set `error` on failure with the server's error message.
|
||||
|
||||
## Step 3: Create Admin Components
|
||||
|
||||
### `frontend/src/components/admin/TokenPrompt.vue`
|
||||
|
||||
The entry point for admin access. When no token is active:
|
||||
- Show a small, subtle lock icon in the top bar (inside AppShell's header area)
|
||||
- Clicking it reveals an inline token input field with a submit button
|
||||
- On successful login, the input disappears and admin controls appear
|
||||
|
||||
Props: none (uses `useAdmin` composable directly).
|
||||
|
||||
Emits:
|
||||
- `authenticated` — fired when login succeeds
|
||||
|
||||
### `frontend/src/components/admin/AdminToolbar.vue`
|
||||
|
||||
A thin bar shown below the tab nav when admin is authenticated. Contains:
|
||||
- An "Editing" badge (subtle indicator)
|
||||
- An "Add Segment" button that triggers segment creation
|
||||
- A "Logout" button
|
||||
|
||||
Props: none (uses `useAdmin` composable directly).
|
||||
|
||||
Emits:
|
||||
- `add-segment` — when "Add Segment" is clicked
|
||||
- `logout` — when "Logout" is clicked
|
||||
|
||||
### `frontend/src/components/admin/SegmentEditor.vue`
|
||||
|
||||
Inline editor that appears below a segment card when editing. Handles:
|
||||
- Editing title (text input)
|
||||
- Editing content based on segment type:
|
||||
- `markdown`: textarea for raw markdown
|
||||
- `pdf`, `video`, `audio`: file upload (via AssetUploader) or URL input showing current asset
|
||||
- `iframe`: URL text input
|
||||
- `gallery`: file upload for multiple images, display current images with remove buttons
|
||||
- Save and Cancel buttons
|
||||
- Delete button (with confirmation)
|
||||
|
||||
Props:
|
||||
- `segment: Segment`
|
||||
|
||||
Emits:
|
||||
- `save(updates: { title?: string; content?: string; metadata?: Record<string, unknown> })` — partial update
|
||||
- `cancel` — close editor
|
||||
- `delete` — delete this segment
|
||||
|
||||
### `frontend/src/components/admin/AssetUploader.vue`
|
||||
|
||||
Drag-and-drop file upload zone. Features:
|
||||
- Dashed border drop zone
|
||||
- Click to select file
|
||||
- Shows upload progress/status
|
||||
- Returns the uploaded filename
|
||||
|
||||
Props:
|
||||
- `accept?: string` — file type filter (e.g., `"application/pdf"`, `"image/*"`)
|
||||
- `label?: string` — descriptive text inside the zone
|
||||
|
||||
Emits:
|
||||
- `uploaded(filename: string)` — when upload completes successfully
|
||||
|
||||
## Step 4: Integrate Admin into Existing Components
|
||||
|
||||
### Modify `AppShell.vue`
|
||||
|
||||
- Add `TokenPrompt` to the title bar header (when not authenticated)
|
||||
- Add `AdminToolbar` below the tab nav (when authenticated)
|
||||
- Pass admin state down or let child components use `useAdmin` directly
|
||||
|
||||
### Modify `SegmentList.vue`
|
||||
|
||||
When admin is active:
|
||||
- Show a small edit icon (pencil) on each segment card header (next to the title)
|
||||
- Clicking the edit icon toggles `SegmentEditor` inline below that segment
|
||||
- Show drag handles on each card for reorder (use `vuedraggable`)
|
||||
- After drag-and-drop reorder, call `PUT /api/segments/reorder` with the new ID order, then `refresh()`
|
||||
|
||||
The `vuedraggable` package is already installed (`"vuedraggable": "4.1.0"` in package.json). Import it as:
|
||||
```typescript
|
||||
import draggable from "vuedraggable";
|
||||
```
|
||||
|
||||
Note: `vuedraggable` 4.x may not have TypeScript declarations. If `vue-tsc` complains, create a type declaration file `frontend/src/types/vuedraggable.d.ts`:
|
||||
```typescript
|
||||
declare module "vuedraggable" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
```
|
||||
|
||||
### Modify `HomePage.vue`
|
||||
|
||||
- Wire up the "Add Segment" flow: when `AdminToolbar` emits `add-segment`, show a creation form (could be a simple modal or inline form at the top/bottom of the segment list)
|
||||
- After creating a segment, call `refresh()` to reload the list
|
||||
- After editing/deleting a segment, call `refresh()`
|
||||
- Handle logout: call `useAdmin().logout()`
|
||||
|
||||
## Step 5: Segment Creation Flow
|
||||
|
||||
When "Add Segment" is clicked, the user needs to:
|
||||
1. Choose a segment type (dropdown or button group)
|
||||
2. Enter a title
|
||||
3. Provide initial content (type-dependent: text for markdown, URL for iframe, file upload for pdf/video/audio, etc.)
|
||||
4. Submit → `POST /api/segments/` with auth header → `refresh()`
|
||||
|
||||
## Step 6: Wire Up All Mutations
|
||||
|
||||
All admin mutations must:
|
||||
1. Include auth headers from `useAdmin().authHeaders`
|
||||
2. Call `useSegments().refresh()` after success to sync the UI
|
||||
3. Handle errors gracefully (show error message, don't lose user input)
|
||||
|
||||
### Mutation summary:
|
||||
|
||||
| Action | Method | Endpoint | Auth | After |
|
||||
|--------|--------|----------|------|-------|
|
||||
| Create segment | POST | `/api/segments/` | Bearer header | refresh() |
|
||||
| Update segment | PATCH | `/api/segments/{id}` | Bearer header | refresh() |
|
||||
| Delete segment | DELETE | `/api/segments/{id}` | Bearer header | refresh() |
|
||||
| Reorder segments | PUT | `/api/segments/reorder` | Bearer header | refresh() |
|
||||
| Upload asset | POST | `/api/assets/` | Bearer header | use filename |
|
||||
| Delete asset | DELETE | `/api/assets/{filename}` | Bearer header | refresh() |
|
||||
| Update site title | PUT | `/api/site/` | Bearer header | refresh() |
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation, run:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Must succeed with zero errors. Then verify functionally:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
Or with Docker:
|
||||
```bash
|
||||
docker compose up --build
|
||||
# Visit http://localhost:5174
|
||||
```
|
||||
|
||||
Check:
|
||||
1. **No admin UI visible** without token — only the lock icon in the header
|
||||
2. Click lock icon → enter token `changeme` → admin controls appear
|
||||
3. Admin toolbar shows with "Add Segment" and "Logout" buttons
|
||||
4. Create a markdown segment → appears in the list
|
||||
5. Edit a segment's title and content → saves correctly
|
||||
6. Delete a segment → removed from list (after confirmation)
|
||||
7. Drag-and-drop reorder segments → order persists after refresh
|
||||
8. Upload an asset (PDF, image) → segment displays it
|
||||
9. Logout → all admin UI disappears, read-only view restored
|
||||
10. No TypeScript errors (`npm run type-check`)
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| CREATE | `frontend/src/composables/useAdmin.ts` |
|
||||
| CREATE | `frontend/src/composables/useAssetUpload.ts` |
|
||||
| CREATE | `frontend/src/components/admin/TokenPrompt.vue` |
|
||||
| CREATE | `frontend/src/components/admin/AdminToolbar.vue` |
|
||||
| CREATE | `frontend/src/components/admin/SegmentEditor.vue` |
|
||||
| CREATE | `frontend/src/components/admin/AssetUploader.vue` |
|
||||
| CREATE | `frontend/src/types/vuedraggable.d.ts` (if needed for type-check) |
|
||||
| MODIFY | `frontend/src/components/layout/AppShell.vue` |
|
||||
| MODIFY | `frontend/src/components/segments/SegmentList.vue` |
|
||||
| MODIFY | `frontend/src/views/HomePage.vue` |
|
||||
214
prompts/slice-7-polish.md
Normal file
214
prompts/slice-7-polish.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# Slice 7: Polish + Final Validation
|
||||
|
||||
## Role
|
||||
|
||||
You are a senior developer performing a final quality sweep on an academic group submission website. The backend (FastAPI + SQLite) and frontend (Vue 3 + TypeScript + Tailwind CSS v4) are fully implemented through Slices 0–6. Your job is to run every verification check, fix any issues found, and ensure the app works end-to-end.
|
||||
|
||||
## Objective
|
||||
|
||||
Run the full verification suite, fix any failures, and validate the complete user flow. The app must pass all automated checks with zero errors and work correctly in Docker.
|
||||
|
||||
## Architecture Reference
|
||||
|
||||
Read [PLAN.md](../PLAN.md) for full architecture context. Key deviations from the original plan:
|
||||
|
||||
### Layout
|
||||
The layout uses a **horizontal top bar with tabs** (not a sidebar):
|
||||
- `AppShell.vue` — sticky header (title bar `bg-primary-300` + nav bar) + dark mode toggle + TokenPrompt + AdminToolbar + back-to-top button + main content slot
|
||||
- `SidebarNav.vue` — horizontal tab bar (desktop, `md+`)
|
||||
- `MobileHeader.vue` — hamburger dropdown (mobile, `<md`)
|
||||
- The entire header (title bar + nav) is `sticky top-0 z-20` — always visible when scrolling
|
||||
- A floating back-to-top button appears after scrolling 300px (bottom-right, `bg-primary-300`)
|
||||
|
||||
### Backend Routes
|
||||
- Collection routes use **trailing slashes** (`/api/segments/`, `/api/site/`, `/api/assets/`)
|
||||
- Single-resource routes have **no trailing slash** (`/api/segments/{id}`, `/api/auth/verify`)
|
||||
- Segment updates use **PATCH** (not PUT) — partial updates only
|
||||
|
||||
### Segment Types
|
||||
Seven types: `markdown`, `pdf`, `video`, `audio`, `iframe`, `gallery`, `link`.
|
||||
- `link` is nav-only — appears in tab bar/mobile menu as an external `<a target="_blank">`, not in the content area.
|
||||
|
||||
### Site Title
|
||||
- Configurable via `HANDIN_SITE_TITLE` env var (default: `"Untitled Site"`)
|
||||
- Stored in `config.py` as `Settings.site_title`, passed to `site_service.get_site_title()` as `default` kwarg
|
||||
- Can also be overridden at runtime via `PUT /api/site/` (stored in DB, takes precedence over env var)
|
||||
|
||||
### Admin UI
|
||||
- Lock icon in title bar → inline token input → admin controls appear
|
||||
- Header icons (lock, dark mode toggle) use `text-slate-700` in both light and dark mode (yellow header stays consistent)
|
||||
- AdminToolbar below tabs: "Editing" badge + "Add Segment" (modal) + "Logout"
|
||||
- Inline SegmentEditor below each card with type-aware editing
|
||||
- Grip-dot drag handles for reorder via `vuedraggable`
|
||||
- Custom styled delete confirmation modal (not browser `confirm()`)
|
||||
|
||||
## Step 1: Backend Verification
|
||||
|
||||
Run these checks and fix any failures:
|
||||
|
||||
```bash
|
||||
# Tests
|
||||
uv run pytest backend/tests/ -v
|
||||
|
||||
# Linting
|
||||
uv run ruff check backend/
|
||||
|
||||
# Type checking
|
||||
uv run mypy --strict backend/app/
|
||||
```
|
||||
|
||||
All must pass with zero errors. If any tests fail:
|
||||
1. Read the failing test to understand what it expects
|
||||
2. Read the relevant source code
|
||||
3. Fix the source (not the test) unless the test itself is wrong
|
||||
4. Re-run until clean
|
||||
|
||||
### Known Backend Files
|
||||
```
|
||||
backend/app/
|
||||
├── main.py
|
||||
├── config.py # Settings: admin_token, site_title, data_dir, etc.
|
||||
├── auth.py
|
||||
├── models.py # SegmentType enum includes LINK = "link"
|
||||
├── database.py
|
||||
├── routes/
|
||||
│ ├── auth.py
|
||||
│ ├── segments.py
|
||||
│ ├── site.py # Passes settings.site_title as default to service
|
||||
│ └── assets.py
|
||||
└── services/
|
||||
├── segment_service.py
|
||||
├── site_service.py # get_site_title(db_path, *, default=DEFAULT_SITE_TITLE)
|
||||
└── asset_service.py
|
||||
```
|
||||
|
||||
## Step 2: Frontend Verification
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
This runs `vue-tsc --noEmit && vite build`. Must succeed with zero errors.
|
||||
|
||||
### Known Frontend Files
|
||||
```
|
||||
frontend/src/
|
||||
├── composables/
|
||||
│ ├── useSegments.ts
|
||||
│ ├── useAdmin.ts # Module-level reactive state, sessionStorage
|
||||
│ ├── useAssetUpload.ts
|
||||
│ └── useDarkMode.ts # Class-based dark mode, localStorage persistence, system preference fallback
|
||||
├── types/
|
||||
│ ├── segment.ts # SegmentType includes "link"
|
||||
│ └── vuedraggable.d.ts
|
||||
├── components/
|
||||
│ ├── layout/
|
||||
│ │ ├── AppShell.vue # Sticky header, back-to-top button, scroll listener
|
||||
│ │ ├── SidebarNav.vue # Actually horizontal tab bar
|
||||
│ │ └── MobileHeader.vue # Hamburger dropdown
|
||||
│ ├── segments/
|
||||
│ │ ├── SegmentList.vue # Draggable (admin) or plain (read-only), filters out link segments from content
|
||||
│ │ ├── SegmentRenderer.vue
|
||||
│ │ ├── MarkdownSegment.vue
|
||||
│ │ ├── PdfSegment.vue
|
||||
│ │ ├── VideoSegment.vue
|
||||
│ │ ├── AudioSegment.vue
|
||||
│ │ ├── IframeSegment.vue
|
||||
│ │ └── GallerySegment.vue
|
||||
│ └── admin/
|
||||
│ ├── TokenPrompt.vue
|
||||
│ ├── AdminToolbar.vue
|
||||
│ ├── SegmentEditor.vue
|
||||
│ ├── AssetUploader.vue
|
||||
│ └── AddSegmentModal.vue
|
||||
└── views/
|
||||
└── HomePage.vue
|
||||
```
|
||||
|
||||
## Step 3: Docker Build + Smoke Test
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Must build and start without errors. Then verify:
|
||||
|
||||
```bash
|
||||
# Health check — site loads
|
||||
curl -s http://localhost:8000/api/site/ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d)"
|
||||
|
||||
# Auth verify works
|
||||
curl -s http://localhost:8000/api/auth/verify -H "Authorization: Bearer changeme" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['valid']==True; print('Auth OK')"
|
||||
|
||||
# Create a test segment
|
||||
curl -s -X POST http://localhost:8000/api/segments/ \
|
||||
-H "Authorization: Bearer changeme" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"markdown","title":"Test","content":"# Hello"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created segment {d[\"id\"]}')"
|
||||
|
||||
# Create a link segment
|
||||
curl -s -X POST http://localhost:8000/api/segments/ \
|
||||
-H "Authorization: Bearer changeme" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"link","title":"GitHub","content":"https://github.com"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created link {d[\"id\"]}')"
|
||||
|
||||
# List segments
|
||||
curl -s http://localhost:8000/api/segments/ | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d)} segments')"
|
||||
|
||||
# Frontend loads
|
||||
curl -s http://localhost:8000/ | head -5
|
||||
```
|
||||
|
||||
Clean up test data after verification:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Step 4: Edge Case Review
|
||||
|
||||
Check these specific scenarios and fix if broken:
|
||||
|
||||
1. **Empty state**: With no segments, the page should show "No content yet" (not crash or show blank).
|
||||
2. **Link segments in read-only mode**: Should appear in nav tabs but NOT in the content area.
|
||||
3. **Link segments in admin mode**: Should appear in the draggable list with URL displayed, editable via SegmentEditor.
|
||||
4. **Gallery segment with no images**: Should not crash — show empty state or just the upload zone.
|
||||
5. **Admin toolbar visibility**: Must be completely hidden when not authenticated.
|
||||
6. **Token persistence**: After page reload, if token was valid, admin state should restore from sessionStorage.
|
||||
7. **Concurrent safety**: Two simultaneous segment creates should not corrupt sort_order.
|
||||
8. **Site title env var**: With `HANDIN_SITE_TITLE=MyCourseName`, the default title should be "MyCourseName" (not "Untitled Site"). Once overridden via `PUT /api/site/`, the DB value takes precedence.
|
||||
|
||||
## Step 5: Visual Polish Check
|
||||
|
||||
Review the Tailwind classes in these components for consistency:
|
||||
|
||||
1. **Spacing**: All segment cards should have consistent padding (`p-6`), gap between cards (`space-y-6`).
|
||||
2. **Colors**: Primary color usage should be consistent — `bg-primary-300` for brand, `primary-50`/`primary-100` for editor backgrounds, `primary-400` for hover states. The header bar keeps its yellow `bg-primary-300` in both light and dark mode (university branding).
|
||||
3. **Typography**: Title bar uses `text-xl font-bold`, segment titles use `text-xl font-semibold`, body text uses default size.
|
||||
4. **Responsive**: Tab bar hidden on mobile, mobile hamburger hidden on desktop. Content area is `max-w-6xl` centered.
|
||||
5. **Shadows**: Segment cards use `shadow-md` (+ `dark:shadow-slate-950/30` in dark mode).
|
||||
6. **Dark mode**: All components have `dark:` variant classes. Toggle is a sun/moon icon in the title bar. Uses class-based dark mode via `@custom-variant dark (&:where(.dark, .dark *))` in style.css. Preference stored in localStorage, falls back to system preference.
|
||||
7. **Sticky header**: Entire header (title bar + nav) sticks to top on scroll. Back-to-top button appears after 300px scroll.
|
||||
8. **Admin controls**: Edit pencil icons should be `text-slate-300 hover:text-primary-300` (muted by default). Drag handles should match.
|
||||
9. **Header icons**: Lock icon and dark mode toggle use `text-slate-700` (no dark: overrides) since they sit on the always-yellow header.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
All of these must be true when you're done:
|
||||
|
||||
- [ ] `uv run pytest backend/tests/ -v` — all pass
|
||||
- [ ] `uv run ruff check backend/` — clean
|
||||
- [ ] `uv run mypy --strict backend/app/` — clean
|
||||
- [ ] `cd frontend && npm run build` — zero errors
|
||||
- [ ] `docker compose up --build` — builds and runs
|
||||
- [ ] Dark mode toggle works, persists across reload, respects system preference on first visit
|
||||
- [ ] Header bar stays yellow (`bg-primary-300`) in both light and dark mode
|
||||
- [ ] Sticky header stays visible when scrolling, back-to-top button appears
|
||||
- [ ] `HANDIN_SITE_TITLE` env var sets the default site title
|
||||
- [ ] Read-only mode shows zero admin UI (only lock icon + dark mode toggle in header)
|
||||
- [ ] Admin mode: all CRUD operations work
|
||||
- [ ] Link segments appear in nav only
|
||||
- [ ] Drag-and-drop reorder persists after refresh
|
||||
|
||||
## Files You May Modify
|
||||
|
||||
Any file in the project. Prefer minimal, targeted fixes. Do not refactor working code unnecessarily.
|
||||
Loading…
Add table
Add a link
Reference in a new issue