initial release
This commit is contained in:
parent
e156de22c3
commit
b3ac11a85e
37 changed files with 3649 additions and 669 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
104
backend/app/routes/pages.py
Normal 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]
|
||||
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
|||
56
backend/app/routes/team.py
Normal file
56
backend/app/routes/team.py
Normal 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)
|
||||
149
backend/app/services/page_service.py
Normal file
149
backend/app/services/page_service.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
91
backend/app/services/team_service.py
Normal file
91
backend/app/services/team_service.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue