initial release

This commit is contained in:
Justin Visser 2026-03-03 16:16:36 +01:00
parent e156de22c3
commit b3ac11a85e
37 changed files with 3649 additions and 669 deletions

View file

@ -5,6 +5,7 @@ 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)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
@ -14,6 +15,19 @@ def init_db(db_path: Path) -> None:
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS pages (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
is_hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_order ON pages(sort_order)")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
@ -27,11 +41,106 @@ def init_db(db_path: Path) -> None:
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)")
conn.execute("""
CREATE TABLE IF NOT EXISTS team_members (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
student_number TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
# Migrate existing table schemas (add columns if missing)
_migrate_columns(conn)
# Remove legacy segment types
conn.execute("DELETE FROM segments WHERE type IN ('link', 'team')")
conn.commit()
# Ensure default pages exist
_migrate_default_page(conn)
_ensure_team_page(conn)
finally:
conn.close()
def _migrate_columns(conn: sqlite3.Connection) -> None:
"""Add any missing columns to existing tables."""
seg_cols = [row["name"] for row in conn.execute("PRAGMA table_info(segments)").fetchall()]
if "page_id" not in seg_cols:
conn.execute("ALTER TABLE segments ADD COLUMN page_id TEXT REFERENCES pages(id)")
page_cols = [row["name"] for row in conn.execute("PRAGMA table_info(pages)").fetchall()]
if "is_system" not in page_cols:
conn.execute("ALTER TABLE pages ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0")
if "is_hidden" not in page_cols:
conn.execute("ALTER TABLE pages ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0")
def _migrate_default_page(conn: sqlite3.Connection) -> None:
"""Ensure a Home page exists and all segments have a page_id."""
from datetime import UTC, datetime
from uuid import uuid4
home_id: str | None = None
home = conn.execute("SELECT id FROM pages WHERE slug = 'home'").fetchone()
if not home:
# Only create Home if no pages exist at all
page_count = conn.execute(
"SELECT COUNT(*) AS cnt FROM pages WHERE is_system = 0"
).fetchone()["cnt"]
if page_count == 0:
now = datetime.now(UTC).isoformat()
default_id = str(uuid4())
conn.execute(
"INSERT INTO pages"
" (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
" VALUES (?, ?, ?, ?, 1, 0, ?, ?)",
(default_id, "Home", "home", 0, now, now),
)
conn.commit()
home_id = default_id
else:
first = conn.execute(
"SELECT id FROM pages WHERE is_system = 0 ORDER BY sort_order ASC LIMIT 1"
).fetchone()
home_id = first["id"] if first else None
else:
home_id = home["id"]
if home_id:
conn.execute("UPDATE segments SET page_id = ? WHERE page_id IS NULL", (home_id,))
conn.commit()
conn.execute("UPDATE pages SET is_system = 1 WHERE slug = 'home'")
conn.commit()
def _ensure_team_page(conn: sqlite3.Connection) -> None:
"""Ensure the system Team page exists (always pinned last)."""
from datetime import UTC, datetime
from uuid import uuid4
existing = conn.execute("SELECT id FROM pages WHERE slug = 'team'").fetchone()
if existing:
# Make sure it's marked as system
conn.execute("UPDATE pages SET is_system = 1 WHERE slug = 'team'")
conn.commit()
return
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO pages"
" (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
" VALUES (?, ?, ?, ?, 1, 0, ?, ?)",
(str(uuid4()), "Team", "team", 9999, now, now),
)
conn.commit()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row

View file

@ -9,8 +9,10 @@ from app.config import get_settings
from app.database import init_db
from app.routes.assets import router as assets_router
from app.routes.auth import router as auth_router
from app.routes.pages import router as pages_router
from app.routes.segments import router as segments_router
from app.routes.site import router as site_router
from app.routes.team import router as team_router
@asynccontextmanager
@ -25,9 +27,11 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(title="Handin Website", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(pages_router)
app.include_router(segments_router)
app.include_router(site_router)
app.include_router(assets_router)
app.include_router(team_router)
@app.get("/api/health")

View file

@ -12,7 +12,7 @@ class SegmentType(StrEnum):
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
LINK = "link"
TEAM = "team"
class Segment(BaseModel):
@ -22,13 +22,15 @@ class Segment(BaseModel):
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
page_id: str | None = None
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
title: str
title: str = ""
page_id: str | None = None
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
@ -54,9 +56,54 @@ class SegmentResponse(BaseModel):
title: str
content: str
metadata: dict[str, object]
page_id: str | None = None
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str
class Page(BaseModel):
id: str
name: str
slug: str
sort_order: int
is_system: bool = False
is_hidden: bool = False
created_at: datetime
updated_at: datetime
class PageCreateRequest(BaseModel):
name: str
slug: str
class PageUpdateRequest(BaseModel):
name: str | None = None
slug: str | None = None
is_hidden: bool | None = None
class PageReorderRequest(BaseModel):
page_ids: list[str]
class TeamMember(BaseModel):
id: str
name: str
student_number: str
sort_order: int
created_at: datetime
updated_at: datetime
class TeamMemberCreateRequest(BaseModel):
name: str
student_number: str
class TeamMemberReorderRequest(BaseModel):
member_ids: list[str]

104
backend/app/routes/pages.py Normal file
View file

@ -0,0 +1,104 @@
import threading
from pathlib import Path
from typing import Annotated
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 (
Page,
PageCreateRequest,
PageReorderRequest,
PageUpdateRequest,
SegmentResponse,
)
from app.services import page_service, segment_service
router = APIRouter(prefix="/api/pages", tags=["pages"])
_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)]
@router.get("/")
def list_pages(db_path: DbPath) -> list[Page]:
return page_service.list_pages(db_path)
@router.post("/", status_code=201)
def create_page(request: PageCreateRequest, _: Admin, db_path: DbPath) -> Page:
with _write_lock:
# Check slug uniqueness
existing = page_service.get_page_by_slug(db_path, request.slug)
if existing:
raise HTTPException(status_code=409, detail="A page with this slug already exists")
return page_service.create_page(db_path, request)
@router.put("/reorder")
def reorder_pages(request: PageReorderRequest, _: Admin, db_path: DbPath) -> list[Page]:
with _write_lock:
try:
return page_service.reorder_pages(db_path, request.page_ids)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.get("/{page_id}")
def get_page(page_id: str, db_path: DbPath) -> Page:
page = page_service.get_page(db_path, page_id)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return page
@router.get("/by-slug/{slug}")
def get_page_by_slug(slug: str, db_path: DbPath) -> Page:
page = page_service.get_page_by_slug(db_path, slug)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return page
@router.patch("/{page_id}")
def update_page(page_id: str, request: PageUpdateRequest, _: Admin, db_path: DbPath) -> Page:
if request.slug is not None:
existing = page_service.get_page_by_slug(db_path, request.slug)
if existing and existing.id != page_id:
raise HTTPException(status_code=409, detail="A page with this slug already exists")
page = page_service.update_page(db_path, page_id, request)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return page
@router.delete("/{page_id}", status_code=204)
def delete_page(page_id: str, _: Admin, db_path: DbPath) -> Response:
deleted, error = page_service.delete_page(db_path, page_id)
if not deleted:
if error == "not_found":
raise HTTPException(status_code=404, detail="Page not found")
if error == "system_page":
raise HTTPException(status_code=403, detail="System pages cannot be deleted")
return Response(status_code=204)
@router.get("/{page_id}/segments")
def list_page_segments(page_id: str, db_path: DbPath) -> list[SegmentResponse]:
page = page_service.get_page(db_path, page_id)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
segments = segment_service.list_segments(db_path, page_id=page_id)
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]

View file

@ -3,7 +3,7 @@ from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from app.auth import require_admin
from app.config import get_settings
@ -34,8 +34,11 @@ Admin = Annotated[None, Depends(require_admin)]
@router.get("/")
def list_segments(db_path: DbPath) -> list[SegmentResponse]:
segments = segment_service.list_segments(db_path)
def list_segments(
db_path: DbPath,
page_id: Annotated[str | None, Query()] = None,
) -> list[SegmentResponse]:
segments = segment_service.list_segments(db_path, page_id=page_id)
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]

View file

@ -0,0 +1,56 @@
import threading
from pathlib import Path
from typing import Annotated
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 TeamMember, TeamMemberCreateRequest, TeamMemberReorderRequest
from app.services import team_service
router = APIRouter(prefix="/api/team", tags=["team"])
_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)]
@router.get("/")
def list_members(db_path: DbPath) -> list[TeamMember]:
return team_service.list_members(db_path)
@router.post("/", status_code=201)
def add_member(request: TeamMemberCreateRequest, _: Admin, db_path: DbPath) -> TeamMember:
with _write_lock:
return team_service.add_member(db_path, request)
@router.put("/reorder")
def reorder_members(
request: TeamMemberReorderRequest, _: Admin, db_path: DbPath
) -> list[TeamMember]:
with _write_lock:
try:
return team_service.reorder_members(db_path, request.member_ids)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.delete("/{member_id}", status_code=204)
def delete_member(member_id: str, _: Admin, db_path: DbPath) -> Response:
deleted = team_service.delete_member(db_path, member_id)
if not deleted:
raise HTTPException(status_code=404, detail="Team member not found")
return Response(status_code=204)

View file

@ -0,0 +1,149 @@
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from app.database import get_connection
from app.models import Page, PageCreateRequest, PageUpdateRequest
def _row_to_page(row: sqlite3.Row) -> Page:
return Page(
id=row["id"],
name=row["name"],
slug=row["slug"],
sort_order=row["sort_order"],
is_system=bool(row["is_system"]),
is_hidden=bool(row["is_hidden"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def list_pages(db_path: Path) -> list[Page]:
conn = get_connection(db_path)
try:
rows = conn.execute(
"SELECT * FROM pages ORDER BY is_system ASC, sort_order ASC"
).fetchall()
return [_row_to_page(row) for row in rows]
finally:
conn.close()
def get_page(db_path: Path, page_id: str) -> Page | None:
conn = get_connection(db_path)
try:
row = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
return _row_to_page(row) if row else None
finally:
conn.close()
def get_page_by_slug(db_path: Path, slug: str) -> Page | None:
conn = get_connection(db_path)
try:
row = conn.execute("SELECT * FROM pages WHERE slug = ?", (slug,)).fetchone()
return _row_to_page(row) if row else None
finally:
conn.close()
def create_page(db_path: Path, request: PageCreateRequest) -> Page:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM pages WHERE is_system = 0"
).fetchone()
sort_order: int = row["next_order"] if row else 0
page_id = str(uuid4())
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO pages"
" (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
" VALUES (?, ?, ?, ?, 0, 0, ?, ?)",
(page_id, request.name, request.slug, sort_order, now, now),
)
conn.commit()
return Page(
id=page_id,
name=request.name,
slug=request.slug,
sort_order=sort_order,
is_system=False,
is_hidden=False,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
finally:
conn.close()
def update_page(db_path: Path, page_id: str, request: PageUpdateRequest) -> Page | None:
conn = get_connection(db_path)
try:
existing = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
if existing is None:
return None
now = datetime.now(UTC).isoformat()
name = request.name if request.name is not None else existing["name"]
slug = request.slug if request.slug is not None else existing["slug"]
is_hidden = (
int(request.is_hidden) if request.is_hidden is not None else existing["is_hidden"]
)
conn.execute(
"UPDATE pages SET name = ?, slug = ?, is_hidden = ?, updated_at = ? WHERE id = ?",
(name, slug, is_hidden, now, page_id),
)
conn.commit()
return get_page(db_path, page_id)
finally:
conn.close()
def delete_page(db_path: Path, page_id: str) -> tuple[bool, str | None]:
"""Cascade-deletes the page and all its segments."""
conn = get_connection(db_path)
try:
existing = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
if existing is None:
return False, "not_found"
if existing["is_system"]:
return False, "system_page"
conn.execute("DELETE FROM segments WHERE page_id = ?", (page_id,))
conn.execute("DELETE FROM pages WHERE id = ?", (page_id,))
conn.commit()
return True, None
finally:
conn.close()
def reorder_pages(db_path: Path, page_ids: list[str]) -> list[Page]:
"""Reorders only non-system pages."""
conn = get_connection(db_path)
try:
existing_ids = {
row["id"]
for row in conn.execute("SELECT id FROM pages WHERE is_system = 0").fetchall()
}
requested_ids = set(page_ids)
missing = requested_ids - existing_ids
if missing:
raise ValueError(f"Page IDs not found: {missing}")
now = datetime.now(UTC).isoformat()
for index, pid in enumerate(page_ids):
conn.execute(
"UPDATE pages SET sort_order = ?, updated_at = ? WHERE id = ?",
(index, now, pid),
)
conn.commit()
return list_pages(db_path)
finally:
conn.close()

View file

@ -16,6 +16,7 @@ def _row_to_segment(row: sqlite3.Row) -> Segment:
title=row["title"],
content=row["content"],
metadata=json.loads(row["metadata"]),
page_id=dict(row).get("page_id"),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
@ -25,7 +26,9 @@ 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"
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order"
" FROM segments WHERE page_id = ?",
(request.page_id,),
).fetchone()
sort_order: int = row["next_order"] if row else 0
@ -33,9 +36,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
now = datetime.now(UTC).isoformat()
metadata_json = json.dumps(request.metadata)
cols = "id, type, sort_order, title, content, metadata, created_at, updated_at"
cols = "id, type, sort_order, title, content, metadata, page_id, created_at, updated_at"
conn.execute(
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
str(segment_id),
request.type.value,
@ -43,6 +46,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
request.title,
request.content,
metadata_json,
request.page_id,
now,
now,
),
@ -56,6 +60,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
title=request.title,
content=request.content,
metadata=request.metadata,
page_id=request.page_id,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
@ -63,10 +68,16 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn.close()
def list_segments(db_path: Path) -> list[Segment]:
def list_segments(db_path: Path, page_id: str | None = None) -> list[Segment]:
conn = get_connection(db_path)
try:
rows = conn.execute("SELECT * FROM segments ORDER BY sort_order ASC").fetchall()
if page_id is not None:
rows = conn.execute(
"SELECT * FROM segments WHERE page_id = ? ORDER BY sort_order ASC",
(page_id,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM segments ORDER BY sort_order ASC").fetchall()
return [_row_to_segment(row) for row in rows]
finally:
conn.close()
@ -140,6 +151,13 @@ def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
)
conn.commit()
return list_segments(db_path)
# Return segments scoped to the same page as the first segment in the list
if segment_ids:
row = conn.execute(
"SELECT page_id FROM segments WHERE id = ?", (str(segment_ids[0]),)
).fetchone()
page_id = row["page_id"] if row else None
return list_segments(db_path, page_id=page_id)
return []
finally:
conn.close()

View file

@ -0,0 +1,91 @@
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from app.database import get_connection
from app.models import TeamMember, TeamMemberCreateRequest
def _row_to_member(row: sqlite3.Row) -> TeamMember:
return TeamMember(
id=row["id"],
name=row["name"],
student_number=row["student_number"],
sort_order=row["sort_order"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def list_members(db_path: Path) -> list[TeamMember]:
conn = get_connection(db_path)
try:
rows = conn.execute("SELECT * FROM team_members ORDER BY sort_order ASC").fetchall()
return [_row_to_member(row) for row in rows]
finally:
conn.close()
def add_member(db_path: Path, request: TeamMemberCreateRequest) -> TeamMember:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM team_members"
).fetchone()
sort_order: int = row["next_order"] if row else 0
member_id = str(uuid4())
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO team_members"
" (id, name, student_number, sort_order, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(member_id, request.name, request.student_number, sort_order, now, now),
)
conn.commit()
return TeamMember(
id=member_id,
name=request.name,
student_number=request.student_number,
sort_order=sort_order,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
finally:
conn.close()
def delete_member(db_path: Path, member_id: str) -> bool:
conn = get_connection(db_path)
try:
existing = conn.execute(
"SELECT id FROM team_members WHERE id = ?", (member_id,)
).fetchone()
if existing is None:
return False
conn.execute("DELETE FROM team_members WHERE id = ?", (member_id,))
conn.commit()
return True
finally:
conn.close()
def reorder_members(db_path: Path, member_ids: list[str]) -> list[TeamMember]:
conn = get_connection(db_path)
try:
existing_ids = {
row["id"] for row in conn.execute("SELECT id FROM team_members").fetchall()
}
missing = set(member_ids) - existing_ids
if missing:
raise ValueError(f"Member IDs not found: {missing}")
now = datetime.now(UTC).isoformat()
for index, mid in enumerate(member_ids):
conn.execute(
"UPDATE team_members SET sort_order = ?, updated_at = ? WHERE id = ?",
(index, now, mid),
)
conn.commit()
return list_members(db_path)
finally:
conn.close()