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