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:
@@ -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"]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user