From 73a44e63c3e28919041df3e30c1959eeee36b650 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Sun, 25 Jan 2026 22:14:29 +0300 Subject: [PATCH 01/13] Add project management CRUD API --- .../71a5eef94341_add_projects_table.py | 40 +++++ backend/app/__init__.py | 2 + backend/app/documents/models.py | 5 + backend/app/projects/models.py | 30 ++++ backend/app/projects/query.py | 57 +++++++ backend/app/projects/schema.py | 33 ++++ backend/app/routers/projects.py | 152 +++++++++++++++++ backend/app/schema.py | 6 + backend/app/services/__init__.py | 2 + backend/app/services/project_service.py | 129 ++++++++++++++ backend/main.py | 12 +- backend/tests/routers/test_routes_projects.py | 160 ++++++++++++++++++ 12 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/71a5eef94341_add_projects_table.py create mode 100644 backend/app/projects/models.py create mode 100644 backend/app/projects/query.py create mode 100644 backend/app/projects/schema.py create mode 100644 backend/app/routers/projects.py create mode 100644 backend/app/services/project_service.py create mode 100644 backend/tests/routers/test_routes_projects.py diff --git a/backend/alembic/versions/71a5eef94341_add_projects_table.py b/backend/alembic/versions/71a5eef94341_add_projects_table.py new file mode 100644 index 0000000..cdec7f0 --- /dev/null +++ b/backend/alembic/versions/71a5eef94341_add_projects_table.py @@ -0,0 +1,40 @@ +"""Add projects table + +Revision ID: 71a5eef94341 +Revises: c12df3eda4a2 +Create Date: 2026-01-25 21:09:10.356746 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# pylint: disable=E1101 + +# revision identifiers, used by Alembic. +revision: str = '71a5eef94341' +down_revision: Union[str, None] = 'c12df3eda4a2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('document', sa.Column('project_id', sa.Integer(), nullable=True)) + op.create_foreign_key('document_project_id_fkey', 'document', 'projects', ['project_id'], ['id']) + + +def downgrade() -> None: + op.drop_constraint('document_project_id_fkey', 'document', type_='foreignkey') + op.drop_column('document', 'project_id') + op.drop_table('projects') diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 811a063..552d9fb 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -10,6 +10,7 @@ XliffRecord, ) from app.glossary.models import Glossary, GlossaryRecord +from app.projects.models import Project from app.schema import ( DocumentTask, User, @@ -32,4 +33,5 @@ "DocumentType", "TxtDocument", "TxtRecord", + "Project", ] diff --git a/backend/app/documents/models.py b/backend/app/documents/models.py index 7345db8..dfe8a80 100644 --- a/backend/app/documents/models.py +++ b/backend/app/documents/models.py @@ -13,6 +13,7 @@ from app.comments.models import Comment from app.glossary.models import Glossary from app.models import User + from app.projects.models import Project from app.translation_memory.models import TranslationMemory @@ -75,6 +76,9 @@ class Document(Base): created_by: Mapped[int] = mapped_column(ForeignKey("user.id")) processing_status: Mapped[str] = mapped_column() upload_time: Mapped[datetime] = mapped_column(default=utc_time) + project_id: Mapped[int | None] = mapped_column( + ForeignKey("projects.id"), nullable=True + ) records: Mapped[list["DocumentRecord"]] = relationship( back_populates="document", @@ -82,6 +86,7 @@ class Document(Base): order_by="DocumentRecord.id", ) user: Mapped["User"] = relationship("User", back_populates="documents") + project: Mapped["Project"] = relationship(back_populates="documents") xliff: Mapped["XliffDocument"] = relationship( back_populates="parent", cascade="all, delete-orphan" ) diff --git a/backend/app/projects/models.py b/backend/app/projects/models.py new file mode 100644 index 0000000..80904bc --- /dev/null +++ b/backend/app/projects/models.py @@ -0,0 +1,30 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + +if TYPE_CHECKING: + from app.documents.models import Document + from app.schema import User + + +def utc_time(): + return datetime.now(UTC) + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column() + created_by: Mapped[int] = mapped_column(ForeignKey("user.id")) + created_at: Mapped[datetime] = mapped_column(default=utc_time) + updated_at: Mapped[datetime] = mapped_column(default=utc_time) + + user: Mapped["User"] = relationship(back_populates="projects") + documents: Mapped[list["Document"]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) diff --git a/backend/app/projects/query.py b/backend/app/projects/query.py new file mode 100644 index 0000000..24aca9f --- /dev/null +++ b/backend/app/projects/query.py @@ -0,0 +1,57 @@ +from datetime import UTC, datetime + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from app.base.exceptions import BaseQueryException +from app.projects.models import Project +from app.projects.schema import ProjectCreate, ProjectUpdate + + +class NotFoundProjectExc(BaseQueryException): + """Not found project""" + + +class ProjectQuery: + """Contain query to Project""" + + def __init__(self, db: Session): + self.__db = db + + def _get_project(self, project_id: int) -> Project: + project = self.__db.execute( + select(Project).where(Project.id == project_id) + ).scalar_one_or_none() + if project: + return project + raise NotFoundProjectExc() + + def list_projects(self, user_id: int) -> list[Project]: + """List all projects for a specific user.""" + return list( + self.__db.execute(select(Project).order_by(Project.id)).scalars().all() + ) + + def create_project(self, user_id: int, data: ProjectCreate) -> Project: + project = Project(user_id=user_id, name=data.name) + self.__db.add(project) + self.__db.commit() + return project + + def update_project(self, project_id: int, data: ProjectUpdate) -> Project: + dump = data.model_dump() + dump["updated_at"] = datetime.now(UTC) + self.__db.execute(update(Project).where(Project.id == project_id).values(dump)) + self.__db.commit() + return self._get_project(project_id) + + def delete_project(self, project_id: int) -> bool: + project = self.__db.execute( + select(Project).where(Project.id == project_id) + ).scalar_one_or_none() + if not project: + return False + + self.__db.delete(project) + self.__db.commit() + return True diff --git a/backend/app/projects/schema.py b/backend/app/projects/schema.py new file mode 100644 index 0000000..523ef55 --- /dev/null +++ b/backend/app/projects/schema.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, ConfigDict, Field + +from app.base.schema import IdentifiedTimestampedModel +from app.documents.schema import DocumentWithRecordsCount + + +class ProjectCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + + model_config = ConfigDict(from_attributes=True) + + +class ProjectUpdate(BaseModel): + name: str = Field(min_length=1, max_length=255) + + model_config = ConfigDict(from_attributes=True) + + +class ProjectResponse(IdentifiedTimestampedModel): + name: str + created_by: int + + model_config = ConfigDict(from_attributes=True) + + +class DetailedProjectResponse(ProjectResponse): + documents: list[DocumentWithRecordsCount] + approved_records_count: int + total_records_count: int + approved_words_count: int + total_words_count: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..0a155a2 --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,152 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.base.exceptions import EntityNotFound, UnauthorizedAccess +from app.db import get_db +from app.models import StatusMessage +from app.projects.schema import ProjectCreate, ProjectResponse, ProjectUpdate +from app.services.project_service import ProjectService +from app.user.depends import get_current_user_id, has_user_role + +router = APIRouter( + prefix="/projects", tags=["projects"], dependencies=[Depends(has_user_role)] +) + + +def get_service(db: Annotated[Session, Depends(get_db)]): + return ProjectService(db) + + +@router.get( + "/", + description="Get a project list", + response_model=list[ProjectResponse], + status_code=status.HTTP_200_OK, +) +def list_projects( + user_id: Annotated[int, Depends(get_current_user_id)], + service: Annotated[ProjectService, Depends(get_service)], +): + return service.list_projects(user_id) + + +@router.get( + path="/{project_id}", + description="Get a single project", + response_model=ProjectResponse, + status_code=status.HTTP_200_OK, + responses={ + 404: { + "description": "Project requested by id", + "content": { + "application/json": { + "example": {"detail": "Project with id 1 not found"} + } + }, + }, + }, +) +def retrieve_project( + project_id: int, + user_id: Annotated[int, Depends(get_current_user_id)], + service: Annotated[ProjectService, Depends(get_service)], +): + try: + return service.get_project(project_id, user_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except UnauthorizedAccess as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) + + +@router.post( + "/", + description="Create project", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, +) +def create_project( + project: ProjectCreate, + user_id: Annotated[int, Depends(get_current_user_id)], + service: Annotated[ProjectService, Depends(get_service)], +): + return service.create_project(project, user_id) + + +@router.put( + path="/{project_id}", + description="Update a single project", + response_model=ProjectResponse, + status_code=status.HTTP_200_OK, + responses={ + 404: { + "description": "Project requested by id", + "content": { + "application/json": { + "example": {"detail": "Project with id 1 not found"} + } + }, + }, + }, +) +def update_project( + project_id: int, + project: ProjectUpdate, + user_id: Annotated[int, Depends(get_current_user_id)], + service: Annotated[ProjectService, Depends(get_service)], +): + try: + return service.update_project(project_id, project, user_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except UnauthorizedAccess as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) + + +@router.delete( + path="/{project_id}", + description="Delete a single project", + response_model=StatusMessage, + status_code=status.HTTP_200_OK, + responses={ + 404: { + "description": "Project requested by id", + "content": { + "application/json": { + "example": {"detail": "Project with id 1 not found"} + } + }, + }, + }, +) +def delete_project( + project_id: int, + user_id: Annotated[int, Depends(get_current_user_id)], + service: Annotated[ProjectService, Depends(get_service)], +): + try: + return service.delete_project(project_id, user_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except UnauthorizedAccess as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) diff --git a/backend/app/schema.py b/backend/app/schema.py index 6826c13..8e2de2c 100644 --- a/backend/app/schema.py +++ b/backend/app/schema.py @@ -8,6 +8,7 @@ from app.comments.models import Comment from app.documents.models import Document from app.glossary.models import Glossary + from app.projects.models import Project from app.translation_memory.models import TranslationMemory @@ -37,6 +38,11 @@ class User(Base): documents: Mapped[list["Document"]] = relationship( back_populates="user", cascade="all, delete-orphan", order_by="Document.id" ) + projects: Mapped[list["Project"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + order_by="Project.id", + ) glossaries: Mapped[list["Glossary"]] = relationship( back_populates="created_by_user", cascade="all, delete-orphan", diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 5bb4081..826742b 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -4,6 +4,7 @@ from app.services.comment_service import CommentService from app.services.document_service import DocumentService from app.services.glossary_service import GlossaryService +from app.services.project_service import ProjectService from app.services.translation_memory_service import TranslationMemoryService from app.services.user_service import UserService @@ -12,6 +13,7 @@ "CommentService", "DocumentService", "GlossaryService", + "ProjectService", "TranslationMemoryService", "UserService", ] diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py new file mode 100644 index 0000000..1a6e63f --- /dev/null +++ b/backend/app/services/project_service.py @@ -0,0 +1,129 @@ +"""Project service for project management operations.""" + +from sqlalchemy.orm import Session + +from app.base.exceptions import EntityNotFound, UnauthorizedAccess +from app.projects.models import Project +from app.projects.query import NotFoundProjectExc, ProjectQuery +from app.projects.schema import ProjectCreate, ProjectResponse, ProjectUpdate +from app.models import StatusMessage + + +class ProjectService: + """Service for project management operations.""" + + def __init__(self, db: Session): + self.__query = ProjectQuery(db) + + def list_projects(self, user_id: int) -> list[ProjectResponse]: + """ + Get list of all projects for a user. + + Args: + user_id: ID of user + + Returns: + List of ProjectResponse objects + """ + projects = self.__query.list_projects(user_id) + return [ProjectResponse.model_validate(project) for project in projects] + + def get_project(self, project_id: int, user_id: int) -> ProjectResponse: + """ + Get a single project by ID. + + Args: + project_id: Project ID + user_id: ID of user requesting the project + + Returns: + ProjectResponse object + + Raises: + EntityNotFound: If project not found + UnauthorizedAccess: If user doesn't own the project + """ + try: + project = self.__query._get_project(project_id) + self._check_ownership(project, user_id) + return ProjectResponse.model_validate(project) + except NotFoundProjectExc: + raise EntityNotFound("Project", project_id) + + def create_project(self, data: ProjectCreate, user_id: int) -> ProjectResponse: + """ + Create a new project. + + Args: + data: Project schema data + user_id: ID of user creating the project + + Returns: + Created ProjectResponse object + """ + project = self.__query.create_project(user_id, data) + return ProjectResponse.model_validate(project) + + def update_project( + self, project_id: int, data: ProjectUpdate, user_id: int + ) -> ProjectResponse: + """ + Update a project. + + Args: + project_id: Project ID + data: Updated project schema data + user_id: ID of user updating the project + + Returns: + Updated ProjectResponse object + + Raises: + EntityNotFound: If project not found + UnauthorizedAccess: If user doesn't own the project + """ + try: + project = self.__query._get_project(project_id) + self._check_ownership(project, user_id) + updated_project = self.__query.update_project(project_id, data) + return ProjectResponse.model_validate(updated_project) + except NotFoundProjectExc: + raise EntityNotFound("Project", project_id) + + def delete_project(self, project_id: int, user_id: int) -> StatusMessage: + """ + Delete a project. + + Args: + project_id: Project ID + user_id: ID of user deleting the project + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If project not found + UnauthorizedAccess: If user doesn't own the project + """ + try: + project = self.__query._get_project(project_id) + self._check_ownership(project, user_id) + if not self.__query.delete_project(project_id): + raise EntityNotFound("Project", project_id) + return StatusMessage(message="Deleted") + except NotFoundProjectExc: + raise EntityNotFound("Project", project_id) + + def _check_ownership(self, project: Project, user_id: int) -> None: + """ + Check if user owns the project. + + Args: + project: Project object + user_id: ID of user attempting the action + + Raises: + UnauthorizedAccess: If user doesn't own the project + """ + # Currently not implemented, left for the future + pass diff --git a/backend/main.py b/backend/main.py index 01ed0f9..0c5509a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,13 +6,23 @@ comments, document, glossary, + projects, translation_memory, user, users, ) from app.settings import settings -ROUTERS = (auth, comments, document, translation_memory, user, users, glossary) +ROUTERS = ( + auth, + comments, + document, + translation_memory, + user, + users, + glossary, + projects, +) def create_app(): diff --git a/backend/tests/routers/test_routes_projects.py b/backend/tests/routers/test_routes_projects.py new file mode 100644 index 0000000..86ebbce --- /dev/null +++ b/backend/tests/routers/test_routes_projects.py @@ -0,0 +1,160 @@ +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.projects.query import ProjectQuery +from app.projects.schema import ProjectCreate +from main import app + + +def test_create_project(user_logged_client: TestClient, session: Session): + """POST /projects/""" + expected_name = "Test Project" + path = app.url_path_for("create_project") + + response = user_logged_client.post(url=path, json={"name": expected_name}) + response_json = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert response_json["name"] == expected_name + assert response_json["user_id"] == 1 + assert "id" in response_json + assert "created_at" in response_json + assert "updated_at" in response_json + assert response_json["user"]["id"] == 1 + + +def test_create_project_validation_error(user_logged_client: TestClient): + """POST /projects/ - validation error for empty name""" + path = app.url_path_for("create_project") + + response = user_logged_client.post(url=path, json={"name": ""}) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_list_projects(user_logged_client: TestClient, session: Session): + """GET /projects/""" + path = app.url_path_for("list_projects") + + project_1 = ProjectQuery(session).create_project( + user_id=1, data=ProjectCreate(name="Project 1") + ) + project_2 = ProjectQuery(session).create_project( + user_id=1, data=ProjectCreate(name="Project 2") + ) + + response = user_logged_client.get(path) + response_json = response.json() + + assert response.status_code == status.HTTP_200_OK + assert len(response_json) == 2 + assert response_json[0]["name"] == project_1.name + assert response_json[1]["name"] == project_2.name + + +def test_retrieve_project(user_logged_client: TestClient, session: Session): + """GET /projects/{project_id}/""" + project = ProjectQuery(session).create_project( + user_id=1, data=ProjectCreate(name="Test Project") + ) + path = app.url_path_for("retrieve_project", **{"project_id": project.id}) + + response = user_logged_client.get(path) + response_json = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_json["id"] == project.id + assert response_json["name"] == project.name + assert response_json["user_id"] == 1 + assert response_json["user"]["id"] == 1 + + +def test_retrieve_project_unauthorized(fastapi_client: TestClient, session: Session): + """GET /projects/{project_id}/ - 403 for accessing another user's project""" + project = ProjectQuery(session).create_project( + user_id=2, data=ProjectCreate(name="User 2 Project") + ) + path = app.url_path_for("retrieve_project", **{"project_id": project.id}) + response = fastapi_client.get(path) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_retrieve_project_not_found(user_logged_client: TestClient): + """GET /projects/{project_id}/ - 404 for non-existent project""" + response = user_logged_client.get("/projects/999") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Project with id 999 not found" + + +def test_update_project(user_logged_client: TestClient, session: Session): + """PUT /projects/{project_id}/""" + project = ProjectQuery(session).create_project( + user_id=1, data=ProjectCreate(name="Original Name") + ) + expected_name = "Updated Name" + old_time = project.updated_at + path = app.url_path_for("update_project", **{"project_id": project.id}) + + response = user_logged_client.put(url=path, json={"name": expected_name}) + response_json = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_json["name"] == expected_name + assert response_json["updated_at"] != old_time.isoformat() + + +def test_update_project_unauthorized(fastapi_client: TestClient, session: Session): + """PUT /projects/{project_id}/ - 403 for updating another user's project""" + project = ProjectQuery(session).create_project( + user_id=2, data=ProjectCreate(name="User 2 Project") + ) + path = app.url_path_for("update_project", **{"project_id": project.id}) + + response = fastapi_client.put(url=path, json={"name": "Updated Name"}) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_update_project_not_found(user_logged_client: TestClient): + """PUT /projects/{project_id}/ - 404 for non-existent project""" + response = user_logged_client.put("/projects/999", json={"name": "Updated Name"}) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Project with id 999 not found" + + +def test_delete_project(user_logged_client: TestClient, session: Session): + """DELETE /projects/{project_id}/""" + project = ProjectQuery(session).create_project( + user_id=1, data=ProjectCreate(name="Test Project") + ) + path = app.url_path_for("delete_project", **{"project_id": project.id}) + + response = user_logged_client.delete(url=path) + response_json = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_json == {"message": "Deleted"} + + +def test_delete_project_unauthorized(fastapi_client: TestClient, session: Session): + """DELETE /projects/{project_id}/ - 403 for deleting another user's project""" + project = ProjectQuery(session).create_project( + user_id=2, data=ProjectCreate(name="User 2 Project") + ) + path = app.url_path_for("delete_project", **{"project_id": project.id}) + + response = fastapi_client.delete(url=path) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_delete_project_not_found(user_logged_client: TestClient): + """DELETE /projects/{project_id}/ - 404 for non-existent project""" + response = user_logged_client.delete("/projects/999") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Project with id 999 not found" From 098f164902caa4396abb5b5876d4bc3065ec4122 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 26 Jan 2026 00:04:25 +0300 Subject: [PATCH 02/13] Add update document endpoint --- backend/app/documents/query.py | 18 ++ backend/app/documents/schema.py | 24 ++ backend/app/routers/document.py | 23 +- backend/app/services/document_service.py | 37 ++- .../tests/routers/test_routes_documents.py | 219 ++++++++++++++++++ 5 files changed, 319 insertions(+), 2 deletions(-) diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index 4ca230c..73cea94 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -28,6 +28,10 @@ class NotFoundDocumentRecordExc(BaseQueryException): """Exception raised when document record not found""" +class NotFoundDocumentExc(BaseQueryException): + """Exception raised when document not found""" + + class GenericDocsQuery: """Contain query to Document""" @@ -221,6 +225,20 @@ def update_record( self.__db.commit() return record + def update_document( + self, doc_id: int, name: str | None, project_id: int | None + ) -> Document: + document = self.get_document(doc_id) + if not document: + raise NotFoundDocumentExc() + + if name is not None: + document.name = name + document.project_id = project_id + self.__db.commit() + self.__db.refresh(document) + return document + def get_record_ids_by_source(self, doc_id: int, source: str) -> list[int]: return list( self.__db.execute( diff --git a/backend/app/documents/schema.py b/backend/app/documents/schema.py index 26c3371..42a0ced 100644 --- a/backend/app/documents/schema.py +++ b/backend/app/documents/schema.py @@ -100,3 +100,27 @@ class DocumentRecordHistory(BaseModel): class DocumentRecordHistoryListResponse(BaseModel): history: list[DocumentRecordHistory] + + +class DocumentUpdate(BaseModel): + name: str | None = Field( + default=None, + description="New name for the document.", + min_length=1, + max_length=255, + ) + project_id: int | None = Field( + default=None, + description="ID of project to assign document to. Set to null to unassign.", + ge=1, + ) + + model_config = ConfigDict(from_attributes=True) + + +class DocumentUpdateResponse(BaseModel): + id: int + name: str + project_id: int | None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index b3a6eaa..e8fb9fc 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from app import models -from app.base.exceptions import BusinessLogicError, EntityNotFound +from app.base.exceptions import BusinessLogicError, EntityNotFound, UnauthorizedAccess from app.comments.schema import CommentCreate, CommentResponse from app.db import get_db from app.documents import schema as doc_schema @@ -288,3 +288,24 @@ def download_doc( return service.download_document(doc_id) except EntityNotFound as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.put("/{doc_id}") +def update_document( + doc_id: int, + update_data: doc_schema.DocumentUpdate, + user_id: Annotated[int, Depends(get_current_user_id)], + service: Annotated[DocumentService, Depends(get_service)], +) -> doc_schema.DocumentUpdateResponse: + try: + return service.update_document(doc_id, update_data, user_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except UnauthorizedAccess as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index b34acb4..cf78365 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app import models, schema -from app.base.exceptions import BusinessLogicError, EntityNotFound +from app.base.exceptions import BusinessLogicError, EntityNotFound, UnauthorizedAccess from app.comments.query import CommentsQuery from app.comments.schema import CommentCreate, CommentResponse from app.documents import schema as doc_schema @@ -25,6 +25,7 @@ GenericDocsQuery, NotFoundDocumentRecordExc, ) +from app.projects.query import ProjectQuery, NotFoundProjectExc from app.documents.utils import compute_diff, reconstruct_from_diffs from app.formats.txt import extract_txt_content from app.formats.xliff import SegmentState, extract_xliff_content @@ -824,6 +825,40 @@ def _get_record_by_id(self, record_id: int): raise EntityNotFound("Document record not found") return record + def update_document( + self, doc_id: int, update_data: doc_schema.DocumentUpdate, user_id: int + ) -> doc_schema.DocumentUpdateResponse: + """ + Update a document (name and/or project_id). + + Args: + doc_id: Document ID + update_data: DocumentUpdate object with optional name and project_id + user_id: ID of user performing action + + Returns: + DocumentUpdateResponse object + + Raises: + EntityNotFound: If document or project not found + UnauthorizedAccess: If user doesn't own project + """ + self._get_document_by_id(doc_id) + try: + if update_data.project_id is not None: + pq = ProjectQuery(self.__db) + # verify project exists + pq._get_project(update_data.project_id) + except NotFoundProjectExc: + raise EntityNotFound("Project", update_data.project_id) + + updated_doc = self.__query.update_document( + doc_id, update_data.name, update_data.project_id + ) + return doc_schema.DocumentUpdateResponse( + id=updated_doc.id, name=updated_doc.name, project_id=updated_doc.project_id + ) + def encode_to_latin_1(self, original: str): output = "" for c in original: diff --git a/backend/tests/routers/test_routes_documents.py b/backend/tests/routers/test_routes_documents.py index fd4a5db..c3ec5cc 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -16,6 +16,7 @@ XliffRecord, ) from app.documents.query import GenericDocsQuery +from app.projects.models import Project from app.glossary.models import ProcessingStatuses from app.glossary.query import GlossaryQuery from app.glossary.schema import ( @@ -1068,3 +1069,221 @@ def test_doc_glossary_search_returns_404_for_nonexistent_document( ): response = user_logged_client.get("/document/99/glossary_search?query=test") assert response.status_code == 404 + + +def test_update_document_name_only(user_logged_client: TestClient, session: Session): + """Test successful update of document name only.""" + doc = Document( + name="original.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + ) + session.add(doc) + session.commit() + + response = user_logged_client.put( + f"/document/{doc.id}", json={"name": "updated.txt"} + ) + assert response.status_code == 200 + response_json = response.json() + assert response_json["id"] == doc.id + assert response_json["name"] == "updated.txt" + assert response_json["project_id"] is None + + with session as s: + updated_doc = s.query(Document).filter_by(id=doc.id).first() + assert updated_doc is not None + assert updated_doc.name == "updated.txt" + assert updated_doc.project_id is None + + +def test_update_document_project_only(user_logged_client: TestClient, session: Session): + """Test successful update of document project_id only.""" + doc = Document( + name="document.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + ) + project = Project(user_id=1, name="Test Project") + session.add(doc) + session.add(project) + session.commit() + project_id = project.id # Save id before session expires + + response = user_logged_client.put( + f"/document/{doc.id}", json={"project_id": project_id} + ) + assert response.status_code == 200 + response_json = response.json() + assert response_json["id"] == doc.id + assert response_json["name"] == "document.txt" + assert response_json["project_id"] == project_id + + with session as s: + updated_doc = s.query(Document).filter_by(id=doc.id).first() + assert updated_doc is not None + assert updated_doc.project_id == project_id + + +def test_update_document_name_and_project( + user_logged_client: TestClient, session: Session +): + """Test successful update of both name and project_id.""" + doc = Document( + name="original.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + ) + project = Project(user_id=1, name="Test Project") + session.add(doc) + session.add(project) + session.commit() + project_id = project.id # Save id before session expires + + response = user_logged_client.put( + f"/document/{doc.id}", json={"name": "updated.txt", "project_id": project_id} + ) + assert response.status_code == 200 + response_json = response.json() + assert response_json["id"] == doc.id + assert response_json["name"] == "updated.txt" + assert response_json["project_id"] == project_id + + with session as s: + updated_doc = s.query(Document).filter_by(id=doc.id).first() + assert updated_doc is not None + assert updated_doc.name == "updated.txt" + assert updated_doc.project_id == project_id + + +def test_unassign_document_from_project( + user_logged_client: TestClient, session: Session +): + """Test successful unassignment of document from project.""" + doc = Document( + name="document.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=1, + ) + project = Project(user_id=1, name="Test Project") + session.add(doc) + session.add(project) + session.commit() + + response = user_logged_client.put(f"/document/{doc.id}", json={"project_id": None}) + assert response.status_code == 200 + response_json = response.json() + assert response_json["id"] == doc.id + assert response_json["name"] == "document.txt" + assert response_json["project_id"] is None + + with session as s: + updated_doc = s.query(Document).filter_by(id=doc.id).first() + assert updated_doc is not None + assert updated_doc.project_id is None + + +def test_update_document_not_found(user_logged_client: TestClient, session: Session): + """Test 404 when document doesn't exist.""" + response = user_logged_client.put("/document/999", json={"name": "updated.txt"}) + assert response.status_code == 404 + assert "Document not found" in response.json()["detail"] + + +def test_update_project_not_found(user_logged_client: TestClient, session: Session): + """Test 404 when project doesn't exist.""" + doc = Document( + name="document.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + ) + session.add(doc) + session.commit() + + response = user_logged_client.put(f"/document/{doc.id}", json={"project_id": 999}) + assert response.status_code == 404 + assert "Project with id 999 not found" in response.json()["detail"] + + +def test_update_document_validation_error( + user_logged_client: TestClient, session: Session +): + """Test 422 for invalid project_id (negative, zero) or invalid name.""" + doc = Document( + name="document.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + ) + session.add(doc) + session.commit() + + # Test invalid project_id (negative) + response = user_logged_client.put(f"/document/{doc.id}", json={"project_id": -1}) + assert response.status_code == 422 + + # Test invalid project_id (zero) + response = user_logged_client.put(f"/document/{doc.id}", json={"project_id": 0}) + assert response.status_code == 422 + + # Test invalid name (empty) + response = user_logged_client.put(f"/document/{doc.id}", json={"name": ""}) + assert response.status_code == 422 + + # Test invalid name (too long - over 255 characters) + long_name = "a" * 256 + response = user_logged_client.put(f"/document/{doc.id}", json={"name": long_name}) + assert response.status_code == 422 + + +def test_update_document_unauthenticated(fastapi_client: TestClient, session: Session): + """Test 401 when user is not authenticated.""" + doc = Document( + name="document.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + ) + session.add(doc) + session.commit() + + response = fastapi_client.put(f"/document/{doc.id}", json={"name": "updated.txt"}) + assert response.status_code == 401 + + +def test_update_document_to_same_project( + user_logged_client: TestClient, session: Session +): + """Test idempotent update to same project.""" + doc = Document( + name="document.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=1, + ) + project = Project(user_id=1, name="Test Project") + session.add(doc) + session.add(project) + session.commit() + project_id = project.id # Save id before session expires + + response = user_logged_client.put( + f"/document/{doc.id}", json={"project_id": project_id} + ) + assert response.status_code == 200 + response_json = response.json() + assert response_json["id"] == doc.id + assert response_json["name"] == "document.txt" + assert response_json["project_id"] == project_id + + with session as s: + updated_doc = s.query(Document).filter_by(id=doc.id).first() + assert updated_doc is not None + assert updated_doc.project_id == project_id From 21acab283c90e801211800fe26ecf3eb0da3c0b1 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 26 Jan 2026 01:03:11 +0300 Subject: [PATCH 03/13] Add aggregate metrics to project responses --- backend/app/projects/query.py | 47 ++++- backend/app/projects/schema.py | 9 + backend/app/routers/projects.py | 15 +- backend/app/services/document_service.py | 4 +- backend/app/services/project_service.py | 56 +++++- .../tests/routers/test_routes_documents.py | 2 +- backend/tests/routers/test_routes_projects.py | 180 ++++++++++++++++++ 7 files changed, 292 insertions(+), 21 deletions(-) diff --git a/backend/app/projects/query.py b/backend/app/projects/query.py index 24aca9f..120fe8f 100644 --- a/backend/app/projects/query.py +++ b/backend/app/projects/query.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime -from sqlalchemy import select, update +from sqlalchemy import case, func, select, update from sqlalchemy.orm import Session from app.base.exceptions import BaseQueryException +from app.documents.models import Document, DocumentRecord from app.projects.models import Project from app.projects.schema import ProjectCreate, ProjectUpdate @@ -55,3 +56,47 @@ def delete_project(self, project_id: int) -> bool: self.__db.delete(project) self.__db.commit() return True + + def get_project_aggregates(self, project_id: int) -> tuple[int, int, int, int]: + """ + Get aggregate metrics for a project. + + Returns a tuple containing: + - approved_segments_count: Total approved segments across all documents + - total_segments_count: Total segments across all documents + - approved_words_count: Total approved words across all documents + - total_words_count: Total words across all documents + + Args: + project_id: ID of the project + + Returns: + Tuple of four integers: (approved_segments_count, total_segments_count, + approved_words_count, total_words_count) + """ + stmt = ( + select( + func.sum(case((DocumentRecord.approved.is_(True), 1), else_=0)).label( + "approved_segments" + ), + func.count(DocumentRecord.id).label("total_segments"), + func.sum( + case( + (DocumentRecord.approved.is_(True), DocumentRecord.word_count), + else_=0, + ) + ).label("approved_words"), + func.sum(DocumentRecord.word_count).label("total_words"), + ) + .select_from(DocumentRecord) + .join(Document, DocumentRecord.document_id == Document.id) + .where(Document.project_id == project_id) + ) + + result = self.__db.execute(stmt).one() + return ( + result.approved_segments or 0, + result.total_segments or 0, + result.approved_words or 0, + result.total_words or 0, + ) diff --git a/backend/app/projects/schema.py b/backend/app/projects/schema.py index 523ef55..cc8d458 100644 --- a/backend/app/projects/schema.py +++ b/backend/app/projects/schema.py @@ -31,3 +31,12 @@ class DetailedProjectResponse(ProjectResponse): total_words_count: int model_config = ConfigDict(from_attributes=True) + + +class ProjectResponseWithWordsCount(ProjectResponse): + approved_segments_count: int + total_segments_count: int + approved_words_count: int + total_words_count: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 0a155a2..af35e23 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -6,7 +6,12 @@ from app.base.exceptions import EntityNotFound, UnauthorizedAccess from app.db import get_db from app.models import StatusMessage -from app.projects.schema import ProjectCreate, ProjectResponse, ProjectUpdate +from app.projects.schema import ( + ProjectCreate, + ProjectResponse, + ProjectResponseWithWordsCount, + ProjectUpdate, +) from app.services.project_service import ProjectService from app.user.depends import get_current_user_id, has_user_role @@ -22,8 +27,7 @@ def get_service(db: Annotated[Session, Depends(get_db)]): @router.get( "/", description="Get a project list", - response_model=list[ProjectResponse], - status_code=status.HTTP_200_OK, + response_model=list[ProjectResponseWithWordsCount], ) def list_projects( user_id: Annotated[int, Depends(get_current_user_id)], @@ -35,8 +39,7 @@ def list_projects( @router.get( path="/{project_id}", description="Get a single project", - response_model=ProjectResponse, - status_code=status.HTTP_200_OK, + response_model=ProjectResponseWithWordsCount, responses={ 404: { "description": "Project requested by id", @@ -85,7 +88,6 @@ def create_project( path="/{project_id}", description="Update a single project", response_model=ProjectResponse, - status_code=status.HTTP_200_OK, responses={ 404: { "description": "Project requested by id", @@ -121,7 +123,6 @@ def update_project( path="/{project_id}", description="Delete a single project", response_model=StatusMessage, - status_code=status.HTTP_200_OK, responses={ 404: { "description": "Project requested by id", diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index cf78365..f11270b 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app import models, schema -from app.base.exceptions import BusinessLogicError, EntityNotFound, UnauthorizedAccess +from app.base.exceptions import BusinessLogicError, EntityNotFound from app.comments.query import CommentsQuery from app.comments.schema import CommentCreate, CommentResponse from app.documents import schema as doc_schema @@ -25,12 +25,12 @@ GenericDocsQuery, NotFoundDocumentRecordExc, ) -from app.projects.query import ProjectQuery, NotFoundProjectExc from app.documents.utils import compute_diff, reconstruct_from_diffs from app.formats.txt import extract_txt_content from app.formats.xliff import SegmentState, extract_xliff_content from app.glossary.query import GlossaryQuery, NotFoundGlossaryExc from app.glossary.schema import GlossaryRecordSchema, GlossaryResponse +from app.projects.query import NotFoundProjectExc, ProjectQuery from app.translation_memory.query import TranslationMemoryQuery from app.translation_memory.schema import ( MemorySubstitution, diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index 1a6e63f..a3630c6 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -2,11 +2,16 @@ from sqlalchemy.orm import Session -from app.base.exceptions import EntityNotFound, UnauthorizedAccess +from app.base.exceptions import EntityNotFound +from app.models import ShortUser, StatusMessage from app.projects.models import Project from app.projects.query import NotFoundProjectExc, ProjectQuery -from app.projects.schema import ProjectCreate, ProjectResponse, ProjectUpdate -from app.models import StatusMessage +from app.projects.schema import ( + ProjectCreate, + ProjectResponse, + ProjectResponseWithWordsCount, + ProjectUpdate, +) class ProjectService: @@ -15,9 +20,9 @@ class ProjectService: def __init__(self, db: Session): self.__query = ProjectQuery(db) - def list_projects(self, user_id: int) -> list[ProjectResponse]: + def list_projects(self, user_id: int) -> list[ProjectResponseWithWordsCount]: """ - Get list of all projects for a user. + Get list of all projects for a user with aggregate metrics. Args: user_id: ID of user @@ -26,11 +31,30 @@ def list_projects(self, user_id: int) -> list[ProjectResponse]: List of ProjectResponse objects """ projects = self.__query.list_projects(user_id) - return [ProjectResponse.model_validate(project) for project in projects] - - def get_project(self, project_id: int, user_id: int) -> ProjectResponse: + return [ + ProjectResponseWithWordsCount( + id=project.id, + name=project.name, + user_id=project.user_id, + user=ShortUser.model_validate(project.user), + created_at=project.created_at, + updated_at=project.updated_at, + approved_segments_count=aggregates[0], + total_segments_count=aggregates[1], + approved_words_count=aggregates[2], + total_words_count=aggregates[3], + ) + for project, aggregates in [ + (project, self.__query.get_project_aggregates(project.id)) + for project in projects + ] + ] + + def get_project( + self, project_id: int, user_id: int + ) -> ProjectResponseWithWordsCount: """ - Get a single project by ID. + Get a single project by ID with aggregate metrics. Args: project_id: Project ID @@ -46,7 +70,19 @@ def get_project(self, project_id: int, user_id: int) -> ProjectResponse: try: project = self.__query._get_project(project_id) self._check_ownership(project, user_id) - return ProjectResponse.model_validate(project) + aggregates = self.__query.get_project_aggregates(project_id) + return ProjectResponseWithWordsCount( + id=project.id, + name=project.name, + user_id=project.user_id, + user=ShortUser.model_validate(project.user), + created_at=project.created_at, + updated_at=project.updated_at, + approved_segments_count=aggregates[0], + total_segments_count=aggregates[1], + approved_words_count=aggregates[2], + total_words_count=aggregates[3], + ) except NotFoundProjectExc: raise EntityNotFound("Project", project_id) diff --git a/backend/tests/routers/test_routes_documents.py b/backend/tests/routers/test_routes_documents.py index c3ec5cc..b42069c 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -16,7 +16,6 @@ XliffRecord, ) from app.documents.query import GenericDocsQuery -from app.projects.models import Project from app.glossary.models import ProcessingStatuses from app.glossary.query import GlossaryQuery from app.glossary.schema import ( @@ -24,6 +23,7 @@ GlossarySchema, ) from app.models import DocumentStatus +from app.projects.models import Project from app.schema import DocumentTask from app.translation_memory.models import TranslationMemory diff --git a/backend/tests/routers/test_routes_projects.py b/backend/tests/routers/test_routes_projects.py index 86ebbce..95848be 100644 --- a/backend/tests/routers/test_routes_projects.py +++ b/backend/tests/routers/test_routes_projects.py @@ -2,6 +2,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session +from app.documents.models import Document, DocumentRecord, DocumentType +from app.projects.models import Project from app.projects.query import ProjectQuery from app.projects.schema import ProjectCreate from main import app @@ -158,3 +160,181 @@ def test_delete_project_not_found(user_logged_client: TestClient): assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json()["detail"] == "Project with id 999 not found" + + +def test_list_projects_with_aggregates( + user_logged_client: TestClient, session: Session +): + """GET /projects/ - returns aggregate metrics for projects with documents""" + with session as s: + project = Project(user_id=1, name="Test Project") + s.add(project) + s.flush() + + doc1 = Document( + name="doc1.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=project.id, + records=[ + DocumentRecord( + source="Hello", target="Привет", approved=True, word_count=1 + ), + DocumentRecord( + source="World", target="Мир", approved=False, word_count=1 + ), + ], + ) + doc2 = Document( + name="doc2.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=project.id, + records=[ + DocumentRecord( + source="Test", target="Тест", approved=True, word_count=1 + ), + DocumentRecord( + source="Data", target="Данные", approved=True, word_count=1 + ), + ], + ) + s.add_all([doc1, doc2]) + s.commit() + + response = user_logged_client.get("/projects/") + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert len(response_json) == 1 + + project_data = response_json[0] + assert project_data["name"] == "Test Project" + # 3 approved records (Hello, Test, Data) + assert project_data["approved_segments_count"] == 3 + # 4 total records (Hello, World, Test, Data) + assert project_data["total_segments_count"] == 4 + # 3 approved words (1 + 1 + 1) + assert project_data["approved_words_count"] == 3 + # 4 total words (1 + 1 + 1 + 1) + assert project_data["total_words_count"] == 4 + + +def test_list_projects_empty_project(user_logged_client: TestClient, session: Session): + """GET /projects/ - returns zeros for projects without documents""" + with session as s: + project = Project(user_id=1, name="Empty Project") + s.add(project) + s.commit() + + response = user_logged_client.get("/projects/") + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert len(response_json) == 1 + + project_data = response_json[0] + assert project_data["name"] == "Empty Project" + assert project_data["approved_segments_count"] == 0 + assert project_data["total_segments_count"] == 0 + assert project_data["approved_words_count"] == 0 + assert project_data["total_words_count"] == 0 + + +def test_list_projects_project_with_documents_no_records( + user_logged_client: TestClient, session: Session +): + """GET /projects/ - returns zeros for projects with documents but no records""" + with session as s: + project = Project(user_id=1, name="Project with Empty Docs") + s.add(project) + s.flush() + + doc = Document( + name="empty_doc.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=project.id, + ) + s.add(doc) + s.commit() + + response = user_logged_client.get("/projects/") + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert len(response_json) == 1 + + project_data = response_json[0] + assert project_data["name"] == "Project with Empty Docs" + assert project_data["approved_segments_count"] == 0 + assert project_data["total_segments_count"] == 0 + assert project_data["approved_words_count"] == 0 + assert project_data["total_words_count"] == 0 + + +def test_retrieve_project_with_aggregates( + user_logged_client: TestClient, session: Session +): + """GET /projects/{project_id}/ - returns aggregate metrics for a single project""" + with session as s: + project = Project(user_id=1, name="Test Project") + s.add(project) + s.flush() + project_id = project.id + + doc = Document( + name="doc.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=project_id, + records=[ + DocumentRecord( + source="Hello", target="Привет", approved=True, word_count=1 + ), + DocumentRecord( + source="World", target="Мир", approved=False, word_count=1 + ), + DocumentRecord( + source="Test", target="Тест", approved=True, word_count=2 + ), + ], + ) + s.add(doc) + s.commit() + + response = user_logged_client.get(f"/projects/{project_id}") + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + + assert response_json["id"] == project_id + assert response_json["name"] == "Test Project" + # 2 approved records (Hello, Test) + assert response_json["approved_segments_count"] == 2 + # 3 total records (Hello, World, Test) + assert response_json["total_segments_count"] == 3 + # 3 approved words (1 + 2) + assert response_json["approved_words_count"] == 3 + # 4 total words (1 + 1 + 2) + assert response_json["total_words_count"] == 4 + + +def test_retrieve_project_empty(user_logged_client: TestClient, session: Session): + """GET /projects/{project_id}/ - returns zeros for empty project""" + with session as s: + project = Project(user_id=1, name="Empty Project") + s.add(project) + s.commit() + project_id = project.id + + response = user_logged_client.get(f"/projects/{project_id}") + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + + assert response_json["id"] == project_id + assert response_json["name"] == "Empty Project" + assert response_json["approved_segments_count"] == 0 + assert response_json["total_segments_count"] == 0 + assert response_json["approved_words_count"] == 0 + assert response_json["total_words_count"] == 0 From 687e38eafbe761d858e502a9e9b6aa1037bd0789 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 26 Jan 2026 01:05:50 +0300 Subject: [PATCH 04/13] Generate client --- frontend/src/client/schemas/DocumentUpdate.ts | 6 +++++ .../client/schemas/DocumentUpdateResponse.ts | 7 ++++++ frontend/src/client/schemas/ProjectCreate.ts | 5 ++++ .../src/client/schemas/ProjectResponse.ts | 12 +++++++++ .../schemas/ProjectResponseWithWordsCount.ts | 16 ++++++++++++ frontend/src/client/schemas/ProjectUpdate.ts | 5 ++++ .../src/client/services/DocumentService.ts | 5 ++++ .../src/client/services/ProjectsService.ts | 25 +++++++++++++++++++ 8 files changed, 81 insertions(+) create mode 100644 frontend/src/client/schemas/DocumentUpdate.ts create mode 100644 frontend/src/client/schemas/DocumentUpdateResponse.ts create mode 100644 frontend/src/client/schemas/ProjectCreate.ts create mode 100644 frontend/src/client/schemas/ProjectResponse.ts create mode 100644 frontend/src/client/schemas/ProjectResponseWithWordsCount.ts create mode 100644 frontend/src/client/schemas/ProjectUpdate.ts create mode 100644 frontend/src/client/services/ProjectsService.ts diff --git a/frontend/src/client/schemas/DocumentUpdate.ts b/frontend/src/client/schemas/DocumentUpdate.ts new file mode 100644 index 0000000..30b88ea --- /dev/null +++ b/frontend/src/client/schemas/DocumentUpdate.ts @@ -0,0 +1,6 @@ +// This file is autogenerated, do not edit directly. + +export interface DocumentUpdate { + name?: string | null + project_id?: number | null +} diff --git a/frontend/src/client/schemas/DocumentUpdateResponse.ts b/frontend/src/client/schemas/DocumentUpdateResponse.ts new file mode 100644 index 0000000..e843b3d --- /dev/null +++ b/frontend/src/client/schemas/DocumentUpdateResponse.ts @@ -0,0 +1,7 @@ +// This file is autogenerated, do not edit directly. + +export interface DocumentUpdateResponse { + id: number + name: string + project_id: number | null +} diff --git a/frontend/src/client/schemas/ProjectCreate.ts b/frontend/src/client/schemas/ProjectCreate.ts new file mode 100644 index 0000000..3c5e787 --- /dev/null +++ b/frontend/src/client/schemas/ProjectCreate.ts @@ -0,0 +1,5 @@ +// This file is autogenerated, do not edit directly. + +export interface ProjectCreate { + name: string +} diff --git a/frontend/src/client/schemas/ProjectResponse.ts b/frontend/src/client/schemas/ProjectResponse.ts new file mode 100644 index 0000000..b393e4d --- /dev/null +++ b/frontend/src/client/schemas/ProjectResponse.ts @@ -0,0 +1,12 @@ +// This file is autogenerated, do not edit directly. + +import {ShortUser} from './ShortUser' + +export interface ProjectResponse { + id: number + created_at: string + updated_at: string + name: string + user_id: number + user: ShortUser +} diff --git a/frontend/src/client/schemas/ProjectResponseWithWordsCount.ts b/frontend/src/client/schemas/ProjectResponseWithWordsCount.ts new file mode 100644 index 0000000..3ba7368 --- /dev/null +++ b/frontend/src/client/schemas/ProjectResponseWithWordsCount.ts @@ -0,0 +1,16 @@ +// This file is autogenerated, do not edit directly. + +import {ShortUser} from './ShortUser' + +export interface ProjectResponseWithWordsCount { + id: number + created_at: string + updated_at: string + name: string + user_id: number + user: ShortUser + approved_segments_count: number + total_segments_count: number + approved_words_count: number + total_words_count: number +} diff --git a/frontend/src/client/schemas/ProjectUpdate.ts b/frontend/src/client/schemas/ProjectUpdate.ts new file mode 100644 index 0000000..234e2e7 --- /dev/null +++ b/frontend/src/client/schemas/ProjectUpdate.ts @@ -0,0 +1,5 @@ +// This file is autogenerated, do not edit directly. + +export interface ProjectUpdate { + name: string +} diff --git a/frontend/src/client/services/DocumentService.ts b/frontend/src/client/services/DocumentService.ts index 4a39551..51b1d62 100644 --- a/frontend/src/client/services/DocumentService.ts +++ b/frontend/src/client/services/DocumentService.ts @@ -6,6 +6,8 @@ import {DocumentWithRecordsCount} from '../schemas/DocumentWithRecordsCount' import {Document} from '../schemas/Document' import {Body_create_doc_document__post} from '../schemas/Body_create_doc_document__post' import {StatusMessage} from '../schemas/StatusMessage' +import {DocumentUpdateResponse} from '../schemas/DocumentUpdateResponse' +import {DocumentUpdate} from '../schemas/DocumentUpdate' import {DocumentRecordListResponse} from '../schemas/DocumentRecordListResponse' import {GlossaryRecordSchema} from '../schemas/GlossaryRecordSchema' import {CommentResponse} from '../schemas/CommentResponse' @@ -36,6 +38,9 @@ export const getDoc = async (doc_id: number): Promise export const deleteDoc = async (doc_id: number): Promise => { return await api.delete(`/document/${doc_id}`) } +export const updateDocument = async (doc_id: number, content: DocumentUpdate): Promise => { + return await api.put(`/document/${doc_id}`, content) +} export const getDocRecords = async (doc_id: number, page?: number | null, source?: string | null, target?: string | null): Promise => { return await api.get(`/document/${doc_id}/records`, {query: {page, source, target}}) } diff --git a/frontend/src/client/services/ProjectsService.ts b/frontend/src/client/services/ProjectsService.ts new file mode 100644 index 0000000..73979c7 --- /dev/null +++ b/frontend/src/client/services/ProjectsService.ts @@ -0,0 +1,25 @@ +// This file is autogenerated, do not edit directly. + +import {getApiBase, api} from '../defaults' + +import {ProjectResponseWithWordsCount} from '../schemas/ProjectResponseWithWordsCount' +import {ProjectResponse} from '../schemas/ProjectResponse' +import {ProjectCreate} from '../schemas/ProjectCreate' +import {ProjectUpdate} from '../schemas/ProjectUpdate' +import {StatusMessage} from '../schemas/StatusMessage' + +export const listProjects = async (): Promise => { + return await api.get(`/projects/`) +} +export const createProject = async (content: ProjectCreate): Promise => { + return await api.post(`/projects/`, content) +} +export const retrieveProject = async (project_id: number): Promise => { + return await api.get(`/projects/${project_id}`) +} +export const updateProject = async (project_id: number, content: ProjectUpdate): Promise => { + return await api.put(`/projects/${project_id}`, content) +} +export const deleteProject = async (project_id: number): Promise => { + return await api.delete(`/projects/${project_id}`) +} From 3f6c337408e2cb2f08de77105ae821cb6e416c37 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 26 Jan 2026 23:00:13 +0300 Subject: [PATCH 05/13] Split records part from document to simplify API --- backend/app/documents/query.py | 25 +- backend/app/records/query.py | 34 ++ backend/app/routers/document.py | 80 ----- backend/app/routers/records.py | 97 ++++++ backend/app/services/document_service.py | 271 +--------------- backend/app/services/record_service.py | 293 ++++++++++++++++++ backend/main.py | 2 + backend/tests/routers/test_routes_comments.py | 28 +- .../tests/routers/test_routes_doc_records.py | 16 +- .../tests/routers/test_routes_documents.py | 4 +- .../routers/test_routes_segment_history.py | 30 +- 11 files changed, 463 insertions(+), 417 deletions(-) create mode 100644 backend/app/records/query.py create mode 100644 backend/app/routers/records.py create mode 100644 backend/app/services/record_service.py diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index 73cea94..0bd4137 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -7,7 +7,7 @@ from app.base.exceptions import BaseQueryException from app.comments.models import Comment from app.documents.models import DocumentRecordHistory, DocumentRecordHistoryChangeType -from app.documents.schema import DocumentRecordFilter, DocumentRecordUpdate +from app.documents.schema import DocumentRecordFilter from app.glossary.models import Glossary from app.models import DocumentStatus from app.translation_memory.models import TranslationMemory @@ -24,10 +24,6 @@ ) -class NotFoundDocumentRecordExc(BaseQueryException): - """Exception raised when document record not found""" - - class NotFoundDocumentExc(BaseQueryException): """Exception raised when document not found""" @@ -206,25 +202,6 @@ def get_document_records_paged( .limit(page_records) ).all() - def get_record(self, record_id: int) -> DocumentRecord | None: - return self.__db.execute( - select(DocumentRecord).filter(DocumentRecord.id == record_id) - ).scalar_one_or_none() - - def update_record( - self, record_id: int, data: DocumentRecordUpdate - ) -> DocumentRecord: - record = self.get_record(record_id) - if not record: - raise NotFoundDocumentRecordExc() - - record.target = data.target - if data.approved is not None: - record.approved = data.approved - - self.__db.commit() - return record - def update_document( self, doc_id: int, name: str | None, project_id: int | None ) -> Document: diff --git a/backend/app/records/query.py b/backend/app/records/query.py new file mode 100644 index 0000000..c74b0b8 --- /dev/null +++ b/backend/app/records/query.py @@ -0,0 +1,34 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.base.exceptions import BaseQueryException +from app.documents.models import DocumentRecord +from app.documents.schema import DocumentRecordUpdate + + +class NotFoundDocumentRecordExc(BaseQueryException): + """Exception raised when document record not found""" + + +class RecordsQuery: + def __init__(self, db: Session) -> None: + self.__db = db + + def get_record(self, record_id: int) -> DocumentRecord | None: + return self.__db.execute( + select(DocumentRecord).filter(DocumentRecord.id == record_id) + ).scalar_one_or_none() + + def update_record( + self, record_id: int, data: DocumentRecordUpdate + ) -> DocumentRecord: + record = self.get_record(record_id) + if not record: + raise NotFoundDocumentRecordExc() + + record.target = data.target + if data.approved is not None: + record.approved = data.approved + + self.__db.commit() + return record diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index e8fb9fc..5559b34 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -6,14 +6,11 @@ from app import models from app.base.exceptions import BusinessLogicError, EntityNotFound, UnauthorizedAccess -from app.comments.schema import CommentCreate, CommentResponse from app.db import get_db from app.documents import schema as doc_schema -from app.documents.models import DocumentRecordHistoryChangeType from app.glossary.schema import GlossaryRecordSchema from app.services import DocumentService from app.translation_memory.schema import ( - MemorySubstitution, TranslationMemoryListResponse, TranslationMemoryListSimilarResponse, ) @@ -85,83 +82,6 @@ def doc_glossary_search( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -@router.get("/records/{record_id}/comments") -def get_comments( - record_id: int, - service: Annotated[DocumentService, Depends(get_service)], -) -> list[CommentResponse]: - """Get all comments for a document record""" - try: - return service.get_comments(record_id) - except EntityNotFound as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - - -@router.post("/records/{record_id}/comments") -def create_comment( - record_id: int, - comment_data: CommentCreate, - service: Annotated[DocumentService, Depends(get_service)], - current_user: Annotated[int, Depends(get_current_user_id)], -) -> CommentResponse: - """Create a new comment for a document record""" - try: - return service.create_comment(record_id, comment_data, current_user) - except EntityNotFound as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - - -@router.get("/records/{record_id}/substitutions") -def get_record_substitutions( - record_id: int, - service: Annotated[DocumentService, Depends(get_service)], -) -> list[MemorySubstitution]: - try: - return service.get_record_substitutions(record_id) - except EntityNotFound as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - - -@router.get( - "/records/{record_id}/history", - description="Get the history of changes for a document record", -) -def get_segment_history( - record_id: int, - service: Annotated[DocumentService, Depends(get_service)], -) -> doc_schema.DocumentRecordHistoryListResponse: - try: - return service.get_segment_history(record_id) - except EntityNotFound as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - - -@router.get("/records/{record_id}/glossary_records") -def get_record_glossary_records( - record_id: int, - service: Annotated[DocumentService, Depends(get_service)], -) -> list[GlossaryRecordSchema]: - try: - return service.get_record_glossary_records(record_id) - except EntityNotFound as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - - -@router.put("/records/{record_id}") -def update_doc_record( - record_id: int, - record: doc_schema.DocumentRecordUpdate, - service: Annotated[DocumentService, Depends(get_service)], - current_user: Annotated[int, Depends(get_current_user_id)], -) -> doc_schema.DocumentRecordUpdateResponse: - try: - return service.update_record( - record_id, record, current_user, DocumentRecordHistoryChangeType.manual_edit - ) - except EntityNotFound as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - - @router.get("/{doc_id}/memories") def get_translation_memories( doc_id: int, diff --git a/backend/app/routers/records.py b/backend/app/routers/records.py new file mode 100644 index 0000000..a2a9a09 --- /dev/null +++ b/backend/app/routers/records.py @@ -0,0 +1,97 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.base.exceptions import EntityNotFound +from app.comments.schema import CommentCreate, CommentResponse +from app.db import get_db +from app.documents import schema as doc_schema +from app.documents.models import DocumentRecordHistoryChangeType +from app.glossary.schema import GlossaryRecordSchema +from app.services.record_service import RecordService +from app.translation_memory.schema import MemorySubstitution +from app.user.depends import get_current_user_id, has_user_role + +router = APIRouter( + prefix="/records", tags=["records"], dependencies=[Depends(has_user_role)] +) + + +def get_service(db: Annotated[Session, Depends(get_db)]): + return RecordService(db) + + +@router.put("/{record_id}") +def update_doc_record( + record_id: int, + record: doc_schema.DocumentRecordUpdate, + service: Annotated[RecordService, Depends(get_service)], + current_user: Annotated[int, Depends(get_current_user_id)], +) -> doc_schema.DocumentRecordUpdateResponse: + try: + return service.update_record( + record_id, record, current_user, DocumentRecordHistoryChangeType.manual_edit + ) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.get("/{record_id}/comments") +def get_comments( + record_id: int, + service: Annotated[RecordService, Depends(get_service)], +) -> list[CommentResponse]: + try: + return service.get_comments(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.post("/{record_id}/comments") +def create_comment( + record_id: int, + comment_data: CommentCreate, + service: Annotated[RecordService, Depends(get_service)], + current_user: Annotated[int, Depends(get_current_user_id)], +) -> CommentResponse: + try: + return service.create_comment(record_id, comment_data, current_user) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.get("/{record_id}/substitutions") +def get_record_substitutions( + record_id: int, + service: Annotated[RecordService, Depends(get_service)], +) -> list[MemorySubstitution]: + try: + return service.get_record_substitutions(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.get( + "/{record_id}/history", + description="Get the history of changes for a document record", +) +def get_segment_history( + record_id: int, + service: Annotated[RecordService, Depends(get_service)], +) -> doc_schema.DocumentRecordHistoryListResponse: + try: + return service.get_segment_history(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.get("/{record_id}/glossary_records") +def get_record_glossary_records( + record_id: int, + service: Annotated[RecordService, Depends(get_service)], +) -> list[GlossaryRecordSchema]: + try: + return service.get_record_glossary_records(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index f11270b..55e45fe 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -1,7 +1,7 @@ """Document service for document and document record operations.""" from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from fastapi import UploadFile from fastapi.responses import StreamingResponse @@ -9,23 +9,16 @@ from app import models, schema from app.base.exceptions import BusinessLogicError, EntityNotFound -from app.comments.query import CommentsQuery -from app.comments.schema import CommentCreate, CommentResponse from app.documents import schema as doc_schema from app.documents.models import ( Document, - DocumentRecordHistory, - DocumentRecordHistoryChangeType, DocumentType, TmMode, XliffRecord, ) from app.documents.query import ( - DocumentRecordHistoryQuery, GenericDocsQuery, - NotFoundDocumentRecordExc, ) -from app.documents.utils import compute_diff, reconstruct_from_diffs from app.formats.txt import extract_txt_content from app.formats.xliff import SegmentState, extract_xliff_content from app.glossary.query import GlossaryQuery, NotFoundGlossaryExc @@ -33,7 +26,6 @@ from app.projects.query import NotFoundProjectExc, ProjectQuery from app.translation_memory.query import TranslationMemoryQuery from app.translation_memory.schema import ( - MemorySubstitution, TranslationMemory, TranslationMemoryListResponse, TranslationMemoryListSimilarResponse, @@ -54,10 +46,8 @@ class DocumentService: def __init__(self, db: Session): self.__db = db self.__query = GenericDocsQuery(db) - self.__comments_query = CommentsQuery(db) self.__glossary_query = GlossaryQuery(db) self.__tm_query = TranslationMemoryQuery(db) - self.__history_query = DocumentRecordHistoryQuery(db) def get_documents(self) -> list[doc_schema.DocumentWithRecordsCount]: """ @@ -323,157 +313,6 @@ def get_document_records( total_records=total_records, ) - @staticmethod - def _are_segments_mergeable( - old_history: DocumentRecordHistory, - new_author: int | None, - new_type: DocumentRecordHistoryChangeType, - ): - return ( - new_author is not None - and old_history.author_id == new_author - and old_history.change_type == new_type - ) - - def update_record( - self, - record_id: int, - data: doc_schema.DocumentRecordUpdate, - author_id: int, - change_type: DocumentRecordHistoryChangeType, - shallow: bool = False, - ) -> doc_schema.DocumentRecordUpdateResponse: - """ - Update a document record. - - Args: - record_id: Record ID - data: Updated record data - author_id: Author ID of these changes - change_type: Type of the change - shallow: Whether to apply repetitions to its descendants - - Returns: - DocumentRecordUpdateResponse object - - Raises: - EntityNotFound: If record not found - """ - try: - record = self._get_record_by_id(record_id) - old_target = record.target - updated_record = self.__query.update_record(record_id, data) - new_target = updated_record.target - - # TM tracking - if data.approved and not shallow: - for memory in record.document.memory_associations: - if memory.mode == TmMode.write: - self.__tm_query.add_or_update_record( - memory.tm_id, record.source, record.target - ) - break - - self.track_history( - record.id, old_target, new_target, author_id, change_type - ) - - # update repetitions - if data.approved and data.update_repetitions and not shallow: - updated_records = self.__query.get_record_ids_by_source( - record.document_id, record.source - ) - - for rec in updated_records: - # skip the current ID to avoid making more repetitions than needed - if rec == record.id: - continue - - self.update_record( - rec, - data, - author_id, - DocumentRecordHistoryChangeType.repetition, - shallow=True, - ) - - return doc_schema.DocumentRecordUpdateResponse.model_validate( - updated_record - ) - except NotFoundDocumentRecordExc: - raise EntityNotFound("Record not found") - - def track_history( - self, - record_id: int, - old_target: str, - new_target: str, - author_id: int, - change_type: DocumentRecordHistoryChangeType, - ): - # Track history if the target changed - if old_target == new_target: - return - - last_history = self.__history_query.get_last_history_by_record_id(record_id) - - if last_history and DocumentService._are_segments_mergeable( - last_history, - author_id, - change_type, - ): - # we need to reconstruct original string before doing a merge - all_history = list(self.__history_query.get_history_by_record_id(record_id)) - - diffs = [history.diff for history in all_history[1:]] - original_text = reconstruct_from_diffs(reversed(diffs)) - merged_diff = compute_diff(original_text, new_target) - - self.__history_query.update_history_entry( - last_history, merged_diff, datetime.now(UTC) - ) - else: - # diffs are not mergeable, create a new one - self.__history_query.create_history_entry( - record_id, - compute_diff(old_target, new_target), - author_id, - change_type, - ) - - def get_segment_history( - self, record_id: int - ) -> doc_schema.DocumentRecordHistoryListResponse: - """ - Get the history of changes for a document record. - - Args: - record_id: Document record ID - - Returns: - DocumentRecordHistoryResponse object - - Raises: - EntityNotFound: If record not found - """ - # Verify document record exists - self._get_record_by_id(record_id) - history_entries = self.__history_query.get_history_by_record_id(record_id) - history_list = [ - doc_schema.DocumentRecordHistory( - id=entry.id, - diff=entry.diff, - author=models.ShortUser.model_validate(entry.author) - if entry.author - else None, - timestamp=entry.timestamp, - change_type=entry.change_type, - ) - for entry in history_entries - ] - - return doc_schema.DocumentRecordHistoryListResponse(history=history_list) - def get_glossaries(self, doc_id: int) -> list[doc_schema.DocGlossary]: """ Get glossaries associated with a document. @@ -669,96 +508,6 @@ def search_tm_similar( total_records=len(records), ) - def get_comments(self, record_id: int) -> list[CommentResponse]: - """ - Get all comments for a document record. - - Args: - record_id: Document record ID - - Returns: - List of CommentResponse objects - - Raises: - EntityNotFound: If record not found - """ - # Verify document record exists - self._get_record_by_id(record_id) - - comments = self.__comments_query.get_comments_by_document_record(record_id) - return [CommentResponse.model_validate(comment) for comment in comments] - - def create_comment( - self, record_id: int, comment_data: CommentCreate, user_id: int - ) -> CommentResponse: - """ - Create a new comment for a document record. - - Args: - record_id: Document record ID - comment_data: Comment creation data - user_id: ID of user creating the comment - - Returns: - Created CommentResponse object - - Raises: - EntityNotFound: If record not found - """ - # Verify document record exists - self._get_record_by_id(record_id) - - comment = self.__comments_query.create_comment(comment_data, user_id, record_id) - return CommentResponse.model_validate(comment) - - def get_record_substitutions(self, record_id: int) -> list[MemorySubstitution]: - """ - Get substitution suggestions for a document record. - - Args: - record_id: Document record ID - - Returns: - List of MemorySubstitution objects - - Raises: - EntityNotFound: If record not found - """ - original_segment = self._get_record_by_id(record_id) - - tm_ids = [tm.id for tm in original_segment.document.memories] - return ( - self.__tm_query.get_substitutions(original_segment.source, tm_ids) - if tm_ids - else [] - ) - - def get_record_glossary_records(self, record_id: int) -> list[GlossaryRecordSchema]: - """ - Get glossary records matching a document record. - - Args: - record_id: Document record ID - - Returns: - List of GlossaryRecordSchema objects - - Raises: - EntityNotFound: If record not found - """ - original_segment = self._get_record_by_id(record_id) - glossary_ids = [gl.id for gl in original_segment.document.glossaries] - return ( - [ - GlossaryRecordSchema.model_validate(record) - for record in self.__glossary_query.get_glossary_records_for_phrase( - original_segment.source, glossary_ids - ) - ] - if glossary_ids - else [] - ) - def doc_glossary_search( self, doc_id: int, query: str ) -> list[GlossaryRecordSchema]: @@ -807,24 +556,6 @@ def _get_document_by_id(self, doc_id: int) -> Document: raise EntityNotFound("Document not found") return doc - def _get_record_by_id(self, record_id: int): - """ - Get a document record by ID. - - Args: - record_id: Record ID - - Returns: - DocumentRecord object - - Raises: - EntityNotFound: If record not found - """ - record = self.__query.get_record(record_id) - if not record: - raise EntityNotFound("Document record not found") - return record - def update_document( self, doc_id: int, update_data: doc_schema.DocumentUpdate, user_id: int ) -> doc_schema.DocumentUpdateResponse: diff --git a/backend/app/services/record_service.py b/backend/app/services/record_service.py new file mode 100644 index 0000000..7b3127b --- /dev/null +++ b/backend/app/services/record_service.py @@ -0,0 +1,293 @@ +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app import models +from app.base.exceptions import EntityNotFound +from app.comments.query import CommentsQuery +from app.comments.schema import CommentCreate, CommentResponse +from app.documents import schema as doc_schema +from app.documents.models import ( + DocumentRecordHistory, + DocumentRecordHistoryChangeType, + TmMode, +) +from app.documents.query import ( + DocumentRecordHistoryQuery, + GenericDocsQuery, +) +from app.documents.utils import compute_diff, reconstruct_from_diffs +from app.glossary.query import GlossaryQuery +from app.glossary.schema import GlossaryRecordSchema +from app.records.query import NotFoundDocumentRecordExc, RecordsQuery +from app.translation_memory.query import TranslationMemoryQuery +from app.translation_memory.schema import MemorySubstitution + + +class RecordService: + def __init__(self, db: Session): + self.__query = RecordsQuery(db) + self.__docs_query = GenericDocsQuery(db) + self.__comments_query = CommentsQuery(db) + self.__history_query = DocumentRecordHistoryQuery(db) + self.__tm_query = TranslationMemoryQuery(db) + self.__glossary_query = GlossaryQuery(db) + + def update_record( + self, + record_id: int, + data: doc_schema.DocumentRecordUpdate, + author_id: int, + change_type: DocumentRecordHistoryChangeType, + shallow: bool = False, + ) -> doc_schema.DocumentRecordUpdateResponse: + """ + Update a document record. + + Args: + record_id: Record ID + data: Updated record data + author_id: Author ID of these changes + change_type: Type of the change + shallow: Whether to apply repetitions to its descendants + + Returns: + DocumentRecordUpdateResponse object + + Raises: + EntityNotFound: If record not found + """ + try: + record = self._get_record_by_id(record_id) + old_target = record.target + updated_record = self.__query.update_record(record_id, data) + new_target = updated_record.target + + # TM tracking + if data.approved and not shallow: + for memory in record.document.memory_associations: + if memory.mode == TmMode.write: + self.__tm_query.add_or_update_record( + memory.tm_id, record.source, record.target + ) + break + + self.track_history( + record.id, old_target, new_target, author_id, change_type + ) + + # update repetitions + if data.approved and data.update_repetitions and not shallow: + updated_records = self.__docs_query.get_record_ids_by_source( + record.document_id, record.source + ) + + for rec in updated_records: + # skip the current ID to avoid making more repetitions than needed + if rec == record.id: + continue + + self.update_record( + rec, + data, + author_id, + DocumentRecordHistoryChangeType.repetition, + shallow=True, + ) + + return doc_schema.DocumentRecordUpdateResponse.model_validate( + updated_record + ) + except NotFoundDocumentRecordExc: + raise EntityNotFound("Record not found") + + def track_history( + self, + record_id: int, + old_target: str, + new_target: str, + author_id: int, + change_type: DocumentRecordHistoryChangeType, + ): + # Track history if the target changed + if old_target == new_target: + return + + last_history = self.__history_query.get_last_history_by_record_id(record_id) + + if last_history and RecordService._are_segments_mergeable( + last_history, + author_id, + change_type, + ): + # we need to reconstruct original string before doing a merge + all_history = list(self.__history_query.get_history_by_record_id(record_id)) + + diffs = [history.diff for history in all_history[1:]] + original_text = reconstruct_from_diffs(reversed(diffs)) + merged_diff = compute_diff(original_text, new_target) + + self.__history_query.update_history_entry( + last_history, merged_diff, datetime.now(UTC) + ) + else: + # diffs are not mergeable, create a new one + self.__history_query.create_history_entry( + record_id, + compute_diff(old_target, new_target), + author_id, + change_type, + ) + + @staticmethod + def _are_segments_mergeable( + old_history: DocumentRecordHistory, + new_author: int | None, + new_type: DocumentRecordHistoryChangeType, + ): + return ( + new_author is not None + and old_history.author_id == new_author + and old_history.change_type == new_type + ) + + def get_segment_history( + self, record_id: int + ) -> doc_schema.DocumentRecordHistoryListResponse: + """ + Get the history of changes for a document record. + + Args: + record_id: Document record ID + + Returns: + DocumentRecordHistoryResponse object + + Raises: + EntityNotFound: If record not found + """ + # Verify document record exists + self._get_record_by_id(record_id) + history_entries = self.__history_query.get_history_by_record_id(record_id) + history_list = [ + doc_schema.DocumentRecordHistory( + id=entry.id, + diff=entry.diff, + author=models.ShortUser.model_validate(entry.author) + if entry.author + else None, + timestamp=entry.timestamp, + change_type=entry.change_type, + ) + for entry in history_entries + ] + + return doc_schema.DocumentRecordHistoryListResponse(history=history_list) + + def get_comments(self, record_id: int) -> list[CommentResponse]: + """ + Get all comments for a document record. + + Args: + record_id: Document record ID + + Returns: + List of CommentResponse objects + + Raises: + EntityNotFound: If record not found + """ + # Verify document record exists + self._get_record_by_id(record_id) + + comments = self.__comments_query.get_comments_by_document_record(record_id) + return [CommentResponse.model_validate(comment) for comment in comments] + + def create_comment( + self, record_id: int, comment_data: CommentCreate, user_id: int + ) -> CommentResponse: + """ + Create a new comment for a document record. + + Args: + record_id: Document record ID + comment_data: Comment creation data + user_id: ID of user creating the comment + + Returns: + Created CommentResponse object + + Raises: + EntityNotFound: If record not found + """ + # Verify document record exists + self._get_record_by_id(record_id) + + comment = self.__comments_query.create_comment(comment_data, user_id, record_id) + return CommentResponse.model_validate(comment) + + def get_record_substitutions(self, record_id: int) -> list[MemorySubstitution]: + """ + Get substitution suggestions for a document record. + + Args: + record_id: Document record ID + + Returns: + List of MemorySubstitution objects + + Raises: + EntityNotFound: If record not found + """ + original_segment = self._get_record_by_id(record_id) + + tm_ids = [tm.id for tm in original_segment.document.memories] + return ( + self.__tm_query.get_substitutions(original_segment.source, tm_ids) + if tm_ids + else [] + ) + + def get_record_glossary_records(self, record_id: int) -> list[GlossaryRecordSchema]: + """ + Get glossary records matching a document record. + + Args: + record_id: Document record ID + + Returns: + List of GlossaryRecordSchema objects + + Raises: + EntityNotFound: If record not found + """ + original_segment = self._get_record_by_id(record_id) + glossary_ids = [gl.id for gl in original_segment.document.glossaries] + return ( + [ + GlossaryRecordSchema.model_validate(record) + for record in self.__glossary_query.get_glossary_records_for_phrase( + original_segment.source, glossary_ids + ) + ] + if glossary_ids + else [] + ) + + def _get_record_by_id(self, record_id: int): + """ + Get a document record by ID. + + Args: + record_id: Record ID + + Returns: + DocumentRecord object + + Raises: + EntityNotFound: If record not found + """ + record = self.__query.get_record(record_id) + if not record: + raise EntityNotFound("Document record not found") + return record diff --git a/backend/main.py b/backend/main.py index 0c5509a..561bfaf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,6 +7,7 @@ document, glossary, projects, + records, translation_memory, user, users, @@ -17,6 +18,7 @@ auth, comments, document, + records, translation_memory, user, users, diff --git a/backend/tests/routers/test_routes_comments.py b/backend/tests/routers/test_routes_comments.py index ba8b448..b86c3e4 100644 --- a/backend/tests/routers/test_routes_comments.py +++ b/backend/tests/routers/test_routes_comments.py @@ -48,7 +48,7 @@ def test_can_get_comments_for_record(user_logged_client: TestClient, session: Se s.add(comment) s.commit() - response = user_logged_client.get("/document/records/1/comments") + response = user_logged_client.get("/records/1/comments") assert response.status_code == 200 response_data = response.json() assert len(response_data) == 2 @@ -75,7 +75,7 @@ def test_get_comments_returns_empty_for_no_comments( ) s.commit() - response = user_logged_client.get("/document/records/1/comments") + response = user_logged_client.get("/records/1/comments") assert response.status_code == 200 response_data = response.json() assert response_data == [] @@ -85,7 +85,7 @@ def test_get_comments_returns_404_for_nonexistent_record( user_logged_client: TestClient, ): """Test getting comments for nonexistent record""" - response = user_logged_client.get("/document/records/999/comments") + response = user_logged_client.get("/records/999/comments") assert response.status_code == 404 assert response.json()["detail"] == "Document record not found" @@ -106,9 +106,7 @@ def test_can_create_comment(user_logged_client: TestClient, session: Session): s.commit() comment_data = {"text": "This is a test comment"} - response = user_logged_client.post( - "/document/records/1/comments", json=comment_data - ) + response = user_logged_client.post("/records/1/comments", json=comment_data) assert response.status_code == 200 response_data = response.json() assert response_data["text"] == "This is a test comment" @@ -123,9 +121,7 @@ def test_create_comment_returns_404_for_nonexistent_record( ): """Test creating comment for nonexistent record""" comment_data = {"text": "This is a test comment"} - response = user_logged_client.post( - "/document/records/999/comments", json=comment_data - ) + response = user_logged_client.post("/records/999/comments", json=comment_data) assert response.status_code == 404 assert response.json()["detail"] == "Document record not found" @@ -145,7 +141,7 @@ def test_create_comment_requires_text(user_logged_client: TestClient, session: S ) s.commit() - response = user_logged_client.post("/document/records/1/comments", json={}) + response = user_logged_client.post("/records/1/comments", json={}) assert response.status_code == 422 # Validation error @@ -166,9 +162,7 @@ def test_create_comment_requires_min_length_text( ) s.commit() - response = user_logged_client.post( - "/document/records/1/comments", json={"text": ""} - ) + response = user_logged_client.post("/records/1/comments", json={"text": ""}) assert response.status_code == 422 # Validation error @@ -190,7 +184,7 @@ def test_create_comment_requires_authentication( s.commit() comment_data = {"text": "This is a test comment"} - response = fastapi_client.post("/document/records/1/comments", json=comment_data) + response = fastapi_client.post("/records/1/comments", json=comment_data) assert response.status_code == 401 # Unauthorized @@ -362,13 +356,11 @@ def test_comment_endpoints_require_authentication(fastapi_client: TestClient): fastapi_client.cookies.clear() # Test GET comments - response = fastapi_client.get("/document/records/1/comments") + response = fastapi_client.get("/records/1/comments") assert response.status_code == 401 # Test POST comment - response = fastapi_client.post( - "/document/records/1/comments", json={"text": "test"} - ) + response = fastapi_client.post("/records/1/comments", json={"text": "test"}) assert response.status_code == 401 # Test PUT comment diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index 1d83729..d20f01f 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -170,7 +170,7 @@ def test_can_update_doc_record( ) s.commit() - response = user_logged_client.put("/document/records/2", json=arguments) + response = user_logged_client.put("/records/2", json=arguments) assert response.status_code == 200, response.text assert response.json() == { "id": 2, @@ -217,7 +217,7 @@ def test_record_approving_creates_memory( s.commit() response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated", "approved": True, @@ -272,7 +272,7 @@ def test_record_approving_updates_memory( s.commit() response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated", "approved": True, @@ -300,7 +300,7 @@ def test_returns_404_for_nonexistent_doc_when_updating_record( user_logged_client: TestClient, ): response = user_logged_client.put( - "/document/records/3", + "/records/3", json={ "target": "Updated", "approved": None, @@ -354,7 +354,7 @@ def test_can_update_doc_record_with_repetitions( # Update record 1 with repetition update enabled response = user_logged_client.put( - "/document/records/1", + "/records/1", json={"target": "Updated Hello", "approved": True, "update_repetitions": True}, ) assert response.status_code == 200 @@ -397,7 +397,7 @@ def test_update_repetitions_default_behavior( # Update without specifying update_repetitions (should default to False) response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated Hello", "approved": True, @@ -666,7 +666,7 @@ def test_update_repetitions_only_when_approved( # Update record 1 with repetition update enabled but NOT approved response = user_logged_client.put( - "/document/records/1", + "/records/1", json={"target": "Updated Hello", "approved": False, "update_repetitions": True}, ) assert response.status_code == 200 @@ -688,7 +688,7 @@ def test_update_repetitions_only_when_approved( # Now update record 1 with repetition update enabled AND approved response = user_logged_client.put( - "/document/records/1", + "/records/1", json={"target": "Final Hello", "approved": True, "update_repetitions": True}, ) assert response.status_code == 200 diff --git a/backend/tests/routers/test_routes_documents.py b/backend/tests/routers/test_routes_documents.py index b42069c..a271650 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -752,7 +752,7 @@ def test_can_get_glossaries_substitutions( ) dq.set_document_glossaries(dq.get_document(1), [g]) - response = user_logged_client.get("/document/records/1/glossary_records") + response = user_logged_client.get("/records/1/glossary_records") assert response.status_code == 200 response_json = response.json() assert len(response_json) == 1 @@ -788,7 +788,7 @@ def test_glossary_substitution_returns_404_for_non_existent_record( ) s.commit() - response = user_logged_client.get("/document/records/999/glossary_records") + response = user_logged_client.get("/records/999/glossary_records") assert response.status_code == 404 diff --git a/backend/tests/routers/test_routes_segment_history.py b/backend/tests/routers/test_routes_segment_history.py index bd2d866..ee8fc64 100644 --- a/backend/tests/routers/test_routes_segment_history.py +++ b/backend/tests/routers/test_routes_segment_history.py @@ -33,7 +33,7 @@ def test_get_segment_history_empty(user_logged_client: TestClient, session: Sess ) s.commit() - response = user_logged_client.get("/document/records/1/history") + response = user_logged_client.get("/records/1/history") assert response.status_code == 200 response_data = response.json() assert response_data["history"] == [] @@ -81,7 +81,7 @@ def test_get_segment_history_with_entries( history1_id = history1.id history2_id = history2.id - response = user_logged_client.get("/document/records/1/history") + response = user_logged_client.get("/records/1/history") assert response.status_code == 200 response_data = response.json() assert len(response_data["history"]) == 2 @@ -111,7 +111,7 @@ def test_get_segment_history_with_entries( def test_get_segment_history_404_for_nonexistent_record( user_logged_client: TestClient, ): - response = user_logged_client.get("/document/records/999/history") + response = user_logged_client.get("/records/999/history") assert response.status_code == 404 @@ -134,7 +134,7 @@ def test_update_record_creates_history( # Update record response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated translation", "approved": False, @@ -176,7 +176,7 @@ def test_update_record_with_repetitions_creates_history( # Update record response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated translation", "approved": True, @@ -246,7 +246,7 @@ def test_update_same_type_updates_in_place( initial_history_id = initial_history.id response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated first", "approved": False, @@ -304,7 +304,7 @@ def test_update_different_type_creates_new_history( # Update approved record with different target text response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Updated translation", "approved": False, @@ -389,7 +389,7 @@ def test_no_history_for_same_text(user_logged_client: TestClient, session: Sessi # Update with same text but different approval status response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Same text", "approved": False, @@ -460,7 +460,7 @@ def test_history_ordering_by_timestamp( history2_id = history2.id history3_id = history3.id - response = user_logged_client.get("/document/records/1/history") + response = user_logged_client.get("/records/1/history") assert response.status_code == 200 response_data = response.json() assert len(response_data["history"]) == 3 @@ -505,7 +505,7 @@ def test_merge_diffs_correctly_merges_consecutive_changes( # First update: "Test translation" -> "Hello World" response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Hello World", "approved": False, @@ -516,7 +516,7 @@ def test_merge_diffs_correctly_merges_consecutive_changes( # Second update: "Hello World" -> "Hello World!" (same author, same type) response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Hello World!", "approved": False, @@ -572,7 +572,7 @@ def test_merge_diffs_with_insert_only_operations( # First update: "Hi" -> "Hi there" response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Hi there", "approved": False, @@ -583,7 +583,7 @@ def test_merge_diffs_with_insert_only_operations( # Second update: "Hi there" -> "Hi there!" (same author, same type) response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Hi there!", "approved": False, @@ -651,7 +651,7 @@ def test_merge_diffs_with_multiple_history_records( # First update: "Replacement" -> "Hello World" response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Hello World", "approved": False, @@ -662,7 +662,7 @@ def test_merge_diffs_with_multiple_history_records( # Second update: "Hello World" -> "Hello World!" (same author, same type) response = user_logged_client.put( - "/document/records/1", + "/records/1", json={ "target": "Hello World!", "approved": False, From 8ca0b2d648870da88f3f279cc9aaaffbf0ceafa1 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 26 Jan 2026 23:04:24 +0300 Subject: [PATCH 06/13] Generate client & update mappings --- frontend/mocks/documentMocks.ts | 6 ++-- .../src/client/services/DocumentService.ts | 24 --------------- .../src/client/services/RecordsService.ts | 30 +++++++++++++++++++ .../document/RecordCommentModal.vue | 9 ++++-- .../document/SegmentHistoryModal.vue | 2 +- .../components/document/SubstitutionsList.vue | 12 +++----- frontend/src/views/DocView.vue | 2 +- 7 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 frontend/src/client/services/RecordsService.ts diff --git a/frontend/mocks/documentMocks.ts b/frontend/mocks/documentMocks.ts index b908096..1b3559d 100644 --- a/frontend/mocks/documentMocks.ts +++ b/frontend/mocks/documentMocks.ts @@ -4,16 +4,18 @@ import {faker, fakerRU} from '@faker-js/faker' import {glossaries} from './glossaryMocks' import {AwaitedReturnType} from './utils' import { - getComments, getDoc, getDocRecords, getDocs, getGlossaries, +} from '../src/client/services/DocumentService' +import { + getComments, getRecordGlossaryRecords, getRecordSubstitutions, getSegmentHistory, updateDocRecord, -} from '../src/client/services/DocumentService' +} from '../src/client/services/RecordsService' import {DocumentStatus} from '../src/client/schemas/DocumentStatus' import {DocumentRecordUpdate} from '../src/client/schemas/DocumentRecordUpdate' import {CommentResponse} from '../src/client/schemas/CommentResponse' diff --git a/frontend/src/client/services/DocumentService.ts b/frontend/src/client/services/DocumentService.ts index 51b1d62..bd8ed60 100644 --- a/frontend/src/client/services/DocumentService.ts +++ b/frontend/src/client/services/DocumentService.ts @@ -10,12 +10,6 @@ import {DocumentUpdateResponse} from '../schemas/DocumentUpdateResponse' import {DocumentUpdate} from '../schemas/DocumentUpdate' import {DocumentRecordListResponse} from '../schemas/DocumentRecordListResponse' import {GlossaryRecordSchema} from '../schemas/GlossaryRecordSchema' -import {CommentResponse} from '../schemas/CommentResponse' -import {CommentCreate} from '../schemas/CommentCreate' -import {MemorySubstitution} from '../schemas/MemorySubstitution' -import {DocumentRecordHistoryListResponse} from '../schemas/DocumentRecordHistoryListResponse' -import {DocumentRecordUpdateResponse} from '../schemas/DocumentRecordUpdateResponse' -import {DocumentRecordUpdate} from '../schemas/DocumentRecordUpdate' import {DocTranslationMemory} from '../schemas/DocTranslationMemory' import {DocTranslationMemoryUpdate} from '../schemas/DocTranslationMemoryUpdate' import {TranslationMemoryListResponse} from '../schemas/TranslationMemoryListResponse' @@ -47,24 +41,6 @@ export const getDocRecords = async (doc_id: number, page?: number | null, source export const docGlossarySearch = async (doc_id: number, query: string): Promise => { return await api.get(`/document/${doc_id}/glossary_search`, {query: {query}}) } -export const getComments = async (record_id: number): Promise => { - return await api.get(`/document/records/${record_id}/comments`) -} -export const createComment = async (record_id: number, content: CommentCreate): Promise => { - return await api.post(`/document/records/${record_id}/comments`, content) -} -export const getRecordSubstitutions = async (record_id: number): Promise => { - return await api.get(`/document/records/${record_id}/substitutions`) -} -export const getSegmentHistory = async (record_id: number): Promise => { - return await api.get(`/document/records/${record_id}/history`) -} -export const getRecordGlossaryRecords = async (record_id: number): Promise => { - return await api.get(`/document/records/${record_id}/glossary_records`) -} -export const updateDocRecord = async (record_id: number, content: DocumentRecordUpdate): Promise => { - return await api.put(`/document/records/${record_id}`, content) -} export const getTranslationMemories = async (doc_id: number): Promise => { return await api.get(`/document/${doc_id}/memories`) } diff --git a/frontend/src/client/services/RecordsService.ts b/frontend/src/client/services/RecordsService.ts new file mode 100644 index 0000000..5430d88 --- /dev/null +++ b/frontend/src/client/services/RecordsService.ts @@ -0,0 +1,30 @@ +// This file is autogenerated, do not edit directly. + +import {getApiBase, api} from '../defaults' + +import {DocumentRecordUpdateResponse} from '../schemas/DocumentRecordUpdateResponse' +import {DocumentRecordUpdate} from '../schemas/DocumentRecordUpdate' +import {CommentResponse} from '../schemas/CommentResponse' +import {CommentCreate} from '../schemas/CommentCreate' +import {MemorySubstitution} from '../schemas/MemorySubstitution' +import {DocumentRecordHistoryListResponse} from '../schemas/DocumentRecordHistoryListResponse' +import {GlossaryRecordSchema} from '../schemas/GlossaryRecordSchema' + +export const updateDocRecord = async (record_id: number, content: DocumentRecordUpdate): Promise => { + return await api.put(`/records/${record_id}`, content) +} +export const getComments = async (record_id: number): Promise => { + return await api.get(`/records/${record_id}/comments`) +} +export const createComment = async (record_id: number, content: CommentCreate): Promise => { + return await api.post(`/records/${record_id}/comments`, content) +} +export const getRecordSubstitutions = async (record_id: number): Promise => { + return await api.get(`/records/${record_id}/substitutions`) +} +export const getSegmentHistory = async (record_id: number): Promise => { + return await api.get(`/records/${record_id}/history`) +} +export const getRecordGlossaryRecords = async (record_id: number): Promise => { + return await api.get(`/records/${record_id}/glossary_records`) +} diff --git a/frontend/src/components/document/RecordCommentModal.vue b/frontend/src/components/document/RecordCommentModal.vue index 5268a61..994e40d 100644 --- a/frontend/src/components/document/RecordCommentModal.vue +++ b/frontend/src/components/document/RecordCommentModal.vue @@ -9,7 +9,7 @@ import Dialog from 'primevue/dialog' import InputText from 'primevue/inputtext' import ProgressSpinner from 'primevue/progressspinner' -import {createComment, getComments} from '../../client/services/DocumentService' +import {createComment, getComments} from '../../client/services/RecordsService' import {CommentResponse} from '../../client/schemas/CommentResponse' const props = defineProps<{ @@ -30,7 +30,7 @@ const {data, isLoading} = useQuery({ return await getComments(props.recordId) }, enabled: () => props.recordId !== -1, - placeholderData: (prevData: T) => prevData, + placeholderData: (prevData: T) => prevData, staleTime: 60 * 1000, }) @@ -79,7 +79,10 @@ const addComment = async () => { header="Made by" /> diff --git a/frontend/src/components/document/SegmentHistoryModal.vue b/frontend/src/components/document/SegmentHistoryModal.vue index 92bfa2c..83bfdf7 100644 --- a/frontend/src/components/document/SegmentHistoryModal.vue +++ b/frontend/src/components/document/SegmentHistoryModal.vue @@ -9,7 +9,7 @@ import Dialog from 'primevue/dialog' import DataTable from 'primevue/datatable' import Column from 'primevue/column' -import {getSegmentHistory} from '../../client/services/DocumentService' +import {getSegmentHistory} from '../../client/services/RecordsService' import type {DocumentRecordHistory} from '../../client/schemas/DocumentRecordHistory' import type {DocumentRecordHistoryChangeType} from '../../client/schemas/DocumentRecordHistoryChangeType' import { diff --git a/frontend/src/components/document/SubstitutionsList.vue b/frontend/src/components/document/SubstitutionsList.vue index 02aaa7d..155cc91 100644 --- a/frontend/src/components/document/SubstitutionsList.vue +++ b/frontend/src/components/document/SubstitutionsList.vue @@ -6,7 +6,7 @@ import {GlossarySubstitution, MemorySubstitution} from './types' import { getRecordGlossaryRecords, getRecordSubstitutions, -} from '../../client/services/DocumentService' +} from '../../client/services/RecordsService' import {useGlossaryStore} from '../../stores/glossary' const {documentId, currentSegmentId = undefined} = defineProps<{ @@ -27,17 +27,13 @@ const subClass = (sub: MemorySubstitution | GlossarySubstitution) => { const {data: substitutions} = useQuery({ key: () => ['substitutions', documentId, currentSegmentId ?? -1], query: async () => { - const memorySubs = ( - await getRecordSubstitutions(currentSegmentId!) - ) + const memorySubs = (await getRecordSubstitutions(currentSegmentId!)) .map((sub): MemorySubstitution => { return {type: 'memory', ...sub} }) .sort((a, b) => b.similarity - a.similarity) - const glossarySubs = ( - await getRecordGlossaryRecords(currentSegmentId!) - ) + const glossarySubs = (await getRecordGlossaryRecords(currentSegmentId!)) .map((sub): GlossarySubstitution => { return { type: 'glossary', @@ -55,7 +51,7 @@ const {data: substitutions} = useQuery({ return [...memorySubs, ...glossarySubs] }, enabled: () => currentSegmentId !== undefined, - placeholderData: (prevData: T) => prevData, + placeholderData: (prevData: T) => prevData, staleTime: 30 * 1000, }) diff --git a/frontend/src/views/DocView.vue b/frontend/src/views/DocView.vue index 40b2eaa..68e213c 100644 --- a/frontend/src/views/DocView.vue +++ b/frontend/src/views/DocView.vue @@ -22,9 +22,9 @@ import { getDoc, getDocRecords, getDownloadDocLink, - updateDocRecord, } from '../client/services/DocumentService' import {useDocStore} from '../stores/document' +import {updateDocRecord} from '../client/services/RecordsService' // TODO: 100 records per page is a magic number, it should be obtained from // the server side somehow From 3892baf8bdef9d33059f2acb921757c449a21c0e Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Thu, 29 Jan 2026 01:11:38 +0300 Subject: [PATCH 07/13] Update project requesting to be more consistent --- backend/app/documents/schema.py | 2 +- backend/app/projects/query.py | 29 +-- backend/app/routers/document.py | 7 - backend/app/routers/projects.py | 6 +- backend/app/services/document_service.py | 29 +-- backend/app/services/project_service.py | 88 ++++++--- .../tests/routers/test_routes_documents.py | 65 +------ backend/tests/routers/test_routes_projects.py | 182 ++++++++---------- 8 files changed, 165 insertions(+), 243 deletions(-) diff --git a/backend/app/documents/schema.py b/backend/app/documents/schema.py index 42a0ced..e232d46 100644 --- a/backend/app/documents/schema.py +++ b/backend/app/documents/schema.py @@ -23,7 +23,7 @@ class Document(Identified): class DocumentWithRecordsCount(Document): approved_records_count: int - records_count: int + total_records_count: int approved_word_count: int total_word_count: int diff --git a/backend/app/projects/query.py b/backend/app/projects/query.py index 120fe8f..b1ec050 100644 --- a/backend/app/projects/query.py +++ b/backend/app/projects/query.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +from typing import Sequence from sqlalchemy import case, func, select, update from sqlalchemy.orm import Session @@ -28,13 +29,12 @@ def _get_project(self, project_id: int) -> Project: raise NotFoundProjectExc() def list_projects(self, user_id: int) -> list[Project]: - """List all projects for a specific user.""" return list( self.__db.execute(select(Project).order_by(Project.id)).scalars().all() ) def create_project(self, user_id: int, data: ProjectCreate) -> Project: - project = Project(user_id=user_id, name=data.name) + project = Project(created_by=user_id, name=data.name) self.__db.add(project) self.__db.commit() return project @@ -57,11 +57,20 @@ def delete_project(self, project_id: int) -> bool: self.__db.commit() return True - def get_project_aggregates(self, project_id: int) -> tuple[int, int, int, int]: + def get_project_documents(self, project_id: int | None) -> Sequence[Document]: + docs = ( + self.__db.execute(select(Document).where(Document.project_id == project_id)) + .scalars() + .all() + ) + return docs + + def get_project_aggregates(self, project_id: int | None): """ Get aggregate metrics for a project. Returns a tuple containing: + - doc_id: Document ID - approved_segments_count: Total approved segments across all documents - total_segments_count: Total segments across all documents - approved_words_count: Total approved words across all documents @@ -71,11 +80,12 @@ def get_project_aggregates(self, project_id: int) -> tuple[int, int, int, int]: project_id: ID of the project Returns: - Tuple of four integers: (approved_segments_count, total_segments_count, - approved_words_count, total_words_count) + List of tuples of five integers: (doc_id, approved_segments_count, + total_segments_count, approved_words_count, total_words_count) """ stmt = ( select( + DocumentRecord.document_id.label("doc_id"), func.sum(case((DocumentRecord.approved.is_(True), 1), else_=0)).label( "approved_segments" ), @@ -91,12 +101,7 @@ def get_project_aggregates(self, project_id: int) -> tuple[int, int, int, int]: .select_from(DocumentRecord) .join(Document, DocumentRecord.document_id == Document.id) .where(Document.project_id == project_id) + .group_by("doc_id") ) - result = self.__db.execute(stmt).one() - return ( - result.approved_segments or 0, - result.total_segments or 0, - result.approved_words or 0, - result.total_words or 0, - ) + return self.__db.execute(stmt).all() diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index 5559b34..9a851f0 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -25,13 +25,6 @@ def get_service(db: Annotated[Session, Depends(get_db)]): return DocumentService(db) -@router.get("/") -def get_docs( - service: Annotated[DocumentService, Depends(get_service)], -) -> list[doc_schema.DocumentWithRecordsCount]: - return service.get_documents() - - @router.get("/{doc_id}") def get_doc( doc_id: int, diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index af35e23..cb805a5 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -7,9 +7,9 @@ from app.db import get_db from app.models import StatusMessage from app.projects.schema import ( + DetailedProjectResponse, ProjectCreate, ProjectResponse, - ProjectResponseWithWordsCount, ProjectUpdate, ) from app.services.project_service import ProjectService @@ -27,7 +27,7 @@ def get_service(db: Annotated[Session, Depends(get_db)]): @router.get( "/", description="Get a project list", - response_model=list[ProjectResponseWithWordsCount], + response_model=list[ProjectResponse], ) def list_projects( user_id: Annotated[int, Depends(get_current_user_id)], @@ -39,7 +39,7 @@ def list_projects( @router.get( path="/{project_id}", description="Get a single project", - response_model=ProjectResponseWithWordsCount, + response_model=DetailedProjectResponse, responses={ 404: { "description": "Project requested by id", diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 55e45fe..7432f4c 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -49,33 +49,6 @@ def __init__(self, db: Session): self.__glossary_query = GlossaryQuery(db) self.__tm_query = TranslationMemoryQuery(db) - def get_documents(self) -> list[doc_schema.DocumentWithRecordsCount]: - """ - Get list of all documents. - - Returns: - List of DocumentWithRecordsCount objects - """ - docs = self.__query.get_documents_list() - output = [] - for doc in docs: - records = self.__query.get_document_records_count_with_approved(doc) - words = self.__query.get_document_word_count_with_approved(doc) - output.append( - doc_schema.DocumentWithRecordsCount( - id=doc.id, - name=doc.name, - status=models.DocumentStatus(doc.processing_status), - created_by=doc.created_by, - type=doc.type.value, - approved_records_count=records[0], - records_count=records[1], - approved_word_count=words[0], - total_word_count=words[1], - ) - ) - return output - def get_document(self, doc_id: int) -> doc_schema.DocumentWithRecordsCount: """ Get a single document by ID. @@ -101,7 +74,7 @@ def get_document(self, doc_id: int) -> doc_schema.DocumentWithRecordsCount: created_by=doc.created_by, type=doc.type.value, approved_records_count=records[0], - records_count=records[1], + total_records_count=records[1], approved_word_count=words[0], total_word_count=words[1], ) diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index a3630c6..5b2c077 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -1,15 +1,18 @@ """Project service for project management operations.""" +from datetime import UTC, datetime + from sqlalchemy.orm import Session from app.base.exceptions import EntityNotFound -from app.models import ShortUser, StatusMessage +from app.documents.schema import DocumentWithRecordsCount +from app.models import DocumentStatus, StatusMessage from app.projects.models import Project from app.projects.query import NotFoundProjectExc, ProjectQuery from app.projects.schema import ( + DetailedProjectResponse, ProjectCreate, ProjectResponse, - ProjectResponseWithWordsCount, ProjectUpdate, ) @@ -20,7 +23,7 @@ class ProjectService: def __init__(self, db: Session): self.__query = ProjectQuery(db) - def list_projects(self, user_id: int) -> list[ProjectResponseWithWordsCount]: + def list_projects(self, user_id: int) -> list[ProjectResponse]: """ Get list of all projects for a user with aggregate metrics. @@ -32,27 +35,25 @@ def list_projects(self, user_id: int) -> list[ProjectResponseWithWordsCount]: """ projects = self.__query.list_projects(user_id) return [ - ProjectResponseWithWordsCount( + ProjectResponse( + id=-1, + name="Unnamed project", + created_by=-1, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + ] + [ + ProjectResponse( id=project.id, name=project.name, - user_id=project.user_id, - user=ShortUser.model_validate(project.user), + created_by=project.created_by, created_at=project.created_at, updated_at=project.updated_at, - approved_segments_count=aggregates[0], - total_segments_count=aggregates[1], - approved_words_count=aggregates[2], - total_words_count=aggregates[3], ) - for project, aggregates in [ - (project, self.__query.get_project_aggregates(project.id)) - for project in projects - ] + for project in projects ] - def get_project( - self, project_id: int, user_id: int - ) -> ProjectResponseWithWordsCount: + def get_project(self, project_id: int, user_id: int) -> DetailedProjectResponse: """ Get a single project by ID with aggregate metrics. @@ -68,20 +69,55 @@ def get_project( UnauthorizedAccess: If user doesn't own the project """ try: - project = self.__query._get_project(project_id) + project = ( + self.__query._get_project(project_id) + if project_id != -1 + else Project( + id=-1, + name="Unnamed project", + created_by=-1, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + ) self._check_ownership(project, user_id) - aggregates = self.__query.get_project_aggregates(project_id) - return ProjectResponseWithWordsCount( + aggregates = self.__query.get_project_aggregates( + project_id if project_id != -1 else None + ) + documents = self.__query.get_project_documents( + project_id if project_id != -1 else None + ) + + def find_doc(doc_id: int): + for aggregate in aggregates: + if aggregate[0] == doc_id: + return aggregate + return (0, 0, 0, 0, 0) + + return DetailedProjectResponse( id=project.id, name=project.name, - user_id=project.user_id, - user=ShortUser.model_validate(project.user), + created_by=project.created_by, created_at=project.created_at, updated_at=project.updated_at, - approved_segments_count=aggregates[0], - total_segments_count=aggregates[1], - approved_words_count=aggregates[2], - total_words_count=aggregates[3], + documents=[ + DocumentWithRecordsCount( + id=document.id, + name=document.name, + created_by=document.created_by, + status=DocumentStatus(document.processing_status), + type=document.type.value, + approved_records_count=find_doc(document.id)[1], + total_records_count=find_doc(document.id)[2], + approved_word_count=find_doc(document.id)[3], + total_word_count=find_doc(document.id)[4], + ) + for document in documents + ], + approved_records_count=sum([val[1] for val in aggregates]), + total_records_count=sum([val[2] for val in aggregates]), + approved_words_count=sum([val[3] for val in aggregates]), + total_words_count=sum([val[4] for val in aggregates]), ) except NotFoundProjectExc: raise EntityNotFound("Project", project_id) diff --git a/backend/tests/routers/test_routes_documents.py b/backend/tests/routers/test_routes_documents.py index a271650..cbb5f51 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -30,61 +30,6 @@ # pylint: disable=C0116 -def test_can_get_list_of_docs(user_logged_client: TestClient, session: Session): - with session as s: - s.add_all( - [ - Document( - name="first_doc.txt", - type=DocumentType.txt, - processing_status="pending", - records=[ - DocumentRecord( - source="Regional Effects", - target="Translation", - word_count=2, - ) - ], - created_by=1, - ), - Document( - name="another_doc.xliff", - type=DocumentType.xliff, - processing_status="done", - created_by=1, - ), - ] - ) - s.commit() - - response = user_logged_client.get("/document") - assert response.status_code == 200 - assert response.json() == [ - { - "id": 1, - "name": "first_doc.txt", - "status": "pending", - "created_by": 1, - "type": "txt", - "approved_records_count": 0, - "records_count": 1, - "approved_word_count": 0, - "total_word_count": 2, - }, - { - "id": 2, - "name": "another_doc.xliff", - "status": "done", - "created_by": 1, - "type": "xliff", - "approved_records_count": 0, - "records_count": 0, - "approved_word_count": 0, - "total_word_count": 0, - }, - ] - - def test_can_get_document(user_logged_client: TestClient, session: Session): with session as s: records = [ @@ -118,7 +63,7 @@ def test_can_get_document(user_logged_client: TestClient, session: Session): "status": "pending", "created_by": 1, "approved_records_count": 0, - "records_count": 2, + "total_records_count": 2, "type": "txt", "approved_word_count": 0, "total_word_count": 4, @@ -1106,7 +1051,7 @@ def test_update_document_project_only(user_logged_client: TestClient, session: S processing_status="done", created_by=1, ) - project = Project(user_id=1, name="Test Project") + project = Project(created_by=1, name="Test Project") session.add(doc) session.add(project) session.commit() @@ -1137,7 +1082,7 @@ def test_update_document_name_and_project( processing_status="done", created_by=1, ) - project = Project(user_id=1, name="Test Project") + project = Project(created_by=1, name="Test Project") session.add(doc) session.add(project) session.commit() @@ -1170,7 +1115,7 @@ def test_unassign_document_from_project( created_by=1, project_id=1, ) - project = Project(user_id=1, name="Test Project") + project = Project(created_by=1, name="Test Project") session.add(doc) session.add(project) session.commit() @@ -1268,7 +1213,7 @@ def test_update_document_to_same_project( created_by=1, project_id=1, ) - project = Project(user_id=1, name="Test Project") + project = Project(created_by=1, name="Test Project") session.add(doc) session.add(project) session.commit() diff --git a/backend/tests/routers/test_routes_projects.py b/backend/tests/routers/test_routes_projects.py index 95848be..462abdb 100644 --- a/backend/tests/routers/test_routes_projects.py +++ b/backend/tests/routers/test_routes_projects.py @@ -10,7 +10,6 @@ def test_create_project(user_logged_client: TestClient, session: Session): - """POST /projects/""" expected_name = "Test Project" path = app.url_path_for("create_project") @@ -19,15 +18,13 @@ def test_create_project(user_logged_client: TestClient, session: Session): assert response.status_code == status.HTTP_201_CREATED assert response_json["name"] == expected_name - assert response_json["user_id"] == 1 + assert response_json["created_by"] == 1 assert "id" in response_json assert "created_at" in response_json assert "updated_at" in response_json - assert response_json["user"]["id"] == 1 def test_create_project_validation_error(user_logged_client: TestClient): - """POST /projects/ - validation error for empty name""" path = app.url_path_for("create_project") response = user_logged_client.post(url=path, json={"name": ""}) @@ -36,7 +33,6 @@ def test_create_project_validation_error(user_logged_client: TestClient): def test_list_projects(user_logged_client: TestClient, session: Session): - """GET /projects/""" path = app.url_path_for("list_projects") project_1 = ProjectQuery(session).create_project( @@ -50,13 +46,13 @@ def test_list_projects(user_logged_client: TestClient, session: Session): response_json = response.json() assert response.status_code == status.HTTP_200_OK - assert len(response_json) == 2 - assert response_json[0]["name"] == project_1.name - assert response_json[1]["name"] == project_2.name + assert len(response_json) == 3 + assert response_json[0]["name"] == "Unnamed project" + assert response_json[1]["name"] == project_1.name + assert response_json[2]["name"] == project_2.name def test_retrieve_project(user_logged_client: TestClient, session: Session): - """GET /projects/{project_id}/""" project = ProjectQuery(session).create_project( user_id=1, data=ProjectCreate(name="Test Project") ) @@ -68,12 +64,10 @@ def test_retrieve_project(user_logged_client: TestClient, session: Session): assert response.status_code == status.HTTP_200_OK assert response_json["id"] == project.id assert response_json["name"] == project.name - assert response_json["user_id"] == 1 - assert response_json["user"]["id"] == 1 + assert response_json["created_by"] == 1 def test_retrieve_project_unauthorized(fastapi_client: TestClient, session: Session): - """GET /projects/{project_id}/ - 403 for accessing another user's project""" project = ProjectQuery(session).create_project( user_id=2, data=ProjectCreate(name="User 2 Project") ) @@ -84,7 +78,6 @@ def test_retrieve_project_unauthorized(fastapi_client: TestClient, session: Sess def test_retrieve_project_not_found(user_logged_client: TestClient): - """GET /projects/{project_id}/ - 404 for non-existent project""" response = user_logged_client.get("/projects/999") assert response.status_code == status.HTTP_404_NOT_FOUND @@ -92,7 +85,6 @@ def test_retrieve_project_not_found(user_logged_client: TestClient): def test_update_project(user_logged_client: TestClient, session: Session): - """PUT /projects/{project_id}/""" project = ProjectQuery(session).create_project( user_id=1, data=ProjectCreate(name="Original Name") ) @@ -109,7 +101,6 @@ def test_update_project(user_logged_client: TestClient, session: Session): def test_update_project_unauthorized(fastapi_client: TestClient, session: Session): - """PUT /projects/{project_id}/ - 403 for updating another user's project""" project = ProjectQuery(session).create_project( user_id=2, data=ProjectCreate(name="User 2 Project") ) @@ -121,7 +112,6 @@ def test_update_project_unauthorized(fastapi_client: TestClient, session: Sessio def test_update_project_not_found(user_logged_client: TestClient): - """PUT /projects/{project_id}/ - 404 for non-existent project""" response = user_logged_client.put("/projects/999", json={"name": "Updated Name"}) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -129,7 +119,6 @@ def test_update_project_not_found(user_logged_client: TestClient): def test_delete_project(user_logged_client: TestClient, session: Session): - """DELETE /projects/{project_id}/""" project = ProjectQuery(session).create_project( user_id=1, data=ProjectCreate(name="Test Project") ) @@ -143,7 +132,6 @@ def test_delete_project(user_logged_client: TestClient, session: Session): def test_delete_project_unauthorized(fastapi_client: TestClient, session: Session): - """DELETE /projects/{project_id}/ - 403 for deleting another user's project""" project = ProjectQuery(session).create_project( user_id=2, data=ProjectCreate(name="User 2 Project") ) @@ -155,28 +143,27 @@ def test_delete_project_unauthorized(fastapi_client: TestClient, session: Sessio def test_delete_project_not_found(user_logged_client: TestClient): - """DELETE /projects/{project_id}/ - 404 for non-existent project""" response = user_logged_client.delete("/projects/999") assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json()["detail"] == "Project with id 999 not found" -def test_list_projects_with_aggregates( +def test_retrieve_project_with_aggregates( user_logged_client: TestClient, session: Session ): - """GET /projects/ - returns aggregate metrics for projects with documents""" with session as s: - project = Project(user_id=1, name="Test Project") + project = Project(created_by=1, name="Test Project") s.add(project) s.flush() + project_id = project.id - doc1 = Document( - name="doc1.txt", + doc = Document( + name="doc.txt", type=DocumentType.txt, processing_status="done", created_by=1, - project_id=project.id, + project_id=project_id, records=[ DocumentRecord( source="Hello", target="Привет", approved=True, word_count=1 @@ -184,69 +171,55 @@ def test_list_projects_with_aggregates( DocumentRecord( source="World", target="Мир", approved=False, word_count=1 ), - ], - ) - doc2 = Document( - name="doc2.txt", - type=DocumentType.txt, - processing_status="done", - created_by=1, - project_id=project.id, - records=[ - DocumentRecord( - source="Test", target="Тест", approved=True, word_count=1 - ), DocumentRecord( - source="Data", target="Данные", approved=True, word_count=1 + source="Test", target="Тест", approved=True, word_count=2 ), ], ) - s.add_all([doc1, doc2]) + s.add(doc) s.commit() - response = user_logged_client.get("/projects/") + response = user_logged_client.get(f"/projects/{project_id}") assert response.status_code == status.HTTP_200_OK response_json = response.json() - assert len(response_json) == 1 - - project_data = response_json[0] - assert project_data["name"] == "Test Project" - # 3 approved records (Hello, Test, Data) - assert project_data["approved_segments_count"] == 3 - # 4 total records (Hello, World, Test, Data) - assert project_data["total_segments_count"] == 4 - # 3 approved words (1 + 1 + 1) - assert project_data["approved_words_count"] == 3 - # 4 total words (1 + 1 + 1 + 1) - assert project_data["total_words_count"] == 4 - - -def test_list_projects_empty_project(user_logged_client: TestClient, session: Session): - """GET /projects/ - returns zeros for projects without documents""" + + assert response_json["id"] == project_id + assert response_json["name"] == "Test Project" + # 2 approved records (Hello, Test) + assert response_json["approved_records_count"] == 2 + # 3 total records (Hello, World, Test) + assert response_json["total_records_count"] == 3 + # 3 approved words (1 + 2) + assert response_json["approved_words_count"] == 3 + # 4 total words (1 + 1 + 2) + assert response_json["total_words_count"] == 4 + assert len(response_json["documents"]) == 1 + + +def test_retrieve_project_empty(user_logged_client: TestClient, session: Session): with session as s: - project = Project(user_id=1, name="Empty Project") + project = Project(created_by=1, name="Empty Project") s.add(project) s.commit() + project_id = project.id - response = user_logged_client.get("/projects/") + response = user_logged_client.get(f"/projects/{project_id}") assert response.status_code == status.HTTP_200_OK response_json = response.json() - assert len(response_json) == 1 - project_data = response_json[0] - assert project_data["name"] == "Empty Project" - assert project_data["approved_segments_count"] == 0 - assert project_data["total_segments_count"] == 0 - assert project_data["approved_words_count"] == 0 - assert project_data["total_words_count"] == 0 + assert response_json["id"] == project_id + assert response_json["name"] == "Empty Project" + assert response_json["approved_records_count"] == 0 + assert response_json["total_records_count"] == 0 + assert response_json["approved_words_count"] == 0 + assert response_json["total_words_count"] == 0 -def test_list_projects_project_with_documents_no_records( +def test_retrieve_project_project_with_documents_no_records( user_logged_client: TestClient, session: Session ): - """GET /projects/ - returns zeros for projects with documents but no records""" with session as s: - project = Project(user_id=1, name="Project with Empty Docs") + project = Project(created_by=1, name="Project with Empty Docs") s.add(project) s.flush() @@ -260,35 +233,48 @@ def test_list_projects_project_with_documents_no_records( s.add(doc) s.commit() - response = user_logged_client.get("/projects/") + response = user_logged_client.get("/projects/1") assert response.status_code == status.HTTP_200_OK - response_json = response.json() - assert len(response_json) == 1 - project_data = response_json[0] + project_data = response.json() assert project_data["name"] == "Project with Empty Docs" - assert project_data["approved_segments_count"] == 0 - assert project_data["total_segments_count"] == 0 + assert project_data["approved_records_count"] == 0 + assert project_data["total_records_count"] == 0 assert project_data["approved_words_count"] == 0 assert project_data["total_words_count"] == 0 -def test_retrieve_project_with_aggregates( - user_logged_client: TestClient, session: Session -): - """GET /projects/{project_id}/ - returns aggregate metrics for a single project""" +def test_retrieve_unnamed_project(user_logged_client: TestClient, session: Session): with session as s: - project = Project(user_id=1, name="Test Project") + project = Project(created_by=1, name="Test Project") s.add(project) s.flush() project_id = project.id - doc = Document( + doc1 = Document( name="doc.txt", type=DocumentType.txt, processing_status="done", created_by=1, project_id=project_id, + records=[ + DocumentRecord( + source="Hello", target="Привет", approved=True, word_count=10 + ), + DocumentRecord( + source="World", target="Мир", approved=False, word_count=10 + ), + DocumentRecord( + source="Test", target="Тест", approved=True, word_count=20 + ), + ], + ) + doc2 = Document( + name="doc.txt", + type=DocumentType.txt, + processing_status="done", + created_by=1, + project_id=None, records=[ DocumentRecord( source="Hello", target="Привет", approved=True, word_count=1 @@ -301,40 +287,24 @@ def test_retrieve_project_with_aggregates( ), ], ) - s.add(doc) + + s.add(doc1) + s.add(doc2) s.commit() - response = user_logged_client.get(f"/projects/{project_id}") + response = user_logged_client.get("/projects/-1") assert response.status_code == status.HTTP_200_OK response_json = response.json() - assert response_json["id"] == project_id - assert response_json["name"] == "Test Project" + assert response_json["id"] == -1 + assert response_json["name"] == "Unnamed project" # 2 approved records (Hello, Test) - assert response_json["approved_segments_count"] == 2 + assert response_json["approved_records_count"] == 2 # 3 total records (Hello, World, Test) - assert response_json["total_segments_count"] == 3 + assert response_json["total_records_count"] == 3 # 3 approved words (1 + 2) assert response_json["approved_words_count"] == 3 # 4 total words (1 + 1 + 2) assert response_json["total_words_count"] == 4 - - -def test_retrieve_project_empty(user_logged_client: TestClient, session: Session): - """GET /projects/{project_id}/ - returns zeros for empty project""" - with session as s: - project = Project(user_id=1, name="Empty Project") - s.add(project) - s.commit() - project_id = project.id - - response = user_logged_client.get(f"/projects/{project_id}") - assert response.status_code == status.HTTP_200_OK - response_json = response.json() - - assert response_json["id"] == project_id - assert response_json["name"] == "Empty Project" - assert response_json["approved_segments_count"] == 0 - assert response_json["total_segments_count"] == 0 - assert response_json["approved_words_count"] == 0 - assert response_json["total_words_count"] == 0 + assert len(response_json["documents"]) == 1 + assert response_json["documents"][0]["id"] == 2 From 8369ad56db6328f859449c623354a2450a9ff5c4 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Thu, 29 Jan 2026 01:13:17 +0300 Subject: [PATCH 08/13] Generate client again --- .../client/schemas/DetailedProjectResponse.ts | 16 ++++++++++++++++ .../client/schemas/DocumentWithRecordsCount.ts | 2 +- frontend/src/client/schemas/ProjectResponse.ts | 5 +---- .../schemas/ProjectResponseWithWordsCount.ts | 16 ---------------- frontend/src/client/services/DocumentService.ts | 17 +++++++---------- frontend/src/client/services/ProjectsService.ts | 10 +++++----- 6 files changed, 30 insertions(+), 36 deletions(-) create mode 100644 frontend/src/client/schemas/DetailedProjectResponse.ts delete mode 100644 frontend/src/client/schemas/ProjectResponseWithWordsCount.ts diff --git a/frontend/src/client/schemas/DetailedProjectResponse.ts b/frontend/src/client/schemas/DetailedProjectResponse.ts new file mode 100644 index 0000000..78593ba --- /dev/null +++ b/frontend/src/client/schemas/DetailedProjectResponse.ts @@ -0,0 +1,16 @@ +// This file is autogenerated, do not edit directly. + +import {DocumentWithRecordsCount} from './DocumentWithRecordsCount' + +export interface DetailedProjectResponse { + id: number + created_at: string + updated_at: string + name: string + created_by: number + documents: DocumentWithRecordsCount[] + approved_records_count: number + total_records_count: number + approved_words_count: number + total_words_count: number +} diff --git a/frontend/src/client/schemas/DocumentWithRecordsCount.ts b/frontend/src/client/schemas/DocumentWithRecordsCount.ts index 2e9424a..096d577 100644 --- a/frontend/src/client/schemas/DocumentWithRecordsCount.ts +++ b/frontend/src/client/schemas/DocumentWithRecordsCount.ts @@ -9,7 +9,7 @@ export interface DocumentWithRecordsCount { created_by: number type: string approved_records_count: number - records_count: number + total_records_count: number approved_word_count: number total_word_count: number } diff --git a/frontend/src/client/schemas/ProjectResponse.ts b/frontend/src/client/schemas/ProjectResponse.ts index b393e4d..a2bb95c 100644 --- a/frontend/src/client/schemas/ProjectResponse.ts +++ b/frontend/src/client/schemas/ProjectResponse.ts @@ -1,12 +1,9 @@ // This file is autogenerated, do not edit directly. -import {ShortUser} from './ShortUser' - export interface ProjectResponse { id: number created_at: string updated_at: string name: string - user_id: number - user: ShortUser + created_by: number } diff --git a/frontend/src/client/schemas/ProjectResponseWithWordsCount.ts b/frontend/src/client/schemas/ProjectResponseWithWordsCount.ts deleted file mode 100644 index 3ba7368..0000000 --- a/frontend/src/client/schemas/ProjectResponseWithWordsCount.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file is autogenerated, do not edit directly. - -import {ShortUser} from './ShortUser' - -export interface ProjectResponseWithWordsCount { - id: number - created_at: string - updated_at: string - name: string - user_id: number - user: ShortUser - approved_segments_count: number - total_segments_count: number - approved_words_count: number - total_words_count: number -} diff --git a/frontend/src/client/services/DocumentService.ts b/frontend/src/client/services/DocumentService.ts index bd8ed60..aa72190 100644 --- a/frontend/src/client/services/DocumentService.ts +++ b/frontend/src/client/services/DocumentService.ts @@ -3,8 +3,6 @@ import {getApiBase, api} from '../defaults' import {DocumentWithRecordsCount} from '../schemas/DocumentWithRecordsCount' -import {Document} from '../schemas/Document' -import {Body_create_doc_document__post} from '../schemas/Body_create_doc_document__post' import {StatusMessage} from '../schemas/StatusMessage' import {DocumentUpdateResponse} from '../schemas/DocumentUpdateResponse' import {DocumentUpdate} from '../schemas/DocumentUpdate' @@ -16,16 +14,10 @@ import {TranslationMemoryListResponse} from '../schemas/TranslationMemoryListRes import {TranslationMemoryListSimilarResponse} from '../schemas/TranslationMemoryListSimilarResponse' import {DocGlossary} from '../schemas/DocGlossary' import {DocGlossaryUpdate} from '../schemas/DocGlossaryUpdate' +import {Document} from '../schemas/Document' +import {Body_create_doc_document__post} from '../schemas/Body_create_doc_document__post' import {DocumentProcessingSettings} from '../schemas/DocumentProcessingSettings' -export const getDocs = async (): Promise => { - return await api.get(`/document/`) -} -export const createDoc = async (data: Body_create_doc_document__post): Promise => { - const formData = new FormData() - formData.append('file', data.file) - return await api.post(`/document/`, formData) -} export const getDoc = async (doc_id: number): Promise => { return await api.get(`/document/${doc_id}`) } @@ -59,6 +51,11 @@ export const getGlossaries = async (doc_id: number): Promise => { export const setGlossaries = async (doc_id: number, content: DocGlossaryUpdate): Promise => { return await api.post(`/document/${doc_id}/glossaries`, content) } +export const createDoc = async (data: Body_create_doc_document__post): Promise => { + const formData = new FormData() + formData.append('file', data.file) + return await api.post(`/document/`, formData) +} export const processDoc = async (doc_id: number, content: DocumentProcessingSettings): Promise => { return await api.post(`/document/${doc_id}/process`, content) } diff --git a/frontend/src/client/services/ProjectsService.ts b/frontend/src/client/services/ProjectsService.ts index 73979c7..cfc7ae5 100644 --- a/frontend/src/client/services/ProjectsService.ts +++ b/frontend/src/client/services/ProjectsService.ts @@ -2,20 +2,20 @@ import {getApiBase, api} from '../defaults' -import {ProjectResponseWithWordsCount} from '../schemas/ProjectResponseWithWordsCount' import {ProjectResponse} from '../schemas/ProjectResponse' import {ProjectCreate} from '../schemas/ProjectCreate' +import {DetailedProjectResponse} from '../schemas/DetailedProjectResponse' import {ProjectUpdate} from '../schemas/ProjectUpdate' import {StatusMessage} from '../schemas/StatusMessage' -export const listProjects = async (): Promise => { - return await api.get(`/projects/`) +export const listProjects = async (): Promise => { + return await api.get(`/projects/`) } export const createProject = async (content: ProjectCreate): Promise => { return await api.post(`/projects/`, content) } -export const retrieveProject = async (project_id: number): Promise => { - return await api.get(`/projects/${project_id}`) +export const retrieveProject = async (project_id: number): Promise => { + return await api.get(`/projects/${project_id}`) } export const updateProject = async (project_id: number, content: ProjectUpdate): Promise => { return await api.put(`/projects/${project_id}`, content) From aa935137ac196c79382dd950818c4fd4ad08b8e0 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Thu, 29 Jan 2026 01:30:39 +0300 Subject: [PATCH 09/13] Add initial projects presentation --- frontend/mocks/documentMocks.ts | 6 +- .../src/components/DocUploadingDialog.vue | 2 +- frontend/src/components/ProjectList.vue | 31 ++++++ frontend/src/components/ProjectListItem.vue | 89 +++++++++++++++++ frontend/src/stores/document.ts | 27 ------ frontend/src/views/DocView.vue | 17 +++- frontend/src/views/IndexView.vue | 97 ++++++++----------- 7 files changed, 174 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/ProjectList.vue create mode 100644 frontend/src/components/ProjectListItem.vue delete mode 100644 frontend/src/stores/document.ts diff --git a/frontend/mocks/documentMocks.ts b/frontend/mocks/documentMocks.ts index 1b3559d..d984038 100644 --- a/frontend/mocks/documentMocks.ts +++ b/frontend/mocks/documentMocks.ts @@ -6,7 +6,6 @@ import {AwaitedReturnType} from './utils' import { getDoc, getDocRecords, - getDocs, getGlossaries, } from '../src/client/services/DocumentService' import { @@ -232,7 +231,7 @@ const docs: DocumentWithRecordsCount[] = [ { id: 1, created_by: 12, - records_count: segments.length, + total_records_count: segments.length, approved_records_count: segments.filter(({approved}) => approved).length, total_word_count: 20, approved_word_count: 4, @@ -243,9 +242,6 @@ const docs: DocumentWithRecordsCount[] = [ ] export const documentMocks = [ - http.get('http://localhost:8000/document/', () => - HttpResponse.json>(docs) - ), http.get<{id: string}>('http://localhost:8000/document/:id', ({params}) => { const doc = docs.find((doc) => doc.id === Number(params.id)) if (doc !== undefined) { diff --git a/frontend/src/components/DocUploadingDialog.vue b/frontend/src/components/DocUploadingDialog.vue index a60a0af..4c46136 100644 --- a/frontend/src/components/DocUploadingDialog.vue +++ b/frontend/src/components/DocUploadingDialog.vue @@ -187,7 +187,7 @@ const selectedGlossaries = ref([]) diff --git a/frontend/src/components/ProjectList.vue b/frontend/src/components/ProjectList.vue new file mode 100644 index 0000000..7a6bde9 --- /dev/null +++ b/frontend/src/components/ProjectList.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ProjectListItem.vue b/frontend/src/components/ProjectListItem.vue new file mode 100644 index 0000000..be8d1c3 --- /dev/null +++ b/frontend/src/components/ProjectListItem.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/stores/document.ts b/frontend/src/stores/document.ts deleted file mode 100644 index 4ae10da..0000000 --- a/frontend/src/stores/document.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {acceptHMRUpdate, defineStore} from 'pinia' - -import {DocumentWithRecordsCount} from '../client/schemas/DocumentWithRecordsCount' -import {getDoc, getDocs} from '../client/services/DocumentService' - -export const useDocStore = defineStore('document', { - state() { - return { - docs: [] as DocumentWithRecordsCount[], - } - }, - actions: { - async fetchDocs() { - this.docs = await getDocs() - }, - async updateDocument(id: number) { - const idx = this.docs.findIndex((doc) => doc.id === id) - if (idx !== -1) { - this.docs[idx] = await getDoc(this.docs[idx].id) - } - }, - }, -}) - -if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useDocStore, import.meta.hot)) -} diff --git a/frontend/src/views/DocView.vue b/frontend/src/views/DocView.vue index 68e213c..b120dd9 100644 --- a/frontend/src/views/DocView.vue +++ b/frontend/src/views/DocView.vue @@ -1,7 +1,7 @@