Files
pablan/backend/app/api/documents/workflow.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

175 lines
6.6 KiB
Python

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