diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index d7f946e..711ad6a 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -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) diff --git a/api/src/api/endpoints/files/comments.py b/api/src/api/endpoints/files/comments.py new file mode 100644 index 0000000..4eeecb3 --- /dev/null +++ b/api/src/api/endpoints/files/comments.py @@ -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) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 07fd79c..3ece499 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -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) diff --git a/api/src/schemas/comments.py b/api/src/schemas/comments.py new file mode 100644 index 0000000..84b967a --- /dev/null +++ b/api/src/schemas/comments.py @@ -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 diff --git a/api/src/schemas/documents.py b/api/src/schemas/documents.py index 9090be8..2bbe179 100644 --- a/api/src/schemas/documents.py +++ b/api/src/schemas/documents.py @@ -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): diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index 1eaab38..34a5a8e 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -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): diff --git a/api/src/services/documents.py b/api/src/services/documents.py index beedae9..38c4c37 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -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)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index cb285dd..5efd649 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -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 «Дедуп по содержимому».""" diff --git a/api/tests/test_api_comments.py b/api/tests/test_api_comments.py new file mode 100644 index 0000000..7bece3d --- /dev/null +++ b/api/tests/test_api_comments.py @@ -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 diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 722391e..8eb6ca3 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -139,6 +139,7 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor ], # R-6: предупреждения маппинга файла целиком, без фильтра по листу "warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], + "comments_count": 0, } diff --git a/api/tests/test_api_files_read.py b/api/tests/test_api_files_read.py index 2307188..c70d771 100644 --- a/api/tests/test_api_files_read.py +++ b/api/tests/test_api_files_read.py @@ -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, } ] diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index a834587..75738b9 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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"},