Pablan, as it stands
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -0,0 +1,18 @@
|
||||
"""The admin API: everything only an administrator may do.
|
||||
|
||||
Split by what is being administered — model endpoints, prompts, users,
|
||||
departments, and the metrics snapshot. The admin gate is not repeated per
|
||||
endpoint: it sits on the shared router in `routing.py`, so a new route in any
|
||||
of these modules is admin-only whether or not its author thought about it.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.admin import departments, llm, observability, prompts, users
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(llm.router)
|
||||
router.include_router(prompts.router)
|
||||
router.include_router(users.router)
|
||||
router.include_router(departments.router)
|
||||
router.include_router(observability.router)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Model endpoints: test them, configure them, ask what they serve.
|
||||
|
||||
Configuration is bootstrapped from `.env` at first start and lives in the DB
|
||||
afterwards, so this is where an admin changes an endpoint without a restart.
|
||||
Every call runs server-side: the API key must never reach the browser, and the
|
||||
browser must never reach the model endpoint.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.admin.routing import admin_router
|
||||
from app.db import get_db
|
||||
from app.llm.client import (
|
||||
Role,
|
||||
chat_stream,
|
||||
embed,
|
||||
list_models,
|
||||
probe,
|
||||
rebuild_clients,
|
||||
role_config,
|
||||
)
|
||||
from app.llm.errors import LLMError
|
||||
from app.llm.overrides import env_defaults, load_config
|
||||
from app.models import LLMSetting
|
||||
|
||||
router = admin_router()
|
||||
|
||||
ROLES: tuple[Role, ...] = ("chat", "utility", "embedding")
|
||||
|
||||
|
||||
class LLMSettingUpdate(BaseModel):
|
||||
# None means "leave as is"; the reset_* flags restore the value the
|
||||
# environment currently states (same sentinel pattern as
|
||||
# UserUpdate.clear_department).
|
||||
base_url: str | None = Field(default=None, max_length=500)
|
||||
model: str | None = Field(default=None, max_length=200)
|
||||
api_key: str | None = Field(default=None, max_length=500)
|
||||
reset_base_url: bool | None = None
|
||||
reset_model: bool | None = None
|
||||
reset_api_key: bool | None = None
|
||||
|
||||
|
||||
class LLMRoleStatus(BaseModel):
|
||||
role: Role
|
||||
ok: bool
|
||||
base_url: str
|
||||
model: str
|
||||
latency_ms: int | None
|
||||
# Why it failed: `code` is phrased by the frontend, `error` is the
|
||||
# sanitized technical detail (exception class + role) for the admin.
|
||||
code: str | None
|
||||
error: str | None
|
||||
|
||||
|
||||
class LLMTestResponse(BaseModel):
|
||||
roles: list[LLMRoleStatus]
|
||||
|
||||
|
||||
class LLMTestRequest(BaseModel):
|
||||
"""Optional candidate config: test an endpoint BEFORE saving it."""
|
||||
|
||||
role: Role | None = None
|
||||
base_url: str | None = None
|
||||
model: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
async def _ping_role(
|
||||
role: Role, candidate: LLMSettingUpdate | None = None
|
||||
) -> LLMRoleStatus:
|
||||
"""Ping a role — either its effective config, or a candidate an admin is
|
||||
about to save."""
|
||||
base_url, _, model = role_config(role)
|
||||
if candidate is not None:
|
||||
base_url = candidate.base_url or base_url
|
||||
model = candidate.model or model
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
if candidate is None:
|
||||
if role == "embedding":
|
||||
await embed(["ping"])
|
||||
else:
|
||||
# Drain the (max_tokens=1) stream so the call records as
|
||||
# "ok", not "aborted".
|
||||
async for _ in chat_stream(
|
||||
[{"role": "user", "content": "ping"}], role=role, max_tokens=1
|
||||
):
|
||||
pass
|
||||
else:
|
||||
await probe(
|
||||
role,
|
||||
base_url=candidate.base_url,
|
||||
api_key=candidate.api_key,
|
||||
model=candidate.model,
|
||||
)
|
||||
return LLMRoleStatus(
|
||||
role=role,
|
||||
ok=True,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
latency_ms=round((time.monotonic() - started) * 1000),
|
||||
code=None,
|
||||
error=None,
|
||||
)
|
||||
except LLMError as exc:
|
||||
return LLMRoleStatus(
|
||||
role=role,
|
||||
ok=False,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
latency_ms=None,
|
||||
code=exc.code,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/llm/test")
|
||||
async def llm_test(body: LLMTestRequest | None = None) -> LLMTestResponse:
|
||||
"""First-line support tool: pings all three roles, or one candidate
|
||||
configuration without persisting anything."""
|
||||
if body is not None and body.role is not None:
|
||||
candidate = LLMSettingUpdate(
|
||||
base_url=body.base_url, model=body.model, api_key=body.api_key
|
||||
)
|
||||
return LLMTestResponse(roles=[await _ping_role(body.role, candidate)])
|
||||
results = await asyncio.gather(*(_ping_role(role) for role in ROLES))
|
||||
return LLMTestResponse(roles=list(results))
|
||||
|
||||
|
||||
class LLMSettingOut(BaseModel):
|
||||
"""Stored config for one role.
|
||||
|
||||
The api_key is NEVER returned — only whether one is set, and where each
|
||||
field's value came from. `*_from_env` drives the per-field
|
||||
"taken from .env" / "changed here" label and the reset action; it is
|
||||
provenance, not a fallback.
|
||||
"""
|
||||
|
||||
role: Role
|
||||
base_url: str
|
||||
model: str
|
||||
base_url_from_env: bool
|
||||
model_from_env: bool
|
||||
api_key_set: bool
|
||||
api_key_from_env: bool
|
||||
|
||||
|
||||
async def _row_for(db: AsyncSession, role: Role) -> LLMSetting:
|
||||
row = (
|
||||
await db.execute(select(LLMSetting).where(LLMSetting.role == role))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
# Only reachable if bootstrap never ran (a test, or a role added
|
||||
# after install). Seed it from the environment, same as bootstrap.
|
||||
defaults = env_defaults(role)
|
||||
row = LLMSetting(
|
||||
role=role,
|
||||
base_url=defaults.base_url or None,
|
||||
model=defaults.model or None,
|
||||
api_key=defaults.api_key or None,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _setting_out(row: LLMSetting) -> LLMSettingOut:
|
||||
return LLMSettingOut(
|
||||
role=row.role, # type: ignore[arg-type]
|
||||
base_url=row.base_url or "",
|
||||
model=row.model or "",
|
||||
base_url_from_env=row.base_url_from_env,
|
||||
model_from_env=row.model_from_env,
|
||||
api_key_set=bool(row.api_key),
|
||||
api_key_from_env=row.api_key_from_env,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/llm/settings")
|
||||
async def llm_settings(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> list[LLMSettingOut]:
|
||||
return [_setting_out(await _row_for(db, role)) for role in ROLES]
|
||||
|
||||
|
||||
@router.put("/llm/settings/{role}")
|
||||
async def update_llm_setting(
|
||||
role: Role,
|
||||
body: LLMSettingUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> LLMSettingOut:
|
||||
"""Change a role's stored config and apply it without a restart.
|
||||
|
||||
Writing a field marks it as changed here; resetting it writes back what
|
||||
`.env` currently says and marks it as coming from the environment
|
||||
again.
|
||||
"""
|
||||
row = await _row_for(db, role)
|
||||
defaults = env_defaults(role)
|
||||
|
||||
if body.reset_base_url:
|
||||
row.base_url, row.base_url_from_env = defaults.base_url or None, True
|
||||
elif body.base_url is not None:
|
||||
row.base_url, row.base_url_from_env = body.base_url or None, False
|
||||
if body.reset_model:
|
||||
row.model, row.model_from_env = defaults.model or None, True
|
||||
elif body.model is not None:
|
||||
row.model, row.model_from_env = body.model or None, False
|
||||
if body.reset_api_key:
|
||||
row.api_key, row.api_key_from_env = defaults.api_key or None, True
|
||||
elif body.api_key:
|
||||
row.api_key, row.api_key_from_env = body.api_key, False
|
||||
|
||||
await db.commit()
|
||||
await load_config(db)
|
||||
# The cached OpenAI clients hold the old base_url and key.
|
||||
rebuild_clients()
|
||||
return _setting_out(row)
|
||||
|
||||
|
||||
class LLMModelsRequest(BaseModel):
|
||||
"""Optional candidate endpoint, so an admin can list the models of a
|
||||
URL they have typed but not saved."""
|
||||
|
||||
base_url: str | None = Field(default=None, max_length=500)
|
||||
api_key: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class LLMModelsResponse(BaseModel):
|
||||
models: list[str]
|
||||
# False when the endpoint does not implement GET /v1/models — the UI
|
||||
# keeps its free-text field instead of showing an error.
|
||||
supported: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@router.post("/llm/models/{role}")
|
||||
async def llm_models(
|
||||
role: Role, body: LLMModelsRequest | None = None
|
||||
) -> LLMModelsResponse:
|
||||
"""List what the endpoint serves, so the model field can be a dropdown."""
|
||||
try:
|
||||
models = await list_models(
|
||||
role,
|
||||
base_url=(body.base_url if body else None) or None,
|
||||
api_key=(body.api_key if body else None) or None,
|
||||
)
|
||||
except LLMError as exc:
|
||||
# A 404 means "this server has no /v1/models", which is common
|
||||
# enough to be a normal outcome rather than a failure to report.
|
||||
supported = exc.status_code != 404
|
||||
return LLMModelsResponse(
|
||||
models=[],
|
||||
supported=supported,
|
||||
error=exc.cause_type if supported else None,
|
||||
)
|
||||
return LLMModelsResponse(models=models, supported=True)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""What the running process has counted so far."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.api.admin.routing import admin_router
|
||||
from app.metrics import metrics
|
||||
|
||||
router = admin_router()
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics_snapshot() -> dict[str, Any]:
|
||||
"""The in-process metrics registry as JSON.
|
||||
|
||||
Per process by design (the app runs one worker), and admin-only: the
|
||||
counters name models and durations, which is operational detail rather
|
||||
than something to expose publicly.
|
||||
"""
|
||||
return metrics.snapshot()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Editing the shipped system prompts.
|
||||
|
||||
Every prompt has a code default; a row exists only where an admin changed one,
|
||||
and resetting deletes that row rather than storing a copy of the default. The
|
||||
UI labels the keys from its own messages, so nothing user-facing is worded here.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
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 PromptSetting
|
||||
from app.prompts.defaults import DEFAULTS, PROMPT_KEYS
|
||||
from app.prompts.overrides import get_prompt, is_overridden
|
||||
from app.prompts.overrides import load_config as load_prompt_config
|
||||
|
||||
router = admin_router()
|
||||
|
||||
|
||||
class PromptSettingOut(BaseModel):
|
||||
key: str
|
||||
# The effective text: an admin override if present, else the code default.
|
||||
content: str
|
||||
# True when no override exists, i.e. the shipped default is in force.
|
||||
is_default: bool
|
||||
|
||||
|
||||
class PromptSettingUpdate(BaseModel):
|
||||
# New override text, or `reset` to drop the override back to the default.
|
||||
content: str | None = None
|
||||
reset: bool | None = None
|
||||
|
||||
|
||||
@router.get("/prompts")
|
||||
async def prompt_settings(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> list[PromptSettingOut]:
|
||||
"""Every editable system prompt with its effective text and whether it is
|
||||
still the shipped default."""
|
||||
rows = {row.key: row for row in (await db.execute(select(PromptSetting))).scalars()}
|
||||
return [
|
||||
PromptSettingOut(
|
||||
key=key,
|
||||
content=rows[key].content if key in rows else DEFAULTS[key],
|
||||
is_default=key not in rows,
|
||||
)
|
||||
for key in PROMPT_KEYS
|
||||
]
|
||||
|
||||
|
||||
@router.put("/prompts/{key}")
|
||||
async def update_prompt_setting(
|
||||
key: str,
|
||||
body: PromptSettingUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> PromptSettingOut:
|
||||
"""Override a system prompt (applied without a restart) or reset it to the
|
||||
shipped default. Resetting deletes the override row."""
|
||||
if key not in DEFAULTS:
|
||||
raise ApiError(404, "Unknown prompt.", "not_found")
|
||||
row = (
|
||||
await db.execute(select(PromptSetting).where(PromptSetting.key == key))
|
||||
).scalar_one_or_none()
|
||||
|
||||
if body.reset:
|
||||
if row is not None:
|
||||
await db.delete(row)
|
||||
elif body.content is not None:
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise ApiError(422, "A prompt cannot be empty.", "empty_prompt")
|
||||
if row is None:
|
||||
db.add(PromptSetting(key=key, content=content))
|
||||
else:
|
||||
row.content = content
|
||||
|
||||
await db.commit()
|
||||
await load_prompt_config(db)
|
||||
return PromptSettingOut(
|
||||
key=key, content=get_prompt(key), is_default=not is_overridden(key)
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""The one router constructor the admin modules share.
|
||||
|
||||
The admin gate lives here rather than on each endpoint: a route added to any
|
||||
of these modules is admin-only by construction, and there is no way to forget
|
||||
the dependency.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.deps import require_admin
|
||||
|
||||
|
||||
def admin_router() -> APIRouter:
|
||||
return APIRouter(
|
||||
prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user