Files
pablan/backend/app/api/documents/crud.py
T
ProfessorNovaandClaude Opus 5 97dbff309c 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
2026-09-04 08:36:17 +02:00

237 lines
8.9 KiB
Python

"""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()