feat(documents): комментарии к документу и comments_count в карточке (D-11)
POST/GET /documents/{id}/comments, счётчик комментариев в карточке файла
и документа одним запросом на пачку документов.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
3f744b7eb9
commit
c9bd620a18
@ -2,10 +2,11 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints.files import documents, read, statements, upload
|
||||
from src.api.endpoints.files import comments, documents, read, statements, upload
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
router.include_router(upload.router)
|
||||
router.include_router(read.router)
|
||||
router.include_router(documents.router)
|
||||
router.include_router(comments.router)
|
||||
router.include_router(statements.router)
|
||||
|
||||
46
api/src/api/endpoints/files/comments.py
Normal file
46
api/src/api/endpoints/files/comments.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""`/documents/{document_id}/comments` — комментарии к документу (spec documents-api «Комментарии к документу»; D-11).
|
||||
|
||||
Чтение — `DOCUMENT_VIEW`, добавление — `DOCUMENT_EDIT`; документ ЮЛ вне видимости — тот же 404, что несуществующий.
|
||||
Правки и удаления комментариев нет; аудит комментариев не пишется (D-11).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.files import DocumentServiceDep
|
||||
from src.schemas.comments import CommentIn, CommentOut
|
||||
from src.schemas.common import ErrorDetail
|
||||
|
||||
router = APIRouter(prefix="/documents")
|
||||
|
||||
NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{document_id}/comments",
|
||||
response_model=list[CommentOut],
|
||||
dependencies=[Depends(require("DOCUMENT_VIEW"))],
|
||||
responses=NOT_FOUND,
|
||||
summary="Комментарии к документу",
|
||||
)
|
||||
async def list_comments(document_id: uuid.UUID, service: DocumentServiceDep) -> list[CommentOut]:
|
||||
"""Комментарии по возрастанию `created_at` с ФИО автора."""
|
||||
return await service.comments(document_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{document_id}/comments",
|
||||
response_model=CommentOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require("DOCUMENT_EDIT"))],
|
||||
responses=NOT_FOUND,
|
||||
summary="Добавить комментарий к документу",
|
||||
)
|
||||
async def add_comment(document_id: uuid.UUID, body: CommentIn, service: DocumentServiceDep) -> CommentOut:
|
||||
"""Комментарий к документу в любом статусе (в том числе файла `SUPERSEDED`); автор — пользователь запроса."""
|
||||
return await service.add_comment(document_id, body)
|
||||
@ -16,11 +16,15 @@ from sqlalchemy import Select, and_, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domain.policy import VisibilityScope, legal_entities_visible
|
||||
from src.models.access import AppUser
|
||||
from src.models.audit_ex import Comment
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import FileStatus, S3BucketRole
|
||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
|
||||
COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа
|
||||
|
||||
|
||||
def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
|
||||
"""Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`)."""
|
||||
@ -91,6 +95,29 @@ async def documents_by_file(session: AsyncSession, file_ids: Sequence[uuid.UUID]
|
||||
return grouped
|
||||
|
||||
|
||||
async def comments_count_by_document(session: AsyncSession, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
"""Число комментариев документов одним запросом: `document_id → count` (документы без комментариев отсутствуют)."""
|
||||
if not document_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(Comment.object_id, func.count(Comment.id))
|
||||
.where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id.in_(document_ids))
|
||||
.group_by(Comment.object_id)
|
||||
)
|
||||
return {document_id: count for document_id, count in (await session.execute(stmt)).all()}
|
||||
|
||||
|
||||
async def list_comments(session: AsyncSession, document_id: uuid.UUID) -> list[tuple[Comment, str]]:
|
||||
"""Комментарии документа по `created_at` (затем `id`) вместе с ФИО автора."""
|
||||
stmt = (
|
||||
select(Comment, AppUser.full_name)
|
||||
.join(AppUser, AppUser.id == Comment.author_id)
|
||||
.where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id == document_id)
|
||||
.order_by(Comment.created_at, Comment.id)
|
||||
)
|
||||
return [(comment, full_name) for comment, full_name in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def get_document(session: AsyncSession, scope: VisibilityScope, document_id: uuid.UUID) -> Document | None:
|
||||
"""Документ по id; `None` и для несуществующего, и для документа ЮЛ вне видимости."""
|
||||
stmt = _visible_documents(select(Document).where(Document.id == document_id), scope)
|
||||
|
||||
32
api/src/schemas/comments.py
Normal file
32
api/src/schemas/comments.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Схемы комментариев к документу (spec documents-api «Комментарии к документу»; design D-11)."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
MAX_COMMENT_LENGTH = 4000
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
"""Тело комментария: после обрезки пробелов 1–4000 символов; пустой текст и слишком длинный — 422."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
text: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
|
||||
|
||||
|
||||
class CommentAuthorOut(BaseModel):
|
||||
"""Автор комментария."""
|
||||
|
||||
id: uuid.UUID
|
||||
full_name: str
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
"""Комментарий: текст, автор и время создания."""
|
||||
|
||||
id: uuid.UUID
|
||||
text: str
|
||||
author: CommentAuthorOut
|
||||
created_at: datetime.datetime
|
||||
@ -53,6 +53,7 @@ class DocumentOut(BaseModel):
|
||||
confirmed_at: datetime.datetime | None
|
||||
group_schema: list[GroupOut] | None
|
||||
warnings: list[str]
|
||||
comments_count: int = 0 # число комментариев документа (D-11)
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
|
||||
@ -76,6 +76,7 @@ class DocumentBrief(BaseModel):
|
||||
period_check: DocumentCheckResult | None
|
||||
counterparty_check: DocumentCheckResult | None
|
||||
locator: str | None
|
||||
comments_count: int = 0 # число комментариев документа (D-11); в ORM-модели поля нет — задаётся при сборке
|
||||
|
||||
|
||||
class FileOut(BaseModel):
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -20,12 +20,15 @@ from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, NotFoundError
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.access import AppUser
|
||||
from src.models.audit_ex import Comment
|
||||
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus
|
||||
from src.models.files import Document, RecognitionJob, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
from src.models.statements import FinancialStatementMeta
|
||||
from src.repositories import files as files_repo
|
||||
from src.repositories import statements as statements_repo
|
||||
from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut
|
||||
from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut
|
||||
from src.schemas.files import DocumentBrief, FileOut, JobBrief
|
||||
from src.services import consolidation_documents
|
||||
@ -67,8 +70,17 @@ def job_brief(job: RecognitionJob) -> JobBrief:
|
||||
)
|
||||
|
||||
|
||||
def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[Document]) -> FileOut:
|
||||
"""Карточка файла (spec documents-api «Файлы организации и карточка файла»)."""
|
||||
def file_out(
|
||||
file: StoredFile,
|
||||
job: RecognitionJob | None,
|
||||
documents: Sequence[Document],
|
||||
counts: Mapping[uuid.UUID, int] | None = None,
|
||||
) -> FileOut:
|
||||
"""Карточка файла (spec documents-api «Файлы организации и карточка файла»).
|
||||
|
||||
`counts` — `document_id → число комментариев` (`files_repo.comments_count_by_document`); нет — нули.
|
||||
"""
|
||||
counts = counts or {}
|
||||
return FileOut(
|
||||
id=file.id,
|
||||
legal_entity_id=file.legal_entity_id,
|
||||
@ -82,7 +94,10 @@ def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[D
|
||||
uploaded_by=file.uploaded_by,
|
||||
superseded_by_file_id=file.superseded_by_file_id,
|
||||
job=job_brief(job) if job is not None else None,
|
||||
documents=[DocumentBrief.model_validate(document) for document in documents],
|
||||
documents=[
|
||||
DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)})
|
||||
for document in documents
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -97,8 +112,9 @@ def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[Group
|
||||
return None
|
||||
|
||||
|
||||
def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut:
|
||||
"""Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга)."""
|
||||
def document_out(document: Document, job: RecognitionJob | None, comments_count: int = 0) -> DocumentOut:
|
||||
"""Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга),
|
||||
`comments_count` — число комментариев документа."""
|
||||
return DocumentOut(
|
||||
id=document.id,
|
||||
file_id=document.stored_file_id,
|
||||
@ -117,6 +133,16 @@ def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut:
|
||||
group_schema=_group_schema(job, document.locator),
|
||||
# ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла
|
||||
warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)),
|
||||
comments_count=comments_count,
|
||||
)
|
||||
|
||||
|
||||
def _comment_out(comment: Comment, full_name: str) -> CommentOut:
|
||||
return CommentOut(
|
||||
id=comment.id,
|
||||
text=comment.text,
|
||||
author=CommentAuthorOut(id=comment.author_id, full_name=full_name),
|
||||
created_at=comment.created_at,
|
||||
)
|
||||
|
||||
|
||||
@ -139,11 +165,38 @@ class DocumentService:
|
||||
return None
|
||||
return await self._session.get(RecognitionJob, file.current_recognition_job_id)
|
||||
|
||||
async def _comments_count(self, document_id: uuid.UUID) -> int:
|
||||
counts = await files_repo.comments_count_by_document(self._session, [document_id])
|
||||
return counts.get(document_id, 0)
|
||||
|
||||
async def comments(self, document_id: uuid.UUID) -> list[CommentOut]:
|
||||
"""Комментарии видимого документа по `created_at` с ФИО автора; иначе 404."""
|
||||
document = await self._visible(document_id)
|
||||
return [
|
||||
_comment_out(comment, full_name)
|
||||
for comment, full_name in await files_repo.list_comments(self._session, document.id)
|
||||
]
|
||||
|
||||
async def add_comment(self, document_id: uuid.UUID, body: CommentIn) -> CommentOut:
|
||||
"""Комментарий к видимому документу в любом статусе, в том числе файла `SUPERSEDED`; аудит не пишется (D-11)."""
|
||||
document = await self._visible(document_id)
|
||||
comment = Comment(
|
||||
object_type=files_repo.COMMENT_OBJECT_TYPE,
|
||||
object_id=document.id,
|
||||
text=body.text,
|
||||
author_id=self._audit.user_id,
|
||||
created_at=utc_now(),
|
||||
)
|
||||
self._session.add(comment)
|
||||
await self._session.flush()
|
||||
author = await self._session.get(AppUser, comment.author_id)
|
||||
return _comment_out(comment, author.full_name if author is not None else "")
|
||||
|
||||
async def get(self, document_id: uuid.UUID) -> DocumentOut:
|
||||
"""Карточка видимого документа; иначе 404."""
|
||||
document = await self._visible(document_id)
|
||||
file = await self._session.get(StoredFile, document.stored_file_id)
|
||||
return document_out(document, await self._current_job(file))
|
||||
return document_out(document, await self._current_job(file), await self._comments_count(document.id))
|
||||
|
||||
async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]:
|
||||
"""Страница строк видимого документа по `row_number`; иначе 404."""
|
||||
@ -230,4 +283,4 @@ class DocumentService:
|
||||
before_value={"status": before.value},
|
||||
after_value={"status": DocumentStatus.CONFIRMED.value},
|
||||
)
|
||||
return document_out(document, await self._current_job(file))
|
||||
return document_out(document, await self._current_job(file), await self._comments_count(document.id))
|
||||
|
||||
@ -262,7 +262,8 @@ class PrimaryFilesService:
|
||||
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
|
||||
)
|
||||
current = next((job for job in jobs if job.id == file.current_recognition_job_id), None)
|
||||
before = file_out(file, current, documents).model_dump(mode="json")
|
||||
counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents])
|
||||
before = file_out(file, current, documents, counts).model_dump(mode="json")
|
||||
object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)]
|
||||
await files_repo.delete_file_rows(self._session, file)
|
||||
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
|
||||
@ -282,7 +283,10 @@ class PrimaryFilesService:
|
||||
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
|
||||
jobs = await files_repo.current_jobs(self._session, files)
|
||||
documents = await files_repo.documents_by_file(self._session, [file.id for file in files])
|
||||
return [file_out(file, jobs.get(file.id), documents.get(file.id, [])) for file in files]
|
||||
counts = await files_repo.comments_count_by_document(
|
||||
self._session, [document.id for group in documents.values() for document in group]
|
||||
)
|
||||
return [file_out(file, jobs.get(file.id), documents.get(file.id, []), counts) for file in files]
|
||||
|
||||
async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None:
|
||||
"""Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому»."""
|
||||
|
||||
67
api/tests/test_api_comments.py
Normal file
67
api/tests/test_api_comments.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Комментарии к документу (spec documents-api «Комментарии к документу», «Файлы организации…»; D-11)."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.enums import DocumentStatus, FileStatus, RecognitionStatus
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_document, make_stored_file
|
||||
from tests.test_api_documents import KA_RF, RKA, _rostov_entity
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_UNPROCESSABLE = 422
|
||||
COMMENTS_TOTAL = 2
|
||||
|
||||
|
||||
async def _doc(session: AsyncSession, status: DocumentStatus = DocumentStatus.CONFIRMED):
|
||||
entity = await _rostov_entity(session, name="ООО Комментарии")
|
||||
file, job = await make_stored_file(
|
||||
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
|
||||
)
|
||||
return file, await make_document(session, file=file, job=job, status=status)
|
||||
|
||||
|
||||
async def test_add_and_list(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
file, document = await _doc(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
created = await client.post(f"/documents/{document.id}/comments", json={"text": " нет подписи руководителя "})
|
||||
assert created.status_code == HTTP_CREATED, created.text
|
||||
assert created.json()["text"] == "нет подписи руководителя"
|
||||
assert created.json()["author"]["full_name"]
|
||||
await client.post(f"/documents/{document.id}/comments", json={"text": "второй"})
|
||||
listed = (await client.get(f"/documents/{document.id}/comments")).json()
|
||||
assert [item["text"] for item in listed] == ["нет подписи руководителя", "второй"]
|
||||
card = (await client.get(f"/files/{file.id}")).json()
|
||||
assert card["documents"][0]["comments_count"] == COMMENTS_TOTAL
|
||||
assert (await client.get(f"/documents/{document.id}")).json()["comments_count"] == COMMENTS_TOTAL
|
||||
|
||||
|
||||
async def test_validation_and_rights(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
_, document = await _doc(seeded)
|
||||
url = f"/documents/{document.id}/comments"
|
||||
client = await auth_client(KA_RF)
|
||||
assert (await client.post(url, json={"text": " "})).status_code == HTTP_UNPROCESSABLE
|
||||
assert (await client.post(url, json={"text": "x" * 4001})).status_code == HTTP_UNPROCESSABLE
|
||||
reader = await auth_client(RKA)
|
||||
assert (await reader.post(url, json={"text": "можно?"})).status_code == HTTP_FORBIDDEN
|
||||
assert (await reader.get(url)).status_code == HTTP_OK
|
||||
stranger = await auth_client("client_manager@armka.local")
|
||||
assert (await stranger.get(url)).status_code == HTTP_NOT_FOUND
|
||||
|
||||
|
||||
async def test_comment_on_superseded_file(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
entity = await _rostov_entity(seeded, name="ООО Заменённый")
|
||||
file, job = await make_stored_file(
|
||||
session=seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done
|
||||
)
|
||||
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.RECOGNIZED)
|
||||
client = await auth_client(KA_RF)
|
||||
created = await client.post(f"/documents/{document.id}/comments", json={"text": "архивная версия"})
|
||||
assert created.status_code == HTTP_CREATED, created.text
|
||||
listing = (await client.get(f"/legal-entities/{entity.id}/files", params={"include_superseded": "true"})).json()
|
||||
assert listing["items"][0]["documents"][0]["comments_count"] == 1
|
||||
@ -139,6 +139,7 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor
|
||||
],
|
||||
# R-6: предупреждения маппинга файла целиком, без фильтра по листу
|
||||
"warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"],
|
||||
"comments_count": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -142,6 +142,7 @@ async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth
|
||||
"period_check": None,
|
||||
"counterparty_check": None,
|
||||
"locator": "sheet:Лист_1",
|
||||
"comments_count": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -51,6 +51,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
"/api/v1/documents/{document_id}": {"get"},
|
||||
"/api/v1/documents/{document_id}/entries": {"get"},
|
||||
"/api/v1/documents/{document_id}/confirm": {"post"},
|
||||
"/api/v1/documents/{document_id}/comments": {"get", "post"},
|
||||
# форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6)
|
||||
"/api/v1/documents/{document_id}/statement": {"get", "patch"},
|
||||
"/api/v1/documents/{document_id}/statement/checks/accept": {"post"},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user