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:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
"""The documents API.
Split by what a caller is doing, not by HTTP verb: browsing, the life of one
document, its audit trail, the approval workflow, and department sharing. The
shared gates live in `access.py` and the shared response shapes in `view.py`,
so a rule like "an author keeps access to their own document" exists once.
**Route order matters.** FastAPI matches in registration order, so `browse`
goes first: after `/{document_id}` exists, a request for `/search` would be
parsed as a document id.
"""
from fastapi import APIRouter
from app.api.documents import browse, crud, history, sharing, workflow
from app.api.documents.access import readable_document, require_editor
router = APIRouter()
router.include_router(browse.router)
router.include_router(crud.router)
router.include_router(history.router)
router.include_router(workflow.router)
router.include_router(sharing.router)
# The authoring API works on documents the caller may change, so it shares
# this package's gate rather than growing a second one.
__all__ = ["readable_document", "require_editor", "router"]
+175
View File
@@ -0,0 +1,175 @@
"""Who may read, edit and publish a document.
The read gate itself lives in `rag/permissions` as SQL, because there is one
place where "which documents may this user see" is decided. What lives here is
everything the HTTP layer needs around it: loading one document through that
gate, the write gates on top of it, and the Python mirror that can judge a
change BEFORE it is committed.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ApiError
from app.models import (
AccessReason,
Document,
DocumentStatus,
DocumentVisibility,
User,
UserRole,
)
from app.rag.permissions import readable_documents_filter
BUILTIN_READONLY = (
"Built-in help documents are maintained with the product.",
"builtin_readonly",
)
async def readable_document(
db: AsyncSession, document_id: uuid.UUID, user: User
) -> Document:
"""One document, through the same filter the search uses."""
document = (
await db.execute(
select(Document).where(
Document.id == document_id, readable_documents_filter(user)
)
)
).scalar_one_or_none()
if document is None:
# 404 for unreadable docs: existence must not leak.
raise ApiError(404, "Document not found.", "not_found")
return document
def is_open_reviewer(document: Document, user: User) -> bool:
"""Someone asked this user to check the document and has not been answered.
Being asked is what grants the right to change it: a reviewer who spots a
wrong number should fix it, not file a second question about it.
"""
return any(review.reviewer_id == user.id for review in document.open_reviews)
def can_edit(document: Document, user: User) -> bool:
"""Mirror of `require_editor` — the UI must predict the gate, never guess
it."""
if document.is_builtin:
return False
return (
document.author_id == user.id
or user.role == UserRole.admin
or is_open_reviewer(document, user)
)
def require_editor(document: Document, user: User) -> None:
if document.is_builtin:
# Help pages ship with the product and are re-imported on start;
# an edit here would silently vanish on the next deploy.
raise ApiError(409, *BUILTIN_READONLY)
if not can_edit(document, user):
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
def require_author_or_admin(document: Document, user: User) -> None:
"""Stricter than `require_editor`: for the decisions that belong to the
document's owner, like deleting it or handing out a review request."""
if document.is_builtin:
raise ApiError(409, *BUILTIN_READONLY)
if document.author_id != user.id and user.role != UserRole.admin:
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
def access_reason(document: Document, user: User) -> AccessReason:
"""Most specific reason first: being the author explains access better
than the visibility level does.
Mirrors `readable_documents_filter`, where the visibility rules only apply
to a PUBLISHED document — an unpublished one is visible to its author and
to whoever was asked to check it, and to nobody else. So `review` is the
reason whenever nothing more durable carries the access, which is exactly
the case where the access ends with the answer.
"""
if document.author_id == user.id:
return AccessReason.author
published = document.status == DocumentStatus.published
if published and document.visibility == DocumentVisibility.public:
return AccessReason.public
if (
published
and document.visibility == DocumentVisibility.department
and document.department_id is not None
and document.department_id == user.department_id
):
return AccessReason.department
if is_open_reviewer(document, user):
return AccessReason.review
# Everything else that survived the permission filter came via a grant.
return AccessReason.granted
def user_can_read(
user: User,
*,
author_id: uuid.UUID | None,
visibility: DocumentVisibility,
department_id: uuid.UUID | None,
granted_department_ids: set[uuid.UUID],
) -> bool:
"""The Python mirror of `readable_documents_filter` for one document's
proposed state — so a change can be checked BEFORE it is committed. Admins
get no read-everything bypass (same as the filter).
Kept next to its only caller so the two cannot drift apart unnoticed; the
SQL it mirrors is one import away.
"""
if author_id is not None and author_id == user.id:
return True
if visibility == DocumentVisibility.public:
return True
if (
visibility == DocumentVisibility.department
and department_id is not None
and department_id == user.department_id
):
return True
return (
user.department_id is not None and user.department_id in granted_department_ids
)
def guard_self_lockout(
user: User,
*,
author_id: uuid.UUID | None,
visibility: DocumentVisibility,
department_id: uuid.UUID | None,
granted_department_ids: set[uuid.UUID],
confirm: bool,
) -> None:
"""Refuse (or, for a confirming admin, allow) a change that would remove the
editing user's own read access. An author keeps access as author, so this
only ever bites an admin editing a document they do not own."""
if user_can_read(
user,
author_id=author_id,
visibility=visibility,
department_id=department_id,
granted_department_ids=granted_department_ids,
):
return
if user.role != UserRole.admin:
# A non-author non-admin cannot reach this state through the API; a
# defensive block rather than a silent lockout.
raise ApiError(409, "This change would remove your own access.", "self_lockout")
if not confirm:
raise ApiError(
409,
"You will lose access to this document after this change.",
"self_lockout_warning",
)
+293
View File
@@ -0,0 +1,293 @@
"""Finding documents: the paged list, ranked search, the ZIP export, and the
company-wide counts.
Every route here has a static path, so this router is included FIRST: after
`/{document_id}` is registered, "search" would be parsed as a document id.
"""
import io
import re
import uuid
import zipfile
from typing import Annotated
import yaml
from fastapi import Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import exists, func, or_, select, true
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.routing import documents_router
from app.api.documents.schemas import (
DocumentPage,
DocumentSearchHit,
DocumentSort,
DocumentStats,
)
from app.api.documents.view import document_fields, summary
from app.auth.deps import get_current_user
from app.db import get_db
from app.models import (
Department,
DocPermission,
Document,
DocumentStatus,
User,
)
from app.rag.permissions import open_review_for, readable_documents_filter
# aliased: `search` is also a query parameter on the list endpoint
from app.rag.retrieval import search as hybrid_search
router = documents_router()
# Chunks retrieved before grouping, and the most documents a search returns.
SEARCH_CANDIDATES = 20
SEARCH_LIMIT = 20
@router.get("")
async def list_documents(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
department: uuid.UUID | None = None,
status: DocumentStatus | None = None,
assigned_to_me: bool = False,
search: str | None = Query(None, max_length=200),
sort: DocumentSort = DocumentSort.updated,
page: int = Query(1, ge=1),
per_page: int = Query(30, ge=1, le=100),
) -> DocumentPage:
"""Browse readable documents.
Paginated server-side: the list is the one screen that grows without
bound as a knowledge base fills up. Search has its own endpoint and is
ranked rather than paged.
"""
filters = [readable_documents_filter(user)]
if department is not None:
# A department filter matches the owning department OR a shared grant,
# so a document shared with a department shows up under it too.
filters.append(
or_(
Document.department_id == department,
exists(
select(DocPermission.document_id).where(
DocPermission.document_id == Document.id,
DocPermission.department_id == department,
)
),
)
)
if status is not None:
filters.append(Document.status == status)
if assigned_to_me:
# "Waiting for me": documents someone asked THIS user to check.
filters.append(open_review_for(user))
if search:
filters.append(Document.title.ilike(f"%{search}%"))
total = (
await db.execute(select(func.count(Document.id)).where(*filters))
).scalar_one()
order = (
Document.created_at.desc()
if sort is DocumentSort.created
else Document.updated_at.desc()
)
documents = (
(
await db.execute(
select(Document)
.where(*filters)
# Built-in help is reference material and belongs after the
# team's own documents — sorted in SQL so it holds across page
# boundaries, which a client-side sort could not manage.
# `Document.id` breaks ties. Without it the order is only
# partial: the corpus is seeded in one transaction, so many
# rows share a timestamp to the microsecond, and Postgres is
# free to return them in a different order per query. Two pages
# then overlap and a document is shown twice while another is
# never reachable.
.order_by(Document.is_builtin.asc(), order, Document.id)
.offset((page - 1) * per_page)
.limit(per_page)
)
)
.scalars()
.all()
)
return DocumentPage(
items=[summary(document, user) for document in documents],
total=total,
per_page=per_page,
)
@router.get("/search")
async def search_documents(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
q: Annotated[str, Query(min_length=1, max_length=200)],
) -> list[DocumentSearchHit]:
"""Find documents through the same hybrid retrieval the chat uses.
Permission-safe by construction: `search()` requires a user and applies
the shared filter. Drafts and pending documents are readable
but never indexed, so a title fallback covers them — the one asymmetry
between this endpoint and chat retrieval.
"""
results = await hybrid_search(db, q, user=user, top_k=SEARCH_CANDIDATES)
# Group chunks per document, keeping the best-scoring chunk's heading.
best_heading: dict[uuid.UUID, str] = {}
for result in results:
best_heading.setdefault(result.document_id, result.heading_path)
hits: list[DocumentSearchHit] = []
if best_heading:
documents = (
(
await db.execute(
select(Document).where(
Document.id.in_(best_heading),
readable_documents_filter(user),
)
)
)
.scalars()
.all()
)
by_id = {document.id: document for document in documents}
# Preserve retrieval order — relevance, not insertion order.
for document_id, heading in best_heading.items():
document = by_id.get(document_id)
if document is not None:
hits.append(
DocumentSearchHit(
**document_fields(document, user),
heading_path=heading,
)
)
# Title fallback for everything retrieval cannot see.
remaining = SEARCH_LIMIT - len(hits)
if remaining > 0:
by_title = (
(
await db.execute(
select(Document)
.where(
readable_documents_filter(user),
Document.title.ilike(f"%{q}%"),
Document.id.notin_(best_heading) if best_heading else true(),
)
.order_by(Document.updated_at.desc())
.limit(remaining)
)
)
.scalars()
.all()
)
hits.extend(
DocumentSearchHit(**document_fields(document, user))
for document in by_title
)
return hits[:SEARCH_LIMIT]
def _export_name(document: Document, used: set[str]) -> str:
"""A stable, de-duplicated `.md` filename for a document in the export."""
slug = (document.meta or {}).get("slug")
base = slug or re.sub(r"[^a-z0-9]+", "-", document.title.lower()).strip("-")
base = base or str(document.id)
name = f"{base}.md"
counter = 2
while name in used:
name = f"{base}-{counter}.md"
counter += 1
used.add(name)
return name
@router.get("/export")
async def export_documents(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> StreamingResponse:
"""The readable knowledge base as a ZIP of Markdown files with YAML
frontmatter. Permission-filtered by construction (`readable_documents_filter`
— an admin exports what they can read, anyone else the same); built-in help
pages are excluded (product content, not the company's knowledge). stdlib
only, streamed, no temp files."""
documents = (
(
await db.execute(
select(Document)
.where(readable_documents_filter(user), Document.is_builtin.is_(False))
.order_by(Document.title)
)
)
.scalars()
.all()
)
# Resolve department names once for the frontmatter: the owning department
# plus any shared grants, so an export records the full reach of a document.
dept_names = dict((await db.execute(select(Department.id, Department.name))).all())
shared: dict[uuid.UUID, list[str]] = {}
for doc_id, dept_id in (
await db.execute(select(DocPermission.document_id, DocPermission.department_id))
).all():
shared.setdefault(doc_id, []).append(dept_names.get(dept_id, ""))
buffer = io.BytesIO()
used: set[str] = set()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for document in documents:
departments = [
*(
[dept_names[document.department_id]]
if document.department_id in dept_names
else []
),
*sorted(shared.get(document.id, [])),
]
frontmatter = yaml.safe_dump(
{
"title": document.title,
"status": str(document.status),
"visibility": str(document.visibility),
"departments": departments,
},
allow_unicode=True,
sort_keys=False,
)
body = f"---\n{frontmatter}---\n\n{document.content_md.rstrip()}\n"
archive.writestr(_export_name(document, used), body)
buffer.seek(0)
return StreamingResponse(
iter([buffer.getvalue()]),
media_type="application/zip",
headers={"content-disposition": 'attachment; filename="pablan-export.zip"'},
)
@router.get("/stats")
async def document_stats(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentStats:
"""Is this a fresh install or a filled one? Read by the landing page's
first-run guide."""
published = Document.status == DocumentStatus.published
return DocumentStats(
documents_total=(
await db.execute(select(func.count(Document.id)).where(published))
).scalar_one(),
departments_total=(
await db.execute(select(func.count(Department.id)))
).scalar_one(),
)
+236
View File
@@ -0,0 +1,236 @@
"""The life of one document: open it, read it, change it, delete it.
Publishing does NOT live here — a draft becomes public through
`workflow.py`, so a content edit can never make a private draft readable by
accident. Archiving does, because it is the mirror of the `status` a PATCH
already carries.
"""
import uuid
from typing import Annotated, Any
from fastapi import Depends
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.documents.access import (
guard_self_lockout,
readable_document,
require_author_or_admin,
require_editor,
)
from app.api.documents.routing import documents_router
from app.api.documents.schemas import DocumentCreate, DocumentDetail, DocumentUpdate
from app.api.documents.view import detail, full_detail, granted_department_ids
from app.auth.deps import get_current_user
from app.authoring.context import summarize_conversation
from app.authoring.document import render_skeleton, render_title
from app.authoring.history import record_event
from app.authoring.schema import AuthoringTemplate
from app.db import get_db
from app.errors import ApiError
from app.ingestion.handlers import INDEX_DOCUMENT
from app.ingestion.queue import enqueue
from app.models import (
Conversation,
Document,
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
Template,
User,
)
router = documents_router()
@router.post("", status_code=201)
async def create_document(
body: DocumentCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Open a new document to write in.
A `draft` is the author's private working copy: `readable_documents_filter`
shows it to no one else (bar a colleague asked to check it) and only
`published` documents are indexed, so a draft never reaches another user
or an LLM prompt."""
title = body.title
content_md = ""
visibility = body.visibility or DocumentVisibility.department
meta: dict[str, Any] = {}
if body.template_id is not None:
row = await db.get(Template, body.template_id)
if row is None:
raise ApiError(404, "Template not found.", "not_found")
try:
template = AuthoringTemplate.model_validate(row.config)
except ValidationError:
raise ApiError(
422, "Template is not a valid authoring template.", "invalid_template"
) from None
content_md = render_skeleton(template)
meta = {"template": template.id}
if title is None:
title = render_title(template, user)
if body.visibility is None:
visibility = DocumentVisibility(template.metadata.visibility)
if not title:
raise ApiError(422, "A title or a template is required.", "title_required")
if body.conversation_id is not None:
meta.update(await _conversation_context(db, body.conversation_id, user))
document = Document(
title=title,
status=DocumentStatus.draft,
visibility=visibility,
content_md=content_md,
meta=meta,
author_id=user.id,
department_id=user.department_id,
# Marks the collection loaded — a brand-new document has no requests,
# and the serializer reads them without a session to lazy-load in.
reviews=[],
)
db.add(document)
# Flush so the event can reference document.id (the PK default is applied
# at flush, not at construction).
await db.flush()
record_event(db, document, user, DocumentEventAction.created, snapshot=True)
await db.commit()
return detail(document, user)
async def _conversation_context(
db: AsyncSession, conversation_id: uuid.UUID, user: User
) -> dict[str, Any]:
"""What the chat this capture started from was about, as background for
section refinement. Owner-scoped; a foreign or unknown conversation simply
contributes nothing."""
conversation = (
await db.execute(
select(Conversation)
.where(
Conversation.id == conversation_id,
Conversation.user_id == user.id,
)
.options(selectinload(Conversation.messages))
)
).scalar_one_or_none()
if conversation is None:
return {}
topic = await summarize_conversation(conversation)
if not topic:
return {}
return {"context": topic, "conversation_id": str(conversation.id)}
@router.get("/{document_id}")
async def get_document(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
document = await readable_document(db, document_id, user)
return await full_detail(db, document, user)
@router.patch("/{document_id}")
async def update_document(
document_id: uuid.UUID,
body: DocumentUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
document = await readable_document(db, document_id, user)
require_editor(document, user)
# Track the axes of change separately so the audit trail can name what
# happened (an edit vs. a visibility change vs. an archive), even though
# all three equally invalidate the denormalized chunk copy.
body_changed = False
if body.title is not None and body.title != document.title:
document.title = body.title
body_changed = True
if body.content_md is not None and body.content_md != document.content_md:
document.content_md = body.content_md
body_changed = True
visibility_changed = False
if body.visibility is not None and body.visibility != document.visibility:
# Who may READ this is the owner's decision, like sharing and deleting:
# a colleague asked to check the text may correct it, not re-address it.
require_author_or_admin(document, user)
# A visibility change can remove the editing user's own access (only an
# admin editing a document they do not own — an author keeps access).
guard_self_lockout(
user,
author_id=document.author_id,
visibility=body.visibility,
department_id=document.department_id,
granted_department_ids=await granted_department_ids(db, document.id),
confirm=bool(body.confirm_lockout),
)
document.visibility = body.visibility
visibility_changed = True # chunk meta carries a denormalized copy
if body.conversation_id is not None:
# Extending a document out of a chat: same background as a fresh
# capture. Metadata only — no event, and no reindex, because nothing
# a chunk carries changed.
document.meta = {
**document.meta,
**await _conversation_context(db, body.conversation_id, user),
}
archived = False
status_changed = False
if body.status is not None and body.status != document.status:
archivable = {DocumentStatus.published, DocumentStatus.archived}
if body.status not in archivable or document.status not in archivable:
# Publishing is its own endpoint: it indexes the document and is
# the author's decision, not a field on a content edit.
raise ApiError(
409,
"Only published documents can be archived (and vice versa).",
"invalid_status",
)
document.status = body.status
status_changed = True
archived = body.status == DocumentStatus.archived
# Audit: an edit snapshots the new Markdown so the version can be diffed; a
# visibility change or archive is a pure transition (no content snapshot).
if body_changed:
record_event(db, document, user, DocumentEventAction.edited, snapshot=True)
if visibility_changed:
record_event(db, document, user, DocumentEventAction.visibility_changed)
if archived:
record_event(db, document, user, DocumentEventAction.archived)
# Chunks are derivatives of the Markdown: published edits reindex, and
# archive/publish transitions add or remove the chunks.
content_changed = body_changed or visibility_changed
published = document.status == DocumentStatus.published
if (content_changed and published) or status_changed:
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
await db.commit()
return detail(document, user)
@router.delete("/{document_id}", status_code=204)
async def delete_document(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
await db.delete(document) # chunks cascade
await db.commit()
+112
View File
@@ -0,0 +1,112 @@
"""The audit trail: who changed a document, when, and what that change was.
Snapshots are written AFTER their event, so an entry's content is the state it
produced. Showing "what did this one do" therefore needs the pair (this
snapshot and the one before it), which is why the version endpoint returns both.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import readable_document
from app.api.documents.routing import documents_router
from app.api.documents.schemas import DocumentEventOut, DocumentVersion
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import DocumentEvent, User
router = documents_router()
# Newest first, with the id as tiebreaker: events written in one transaction
# share a timestamp, and only a total order can be paged or walked backwards.
_NEWEST_FIRST = (DocumentEvent.created_at.desc(), DocumentEvent.id.desc())
@router.get("/{document_id}/history")
async def document_history(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[DocumentEventOut]:
"""The document's audit trail, newest first: who changed or reviewed it,
when, and whether a content snapshot exists to diff against. Same read gate
as the document itself, so history never leaks to a user who cannot read the
document."""
document = await readable_document(db, document_id, user)
rows = (
await db.execute(
select(DocumentEvent, User.name)
.join(User, DocumentEvent.actor_id == User.id, isouter=True)
.where(DocumentEvent.document_id == document.id)
.order_by(*_NEWEST_FIRST)
)
).all()
return [
DocumentEventOut(
id=event.id,
action=event.action,
actor_id=event.actor_id,
actor_name=name,
visibility=event.visibility,
created_at=event.created_at,
has_snapshot=event.content_md is not None,
)
for event, name in rows
]
@router.get("/{document_id}/versions/{event_id}")
async def document_version(
document_id: uuid.UUID,
event_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentVersion:
"""A single past version's frozen content plus the content it replaced, so
the caller can show what this event changed. Same read gate as the
document."""
document = await readable_document(db, document_id, user)
row = (
await db.execute(
select(DocumentEvent, User.name)
.join(User, DocumentEvent.actor_id == User.id, isouter=True)
.where(
DocumentEvent.id == event_id,
DocumentEvent.document_id == document.id,
)
)
).first()
if row is None:
raise ApiError(404, "Version not found.", "not_found")
event, name = row
# The state this event started from: the closest earlier snapshot, in the
# same order the history list uses.
previous = (
await db.execute(
select(DocumentEvent.content_md)
.where(
DocumentEvent.document_id == document.id,
DocumentEvent.content_md.is_not(None),
tuple_(DocumentEvent.created_at, DocumentEvent.id)
< tuple_(event.created_at, event.id),
)
.order_by(*_NEWEST_FIRST)
.limit(1)
)
).scalar_one_or_none()
return DocumentVersion(
id=event.id,
action=event.action,
actor_id=event.actor_id,
actor_name=name,
created_at=event.created_at,
title=event.title,
content_md=event.content_md,
previous_content_md=previous,
visibility=event.visibility,
)
+13
View File
@@ -0,0 +1,13 @@
"""The one router constructor the package's modules share.
Every module builds its own `APIRouter` and `__init__` mounts them in the
order that matters. They cannot be prefix-less sub-routers: FastAPI refuses a
route whose path and router prefix are BOTH empty, which the browse list ("")
would be, so the prefix lives here rather than five times over.
"""
from fastapi import APIRouter
def documents_router() -> APIRouter:
return APIRouter(prefix="/documents", tags=["documents"])
+198
View File
@@ -0,0 +1,198 @@
"""Request and response shapes for the documents API.
Kept in one module because the whole package answers with the same handful of
document shapes: a summary in lists, a detail on a single document, and the
few command bodies that change one.
"""
import uuid
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AccessReason,
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
)
class DocumentSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
status: DocumentStatus
visibility: DocumentVisibility
department_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
# Why this user sees it and what they may do — so the UI can explain
# access instead of leaving the rules implicit.
access_reason: AccessReason
can_edit: bool
# Unanswered questions about this document. A published document with an
# open question is readable but not settled, and every surface that shows
# the document says so — including the sources under a chat answer.
open_reviews: int
# Shipped with the product: read-only, and never deletable.
is_builtin: bool
class DepartmentRef(BaseModel):
id: uuid.UUID
name: str
class ReviewOut(BaseModel):
"""One request to check this document. Open while `resolved_at` is null."""
id: uuid.UUID
question: str | None
requester_name: str | None
reviewer_id: uuid.UUID | None
reviewer_name: str | None
created_at: datetime
resolved_at: datetime | None
resolved_by_name: str | None
# Whether the caller is the one being asked, so the UI can offer the
# answer rather than just showing the question.
is_mine: bool
class DocumentDetail(DocumentSummary):
content_md: str
# Every request on this document, oldest first, open and answered — the
# answered ones are the record of what was already checked.
reviews: list[ReviewOut] = []
# Additional departments the document is shared with, on top of its owning
# `department_id` (the `doc_permissions` grants). Resolved where the endpoint
# looks it up (get_document, the departments endpoint).
shared_departments: list[DepartmentRef] = []
class DocumentSort(StrEnum):
"""How the browse list is ordered. Deliberately two options: "what
changed" and "what is new" are the two questions people actually ask of
a document list."""
updated = "updated"
created = "created"
class DocumentPage(BaseModel):
items: list[DocumentSummary]
# Total matching the filters, not the page — the UI needs it to know
# whether there is a next page at all.
total: int
per_page: int
class DocumentSearchHit(DocumentSummary):
"""A search result: the document plus the section that matched.
Empty `heading_path` means the match was on the title, not a section.
"""
heading_path: str = ""
class DocumentStats(BaseModel):
"""Company-wide counts, read by the landing page's first-run guide.
Aggregates only — no titles, no per-user data. Deliberately not
permission-filtered: a bare count reveals nothing about content.
"""
documents_total: int
departments_total: int
class DocumentCreate(BaseModel):
"""Start a new document the user will write in the editor.
With a `template_id` the draft opens on that template's Markdown skeleton
and title; without one it starts blank and `title` is required. The result
is a `draft` — author-only and never indexed until it is published."""
template_id: uuid.UUID | None = None
title: str | None = None
visibility: DocumentVisibility | None = None
# When the capture started from a chat: its subject is summarized and kept
# on the draft as background for section refinement.
conversation_id: uuid.UUID | None = None
class DocumentUpdate(BaseModel):
title: str | None = None
content_md: str | None = None
visibility: DocumentVisibility | None = None
# Only the archive transition is settable here; publishing has its own
# endpoint, because it indexes the document.
status: DocumentStatus | None = None
# An admin may knowingly make a change that removes their own access; an
# author never can (they keep access as author). See access.guard_self_lockout.
# Nullable (not `bool = False`) so it stays optional in the generated client.
confirm_lockout: bool | None = None
# Continuing an EXISTING document out of a chat: the same background the
# create path attaches, for the document that already covers the topic.
conversation_id: uuid.UUID | None = None
class DocumentDepartments(BaseModel):
"""The full set of ADDITIONAL departments the document is shared with (on
top of the owning department) — replaces the existing grants."""
department_ids: list[uuid.UUID]
confirm_lockout: bool | None = None
class ReviewerCandidate(BaseModel):
"""A user the author may ask to check a document — id + name only."""
id: uuid.UUID
name: str
class ReviewRequestBody(BaseModel):
"""Ask someone to check this document, optionally about something specific
("do the holiday numbers still hold?")."""
reviewer_id: uuid.UUID
question: str | None = Field(default=None, max_length=2000)
class DocumentEventOut(BaseModel):
"""One entry in a document's history timeline — metadata only."""
id: uuid.UUID
action: DocumentEventAction
actor_id: uuid.UUID | None
# Null once the actor's account is deleted (SET NULL on the event).
actor_name: str | None
visibility: DocumentVisibility | None
created_at: datetime
# A content snapshot exists for this event and can be fetched for diffing.
has_snapshot: bool
class DocumentVersion(BaseModel):
"""A past version's frozen content, for viewing or diffing.
A snapshot is taken *after* its event, so `content_md` is the state this
event produced and `previous_content_md` the state it started from — the
pair is what "what did this change do?" needs. `previous_content_md` is
null for the first snapshot, where everything was added.
"""
id: uuid.UUID
action: DocumentEventAction
actor_id: uuid.UUID | None
actor_name: str | None
created_at: datetime
title: str | None
content_md: str | None
previous_content_md: str | None
visibility: DocumentVisibility | None
+86
View File
@@ -0,0 +1,86 @@
"""Sharing a document with departments beyond its own.
Management, not new permission logic: the read filter's EXISTS branch already
unions `doc_permissions` in, so this endpoint only maintains those rows. Grants
are evaluated live against the table, which is why nothing is reindexed here.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import (
guard_self_lockout,
readable_document,
require_author_or_admin,
)
from app.api.documents.routing import documents_router
from app.api.documents.schemas import DocumentDepartments, DocumentDetail
from app.api.documents.view import full_detail, granted_department_ids
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import Department, DocPermission, PermissionLevel, User
router = documents_router()
@router.put("/{document_id}/departments")
async def set_shared_departments(
document_id: uuid.UUID,
body: DocumentDepartments,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Replace the full set of ADDITIONAL departments this document is shared
with. Author or admin only."""
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
requested = set(body.department_ids)
# A document is never "shared with" its own owning department.
requested.discard(document.department_id)
if requested:
found = set(
(
await db.execute(
select(Department.id).where(Department.id.in_(requested))
)
)
.scalars()
.all()
)
if requested - found:
raise ApiError(404, "One or more departments do not exist.", "not_found")
# Removing a grant can drop the editing admin's own department access.
guard_self_lockout(
user,
author_id=document.author_id,
visibility=document.visibility,
department_id=document.department_id,
granted_department_ids=requested,
confirm=bool(body.confirm_lockout),
)
existing = await granted_department_ids(db, document.id)
for dept_id in existing - requested:
await db.execute(
delete(DocPermission).where(
DocPermission.document_id == document.id,
DocPermission.department_id == dept_id,
)
)
for dept_id in requested - existing:
db.add(
DocPermission(
document_id=document.id,
department_id=dept_id,
level=PermissionLevel.read,
)
)
await db.commit()
return await full_detail(db, document, user)
+158
View File
@@ -0,0 +1,158 @@
"""Document rows to API shapes.
Every endpoint in the package answers with `summary` or `detail`, so the
per-request fields (why this user sees it, what they may do, what is still
open) are computed in exactly one place.
"""
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import access_reason, can_edit
from app.api.documents.schemas import (
DepartmentRef,
DocumentDetail,
DocumentSummary,
ReviewOut,
)
from app.models import Department, DocPermission, Document, ReviewRequest, User
def document_fields(document: Document, user: User) -> dict[str, Any]:
"""Built explicitly rather than via model_validate: access_reason and
can_edit are per-request, so there is nothing to read them from."""
return {
"id": document.id,
"title": document.title,
"status": document.status,
"visibility": document.visibility,
"department_id": document.department_id,
"created_at": document.created_at,
"updated_at": document.updated_at,
"access_reason": access_reason(document, user),
"can_edit": can_edit(document, user),
"open_reviews": len(document.open_reviews),
"is_builtin": document.is_builtin,
}
def summary(document: Document, user: User) -> DocumentSummary:
return DocumentSummary(**document_fields(document, user))
def detail(
document: Document,
user: User,
*,
reviews: list[ReviewOut] | None = None,
shared_departments: list[DepartmentRef] | None = None,
) -> DocumentDetail:
return DocumentDetail(
**document_fields(document, user),
content_md=document.content_md,
reviews=reviews or [],
shared_departments=shared_departments or [],
)
async def resolve_reviews(
db: AsyncSession, document: Document, user: User
) -> list[ReviewOut]:
"""The document's requests with the names filled in.
One query for every name involved, rather than three relationships loaded
with every document: the names are needed on the detail page only, while
the requests themselves ride along everywhere (they decide who may edit).
"""
if not document.reviews:
return []
wanted = {
person_id
for review in document.reviews
for person_id in (
review.requester_id,
review.reviewer_id,
review.resolved_by_id,
)
if person_id is not None
}
names = dict(
(await db.execute(select(User.id, User.name).where(User.id.in_(wanted)))).all()
)
return [
ReviewOut(
id=review.id,
question=review.question,
requester_name=names.get(review.requester_id),
reviewer_id=review.reviewer_id,
reviewer_name=names.get(review.reviewer_id),
created_at=review.created_at,
resolved_at=review.resolved_at,
resolved_by_name=names.get(review.resolved_by_id),
is_mine=review.reviewer_id == user.id,
)
for review in document.reviews
]
async def resolve_shared_departments(
db: AsyncSession, document: Document
) -> list[DepartmentRef]:
"""The additional departments this document is shared with (its
`doc_permissions` grants), resolved to names for display."""
rows = (
await db.execute(
select(Department.id, Department.name)
.join(DocPermission, DocPermission.department_id == Department.id)
.where(DocPermission.document_id == document.id)
.order_by(Department.name)
)
).all()
return [DepartmentRef(id=row.id, name=row.name) for row in rows]
async def granted_department_ids(
db: AsyncSession, document_id: uuid.UUID
) -> set[uuid.UUID]:
return set(
(
await db.execute(
select(DocPermission.department_id).where(
DocPermission.document_id == document_id
)
)
)
.scalars()
.all()
)
async def full_detail(
db: AsyncSession, document: Document, user: User
) -> DocumentDetail:
"""The detail with everything resolved — for the endpoints that answer
with a document the UI is about to render in full."""
return detail(
document,
user,
reviews=await resolve_reviews(db, document, user),
shared_departments=await resolve_shared_departments(db, document),
)
async def open_review_for(
db: AsyncSession, document: Document, reviewer_id: uuid.UUID
) -> ReviewRequest | None:
"""An unanswered request on this document addressed to `reviewer_id`."""
return (
await db.execute(
select(ReviewRequest).where(
ReviewRequest.document_id == document.id,
ReviewRequest.reviewer_id == reviewer_id,
ReviewRequest.resolved_at.is_(None),
)
)
).scalar_one_or_none()
+174
View File
@@ -0,0 +1,174 @@
"""From draft to published, and the questions that hang off a document.
Two things that used to be one. **Publishing** is the author's own decision: a
draft is private until they say it is worth reading, one action, no waiting.
**A review request** is "please check this", and it is not a status — it can
sit on a draft the author is unsure about OR on a document that has been
published for months, and it marks the document wherever it appears until
someone answers it.
Being asked is what grants the right to edit: a reviewer who spots a wrong
number should fix it rather than file a second question about it.
"""
import uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import (
readable_document,
require_author_or_admin,
require_editor,
)
from app.api.documents.routing import documents_router
from app.api.documents.schemas import (
DocumentDetail,
ReviewerCandidate,
ReviewRequestBody,
)
from app.api.documents.view import full_detail
from app.auth.deps import get_current_user
from app.authoring.history import record_event
from app.db import get_db
from app.errors import ApiError
from app.ingestion.handlers import INDEX_DOCUMENT
from app.ingestion.queue import enqueue
from app.models import DocumentEventAction, DocumentStatus, ReviewRequest, User
from app.rag.permissions import document_reader_filter
router = documents_router()
@router.post("/{document_id}/publish")
async def publish_document(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Make a draft readable and searchable for everyone its visibility allows.
The author's own call — an open question about the content does not block
it, it travels with the document instead (`open_reviews`), which is what
lets a colleague read it AND know it is not settled.
Author or admin, deliberately not every editor: a colleague asked to check
a draft may fix what is wrong in it, but whether the company gets to read
it at all is not their call.
"""
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
if document.status != DocumentStatus.draft:
raise ApiError(409, "Only a draft can be published.", "invalid_status")
document.status = DocumentStatus.published
record_event(db, document, user, DocumentEventAction.published)
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
await db.commit()
return await full_detail(db, document, user)
@router.get("/{document_id}/reviewers")
async def list_reviewers(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[ReviewerCandidate]:
"""Who can be asked: everyone who could read this document once published,
minus the author. Permission-safe and non-admin (unlike /admin/users), and
only id + name leave the server."""
document = await readable_document(db, document_id, user)
require_editor(document, user)
rows = (
await db.execute(
select(User.id, User.name)
.where(document_reader_filter(document), User.id != document.author_id)
.order_by(User.name)
)
).all()
return [ReviewerCandidate(id=row.id, name=row.name) for row in rows]
@router.post("/{document_id}/reviews")
async def request_review(
document_id: uuid.UUID,
body: ReviewRequestBody,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Ask a colleague to check this document, optionally about something
specific. The request grants them the right to read and edit it until it
is answered."""
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
if body.reviewer_id == user.id:
raise ApiError(422, "You cannot ask yourself.", "invalid_reviewer")
allowed = (
await db.execute(
select(User.id).where(
User.id == body.reviewer_id,
document_reader_filter(document),
)
)
).scalar_one_or_none()
if allowed is None:
raise ApiError(
422, "That user cannot review this document.", "invalid_reviewer"
)
if any(review.reviewer_id == body.reviewer_id for review in document.open_reviews):
raise ApiError(
409, "That colleague has already been asked.", "review_already_open"
)
db.add(
ReviewRequest(
document_id=document.id,
requester_id=user.id,
reviewer_id=body.reviewer_id,
question=(body.question or "").strip() or None,
)
)
record_event(db, document, user, DocumentEventAction.review_requested)
await db.commit()
# Reload the collection, not just the columns: the serializer reads the
# requests, and a lazy load there would be IO in a sync property.
await db.refresh(document, attribute_names=["reviews"])
return await full_detail(db, document, user)
@router.post("/{document_id}/reviews/{review_id}/resolve")
async def resolve_review(
document_id: uuid.UUID,
review_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Answer a request: the content was checked.
The reviewer answers their own request; the author (or an admin) can close
one that has become moot, because a question nobody will answer should not
mark a document forever.
"""
document = await readable_document(db, document_id, user)
review = next(
(review for review in document.reviews if review.id == review_id), None
)
if review is None:
raise ApiError(404, "Review request not found.", "not_found")
if review.resolved_at is not None:
raise ApiError(409, "This request is already answered.", "already_resolved")
if review.reviewer_id != user.id:
require_author_or_admin(document, user)
review.resolved_at = datetime.now(UTC)
review.resolved_by_id = user.id
record_event(db, document, user, DocumentEventAction.review_resolved)
await db.commit()
# Reload the collection, not just the columns: the serializer reads the
# requests, and a lazy load there would be IO in a sync property.
await db.refresh(document, attribute_names=["reviews"])
return await full_detail(db, document, user)