Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
109 lines
3.6 KiB
Python
109 lines
3.6 KiB
Python
"""Departments.
|
|
|
|
Deliberately unpaged: an SME has a handful of them. The interesting rule is
|
|
deletion — a department's read grants CASCADE away with it, and that access
|
|
loss is invisible, so it has to be confirmed rather than discovered later.
|
|
"""
|
|
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.admin.routing import admin_router
|
|
from app.db import get_db
|
|
from app.errors import ApiError
|
|
from app.models import Department, DocPermission, Document, User
|
|
|
|
router = admin_router()
|
|
|
|
NAME_TAKEN = ("Department name already exists.", "name_taken")
|
|
|
|
|
|
class DepartmentCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
class AdminDepartmentOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
name: str
|
|
|
|
|
|
@router.post("/departments")
|
|
async def create_department(
|
|
body: DepartmentCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> AdminDepartmentOut:
|
|
department = Department(name=body.name.strip())
|
|
db.add(department)
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ApiError(409, *NAME_TAKEN) from None
|
|
return AdminDepartmentOut.model_validate(department)
|
|
|
|
|
|
@router.patch("/departments/{department_id}")
|
|
async def rename_department(
|
|
department_id: uuid.UUID,
|
|
body: DepartmentCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> AdminDepartmentOut:
|
|
department = await db.get(Department, department_id)
|
|
if department is None:
|
|
raise ApiError(404, "Department not found.", "not_found")
|
|
department.name = body.name.strip()
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ApiError(409, *NAME_TAKEN) from None
|
|
return AdminDepartmentOut.model_validate(department)
|
|
|
|
|
|
async def _still_in_use(db: AsyncSession, department_id: uuid.UUID) -> bool:
|
|
"""Members, owned documents or read grants — anything whose access changes
|
|
when this department disappears."""
|
|
for count in (
|
|
select(func.count(User.id)).where(User.department_id == department_id),
|
|
select(func.count(Document.id)).where(Document.department_id == department_id),
|
|
select(func.count())
|
|
.select_from(DocPermission)
|
|
.where(DocPermission.department_id == department_id),
|
|
):
|
|
if (await db.execute(count)).scalar_one():
|
|
return True
|
|
return False
|
|
|
|
|
|
@router.delete("/departments/{department_id}", status_code=204)
|
|
async def delete_department(
|
|
department_id: uuid.UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
confirm: bool = False,
|
|
) -> None:
|
|
"""Delete a department. Members and owned documents survive with their
|
|
`department_id` set to NULL, but this department's `doc_permissions` grants
|
|
CASCADE away — silently dropping the shared read access they gave. Because
|
|
that access loss is invisible, deleting a department that still has members,
|
|
owned documents or grants requires `?confirm=true` (409 `department_in_use`
|
|
otherwise)."""
|
|
department = await db.get(Department, department_id)
|
|
if department is None:
|
|
raise ApiError(404, "Department not found.", "not_found")
|
|
if not confirm and await _still_in_use(db, department_id):
|
|
raise ApiError(
|
|
409,
|
|
"This department is still in use; deleting it drops that access.",
|
|
"department_in_use",
|
|
)
|
|
await db.delete(department)
|
|
await db.commit()
|