"""Paper endpoints (论文) — the writing surface of the application. The shape of the API follows the shape of the work: * ``/papers`` is the library: create, list, edit, delete. * ``/papers/{id}/document`` is the paper as it reads — the template's paragraphs in order, each with the sentences stored at its position. It is one request, because a client should never have to stitch the structure and the content together and risk ordering them differently than the server does. * ``/papers/{id}/paragraphs/{sort}`` is the writer's unit of work: read one paragraph, write it back whole. Two rules are enforced here rather than in the schema, because they reference rows in other tables: a paper may only point at a template that exists, and a template may not be deleted while a paper is written against it. """ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from app.crud import paper as crud from app.db.session import get_db from app.models import Paper, PaperSentence, PaperTemplate from app.schemas.common import BatchDeleteRequest, BatchDeleteResult, PageResult from app.schemas.paper import ( PaperCreate, PaperDocumentRead, PaperListItem, PaperRead, PaperUpdate, ParagraphListRead, ParagraphUpdate, SentenceCreate, SentenceRead, SentenceUpdate, ) router = APIRouter(prefix="/papers", tags=["papers"]) def _get_or_404(db: Session, paper_id: int) -> Paper: paper = crud.get(db, paper_id) if paper is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"论文 {paper_id} 不存在", ) return paper def _get_sentence_or_404(db: Session, paper: Paper, sentence_id: int) -> PaperSentence: """Find a sentence *within* this paper. Scoping the lookup to the paper is what stops a sentence id from one paper being edited through another paper's URL. """ for sentence in paper.sentences: if sentence.id == sentence_id: return sentence raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"句子 {sentence_id} 不属于论文 {paper.id}", ) def _assert_template_exists(db: Session, template_id: int | None) -> None: """Reject a paper pointing at a template that is not there. Checked in Python rather than by a foreign key, because TiDB parses but does not enforce ``FOREIGN KEY`` — an unchecked write would happily leave a paper with no structure and no way to notice. """ if template_id is None: return if db.get(PaperTemplate, template_id) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"模板 {template_id} 不存在", ) @router.get("", response_model=PageResult[PaperListItem], summary="List papers") def list_papers( db: Session = Depends(get_db), keyword: str | None = Query(default=None, description="按标题、作者或关键词模糊搜索"), status_filter: str | None = Query( default=None, alias="status", description="按状态过滤:draft / writing / done", ), template_id: int | None = Query(default=None, description="按所用模板过滤"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=200), ) -> PageResult[PaperListItem]: """Browse papers, most recently edited first.""" items, total = crud.list_papers( db, keyword=keyword, status=status_filter, template_id=template_id, page=page, page_size=page_size, ) return PageResult.build(items=items, total=total, page=page, page_size=page_size) @router.post( "", response_model=PaperRead, status_code=status.HTTP_201_CREATED, summary="Create a paper", ) def create_paper(payload: PaperCreate, db: Session = Depends(get_db)) -> PaperRead: """Create a paper against a template. Nothing is written into the sentence table: the outline is the template's, read live on every render, so a new paper is already the right shape with every paragraph empty. """ _assert_template_exists(db, payload.template_id) return crud.read(crud.create(db, payload)) @router.post( "/batch-delete", response_model=BatchDeleteResult, summary="Delete several papers", ) def batch_delete_papers( payload: BatchDeleteRequest, db: Session = Depends(get_db), ) -> BatchDeleteResult: """Delete the given papers, with all of their sentences and citations.""" return BatchDeleteResult(deleted=crud.delete_many(db, payload.ids)) @router.get("/{paper_id}", response_model=PaperRead, summary="Fetch one paper") def get_paper(paper_id: int, db: Session = Depends(get_db)) -> PaperRead: """Return a paper's metadata and its written/defined paragraph counts.""" return crud.read(_get_or_404(db, paper_id)) @router.get( "/{paper_id}/document", response_model=PaperDocumentRead, summary="Fetch one paper as a document", ) def get_paper_document( paper_id: int, db: Session = Depends(get_db) ) -> PaperDocumentRead: """Return the whole paper: headings, sentences, citations, warnings. Paragraphs arrive in ascending position order. Positions the template does not define are included when content exists there, with ``matched: false`` and no heading — the client renders those as 未设定 rather than dropping them. """ return crud.build_document(db, _get_or_404(db, paper_id)) @router.patch("/{paper_id}", response_model=PaperRead, summary="Update a paper") def update_paper( paper_id: int, payload: PaperUpdate, db: Session = Depends(get_db), ) -> PaperRead: """Update the paper's metadata, including which template it uses. Changing ``template_id`` is the template switch. Sentences keep their positions and gain the new template id, so the same content reappears under the new template's headings — and switching back restores the old layout exactly. """ paper = _get_or_404(db, paper_id) if "template_id" in payload.model_fields_set: _assert_template_exists(db, payload.template_id) # Only the keys the client actually sent are applied: an omitted field must # not be mistaken for an explicit null. values = payload.model_dump(exclude_unset=True) template_changed = ( "template_id" in values and values["template_id"] != paper.template_id ) return crud.read(crud.update(db, paper, values=values, template_changed=template_changed)) @router.delete( "/{paper_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a paper", ) def delete_paper(paper_id: int, db: Session = Depends(get_db)) -> Response: """Delete a paper with its sentences and citations.""" crud.delete(db, _get_or_404(db, paper_id)) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get( "/{paper_id}/paragraphs/{field_sort}", response_model=ParagraphListRead, summary="Read one paragraph", ) def get_paragraph( paper_id: int, field_sort: int, db: Session = Depends(get_db), ) -> ParagraphListRead: """Return one paragraph, assembled exactly as the document renders it. A position that neither the template nor the content knows about is a 404: there is no paragraph there to edit. """ paper = _get_or_404(db, paper_id) paragraph = crud.get_paragraph(db, paper, field_sort) if paragraph is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"论文 {paper_id} 没有第 {field_sort} 段", ) return ParagraphListRead( paper_id=paper.id, paper_title=paper.title, template_id=paper.template_id, paragraph=paragraph, ) @router.put( "/{paper_id}/paragraphs/{field_sort}", response_model=PaperDocumentRead, summary="Replace one paragraph", ) def replace_paragraph( paper_id: int, field_sort: int, payload: ParagraphUpdate, db: Session = Depends(get_db), ) -> PaperDocumentRead: """Write a paragraph whole, and return the refreshed document. The whole document comes back so the reader view updates in place: after a move, the paragraph the editor had open no longer exists at that position, and re-deriving the page from a fresh document is simpler than patching a stale copy. """ paper = _get_or_404(db, paper_id) crud.replace_paragraph(db, paper, field_sort, payload) return crud.build_document(db, paper) @router.post( "/{paper_id}/sentences", response_model=SentenceRead, status_code=status.HTTP_201_CREATED, summary="Append one sentence", ) def create_sentence( paper_id: int, payload: SentenceCreate, db: Session = Depends(get_db), ) -> SentenceRead: """Add a single sentence to a paragraph, at the end unless told otherwise. The paragraph does not have to exist in the template: a position the template does not define is exactly the case the renderer already handles. """ paper = _get_or_404(db, paper_id) return SentenceRead.model_validate(crud.append_sentence(db, paper, payload)) @router.patch( "/{paper_id}/sentences/{sentence_id}", response_model=SentenceRead, summary="Update one sentence", ) def update_sentence( paper_id: int, sentence_id: int, payload: SentenceUpdate, db: Session = Depends(get_db), ) -> SentenceRead: """Edit one sentence's text, its position, or its citations.""" paper = _get_or_404(db, paper_id) sentence = _get_sentence_or_404(db, paper, sentence_id) updated = crud.update_sentence(db, paper, sentence, payload) return SentenceRead.model_validate(updated) @router.delete( "/{paper_id}/sentences/{sentence_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete one sentence", ) def delete_sentence( paper_id: int, sentence_id: int, db: Session = Depends(get_db), ) -> Response: """Delete one sentence and the citations attached to it.""" paper = _get_or_404(db, paper_id) crud.delete_sentence(db, paper, _get_sentence_or_404(db, paper, sentence_id)) return Response(status_code=status.HTTP_204_NO_CONTENT)