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
197 lines
6.4 KiB
Python
197 lines
6.4 KiB
Python
"""User accounts.
|
|
|
|
An admin creates people, corrects their details, and offboards them. The two
|
|
guardrails here exist because an admin who locks themselves out has no second
|
|
admin to call: you cannot change your own role, and you cannot delete yourself.
|
|
"""
|
|
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, Query
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.admin.routing import admin_router
|
|
from app.auth.deps import get_current_user
|
|
from app.auth.passwords import hash_password
|
|
from app.auth.sessions import revoke_user_sessions
|
|
from app.db import get_db
|
|
from app.errors import ApiError
|
|
from app.models import Department, User, UserRole
|
|
|
|
router = admin_router()
|
|
|
|
EMAIL_TAKEN = ("Email address already in use.", "email_taken")
|
|
|
|
|
|
class AdminUserOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
email: str
|
|
name: str
|
|
role: UserRole
|
|
department_id: uuid.UUID | None
|
|
|
|
|
|
class AdminUserPage(BaseModel):
|
|
items: list[AdminUserOut]
|
|
total: int
|
|
per_page: int
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
name: str = Field(min_length=1, max_length=200)
|
|
role: UserRole = UserRole.member
|
|
department_id: uuid.UUID | None = None
|
|
password: str = Field(min_length=8, max_length=200)
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
# Correcting a typo in an address should not mean deleting the person
|
|
# and losing everything hanging off their id. Plain `str` like
|
|
# UserCreate: `EmailStr` would pull in email-validator, a dependency we
|
|
# deliberately avoid, and the format is already guarded by the browser's
|
|
# type="email" and the column's unique constraint.
|
|
email: str | None = Field(default=None, min_length=3, max_length=320)
|
|
role: UserRole | None = None
|
|
department_id: uuid.UUID | None = None
|
|
clear_department: bool | None = None
|
|
# Setting a password IS the reset mechanism.
|
|
password: str | None = Field(default=None, min_length=8, max_length=200)
|
|
|
|
|
|
def _normalize_email(email: str) -> str:
|
|
"""The same normalisation on create and update, or an edited address would
|
|
stop matching what login looks up."""
|
|
return email.strip().lower()
|
|
|
|
|
|
async def _department_must_exist(
|
|
db: AsyncSession, department_id: uuid.UUID | None
|
|
) -> None:
|
|
if department_id is not None and await db.get(Department, department_id) is None:
|
|
raise ApiError(404, "Department not found.", "not_found")
|
|
|
|
|
|
@router.get("/users")
|
|
async def list_users(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
search: str | None = Query(None, max_length=200),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(25, ge=1, le=100),
|
|
) -> AdminUserPage:
|
|
"""Paged, because the admin screen is the one place that scales with
|
|
headcount: a company with two hundred employees would otherwise get two
|
|
hundred rows and no way to find anyone.
|
|
|
|
Departments deliberately stay unpaged: an SME has a handful, and a
|
|
pager over five rows is furniture.
|
|
"""
|
|
filters = []
|
|
if search:
|
|
needle = f"%{search.strip()}%"
|
|
filters.append(or_(User.email.ilike(needle), User.name.ilike(needle)))
|
|
|
|
total = (await db.execute(select(func.count(User.id)).where(*filters))).scalar_one()
|
|
rows = (
|
|
(
|
|
await db.execute(
|
|
select(User)
|
|
.where(*filters)
|
|
.order_by(User.email)
|
|
.offset((page - 1) * per_page)
|
|
.limit(per_page)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return AdminUserPage(
|
|
items=[AdminUserOut.model_validate(row) for row in rows],
|
|
total=total,
|
|
per_page=per_page,
|
|
)
|
|
|
|
|
|
@router.post("/users")
|
|
async def create_user(
|
|
body: UserCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> AdminUserOut:
|
|
await _department_must_exist(db, body.department_id)
|
|
user = User(
|
|
email=_normalize_email(body.email),
|
|
name=body.name,
|
|
role=body.role,
|
|
department_id=body.department_id,
|
|
password_hash=hash_password(body.password),
|
|
)
|
|
db.add(user)
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ApiError(409, *EMAIL_TAKEN) from None
|
|
return AdminUserOut.model_validate(user)
|
|
|
|
|
|
@router.patch("/users/{user_id}")
|
|
async def update_user(
|
|
user_id: uuid.UUID,
|
|
body: UserUpdate,
|
|
admin: Annotated[User, Depends(get_current_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> AdminUserOut:
|
|
user = await db.get(User, user_id)
|
|
if user is None:
|
|
raise ApiError(404, "User not found.", "not_found")
|
|
if user.id == admin.id and body.role is not None and body.role != user.role:
|
|
raise ApiError(409, "You cannot change your own role.", "self_modification")
|
|
|
|
if body.name is not None:
|
|
user.name = body.name
|
|
if body.email is not None:
|
|
user.email = _normalize_email(body.email)
|
|
if body.role is not None:
|
|
user.role = body.role
|
|
if body.clear_department:
|
|
user.department_id = None
|
|
elif body.department_id is not None:
|
|
await _department_must_exist(db, body.department_id)
|
|
user.department_id = body.department_id
|
|
if body.password is not None:
|
|
user.password_hash = hash_password(body.password)
|
|
# A password change revokes every session of that user — including
|
|
# the current one if an admin changes their own password.
|
|
await revoke_user_sessions(db, user.id)
|
|
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ApiError(409, *EMAIL_TAKEN) from None
|
|
return AdminUserOut.model_validate(user)
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=204)
|
|
async def delete_user(
|
|
user_id: uuid.UUID,
|
|
admin: Annotated[User, Depends(get_current_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> None:
|
|
"""Offboarding: sessions and conversations cascade, documents survive
|
|
with author set to NULL."""
|
|
if user_id == admin.id:
|
|
raise ApiError(409, "You cannot delete your own account.", "self_modification")
|
|
user = await db.get(User, user_id)
|
|
if user is None:
|
|
raise ApiError(404, "User not found.", "not_found")
|
|
await db.delete(user)
|
|
await db.commit()
|