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/documents/query.py b/backend/app/documents/query.py index 4ca230c..f7b4a1e 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,8 +24,8 @@ ) -class NotFoundDocumentRecordExc(BaseQueryException): - """Exception raised when document record not found""" +class NotFoundDocumentExc(BaseQueryException): + """Exception raised when document not found""" class GenericDocsQuery: @@ -202,24 +202,20 @@ 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 - + 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 + if project_id is not None: + document.project_id = project_id if project_id != -1 else None self.__db.commit() - return record + self.__db.refresh(document) + return document def get_record_ids_by_source(self, doc_id: int, source: str) -> list[int]: return list( diff --git a/backend/app/documents/schema.py b/backend/app/documents/schema.py index 26c3371..68b5b37 100644 --- a/backend/app/documents/schema.py +++ b/backend/app/documents/schema.py @@ -19,11 +19,12 @@ class Document(Identified): status: DocumentStatus created_by: int type: Literal["xliff", "txt"] + project_id: int | None class DocumentWithRecordsCount(Document): approved_records_count: int - records_count: int + total_records_count: int approved_word_count: int total_word_count: int @@ -100,3 +101,26 @@ 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 -1 to unassign.", + ) + + 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/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..b1ec050 --- /dev/null +++ b/backend/app/projects/query.py @@ -0,0 +1,107 @@ +from datetime import UTC, datetime +from typing import Sequence + +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 + + +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]: + 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(created_by=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 + + 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 + - total_words_count: Total words across all documents + + Args: + project_id: ID of the project + + Returns: + 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" + ), + 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) + .group_by("doc_id") + ) + + return self.__db.execute(stmt).all() 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/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 b3a6eaa..9a851f0 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -5,15 +5,12 @@ from sqlalchemy.orm import Session from app import models -from app.base.exceptions import BusinessLogicError, EntityNotFound -from app.comments.schema import CommentCreate, CommentResponse +from app.base.exceptions import BusinessLogicError, EntityNotFound, UnauthorizedAccess 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, ) @@ -28,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, @@ -85,83 +75,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, @@ -288,3 +201,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/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..cb805a5 --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,153 @@ +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 ( + DetailedProjectResponse, + 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], +) +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=DetailedProjectResponse, + 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, + 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, + 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/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/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/document_service.py b/backend/app/services/document_service.py index b34acb4..2f233e8 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,30 +9,23 @@ 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 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, TranslationMemory, TranslationMemoryListResponse, TranslationMemoryListSimilarResponse, @@ -53,37 +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]: - """ - 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: """ @@ -109,8 +73,9 @@ def get_document(self, doc_id: int) -> doc_schema.DocumentWithRecordsCount: status=models.DocumentStatus(doc.processing_status), created_by=doc.created_by, type=doc.type.value, + project_id=doc.project_id, approved_records_count=records[0], - records_count=records[1], + total_records_count=records[1], approved_word_count=words[0], total_word_count=words[1], ) @@ -164,6 +129,7 @@ async def create_document( status=models.DocumentStatus(doc.processing_status), created_by=doc.created_by, type=doc.type.value, + project_id=None, ) def delete_document(self, doc_id: int) -> models.StatusMessage: @@ -322,157 +288,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. @@ -517,7 +332,9 @@ def set_glossaries( if not glossary_ids: glossaries = [] else: - glossaries = list(self.__glossary_query.get_glossaries(list(glossary_ids))) + glossaries = list( + self.__glossary_query.get_glossaries(list(glossary_ids)) + ) except NotFoundGlossaryExc: raise EntityNotFound("Glossary not found") @@ -668,96 +485,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]: @@ -806,23 +533,41 @@ 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): + def update_document( + self, doc_id: int, update_data: doc_schema.DocumentUpdate, user_id: int + ) -> doc_schema.DocumentUpdateResponse: """ - Get a document record by ID. + Update a document (name and/or project_id). Args: - record_id: Record ID + doc_id: Document ID + update_data: DocumentUpdate object with optional name and project_id + user_id: ID of user performing action Returns: - DocumentRecord object + DocumentUpdateResponse object Raises: - EntityNotFound: If record not found + EntityNotFound: If document or project not found + UnauthorizedAccess: If user doesn't own project """ - record = self.__query.get_record(record_id) - if not record: - raise EntityNotFound("Document record not found") - return record + self._get_document_by_id(doc_id) + try: + if update_data.project_id is not None and update_data.project_id != -1: + 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 = "" diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py new file mode 100644 index 0000000..57711a0 --- /dev/null +++ b/backend/app/services/project_service.py @@ -0,0 +1,202 @@ +"""Project service for project management operations.""" + +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app.base.exceptions import EntityNotFound +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, + ProjectUpdate, +) + + +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 with aggregate metrics. + + Args: + user_id: ID of user + + Returns: + List of ProjectResponse objects + """ + projects = self.__query.list_projects(user_id) + return [ + 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, + created_by=project.created_by, + created_at=project.created_at, + updated_at=project.updated_at, + ) + for project in projects + ] + + def get_project(self, project_id: int, user_id: int) -> DetailedProjectResponse: + """ + Get a single project by ID with aggregate metrics. + + 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) + 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 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, + created_by=project.created_by, + created_at=project.created_at, + updated_at=project.updated_at, + documents=[ + DocumentWithRecordsCount( + id=document.id, + name=document.name, + created_by=document.created_by, + status=DocumentStatus(document.processing_status), + type=document.type.value, + project_id=project.id, + 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) + + 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/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 01ed0f9..561bfaf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,13 +6,25 @@ comments, document, glossary, + projects, + records, translation_memory, user, users, ) from app.settings import settings -ROUTERS = (auth, comments, document, translation_memory, user, users, glossary) +ROUTERS = ( + auth, + comments, + document, + records, + translation_memory, + user, + users, + glossary, + projects, +) def create_app(): 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 fd4a5db..eafdad9 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -23,67 +23,13 @@ GlossarySchema, ) from app.models import DocumentStatus +from app.projects.models import Project from app.schema import DocumentTask from app.translation_memory.models import TranslationMemory # 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 = [ @@ -117,10 +63,11 @@ 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, + "project_id": None, } @@ -751,7 +698,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 @@ -787,7 +734,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 @@ -1068,3 +1015,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(created_by=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(created_by=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(created_by=1, name="Test Project") + session.add(doc) + session.add(project) + session.commit() + + response = user_logged_client.put(f"/document/{doc.id}", json={"project_id": -1}) + 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": -25}) + assert response.status_code == 404 + + # Test invalid project_id (zero) + response = user_logged_client.put(f"/document/{doc.id}", json={"project_id": 0}) + assert response.status_code == 404 + + # 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(created_by=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 diff --git a/backend/tests/routers/test_routes_projects.py b/backend/tests/routers/test_routes_projects.py new file mode 100644 index 0000000..462abdb --- /dev/null +++ b/backend/tests/routers/test_routes_projects.py @@ -0,0 +1,310 @@ +from fastapi import status +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 + + +def test_create_project(user_logged_client: TestClient, session: Session): + 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["created_by"] == 1 + assert "id" in response_json + assert "created_at" in response_json + assert "updated_at" in response_json + + +def test_create_project_validation_error(user_logged_client: TestClient): + 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): + 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) == 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): + 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["created_by"] == 1 + + +def test_retrieve_project_unauthorized(fastapi_client: TestClient, session: Session): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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_retrieve_project_with_aggregates( + user_logged_client: TestClient, session: Session +): + with session as s: + project = Project(created_by=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_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(created_by=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_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_retrieve_project_project_with_documents_no_records( + user_logged_client: TestClient, session: Session +): + with session as s: + project = Project(created_by=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/1") + assert response.status_code == status.HTTP_200_OK + + project_data = response.json() + assert project_data["name"] == "Project with Empty Docs" + 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_unnamed_project(user_logged_client: TestClient, session: Session): + with session as s: + project = Project(created_by=1, name="Test Project") + s.add(project) + s.flush() + project_id = project.id + + 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 + ), + DocumentRecord( + source="World", target="Мир", approved=False, word_count=1 + ), + DocumentRecord( + source="Test", target="Тест", approved=True, word_count=2 + ), + ], + ) + + s.add(doc1) + s.add(doc2) + s.commit() + + response = user_logged_client.get("/projects/-1") + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + + assert response_json["id"] == -1 + assert response_json["name"] == "Unnamed 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 + assert response_json["documents"][0]["id"] == 2 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, diff --git a/frontend/mocks/documentMocks.ts b/frontend/mocks/documentMocks.ts index b908096..d984038 100644 --- a/frontend/mocks/documentMocks.ts +++ b/frontend/mocks/documentMocks.ts @@ -4,16 +4,17 @@ 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' @@ -230,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, @@ -241,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/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/Document.ts b/frontend/src/client/schemas/Document.ts index 3ee02c5..6d91928 100644 --- a/frontend/src/client/schemas/Document.ts +++ b/frontend/src/client/schemas/Document.ts @@ -8,4 +8,5 @@ export interface Document { status: DocumentStatus created_by: number type: string + project_id: number | null } 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/DocumentWithRecordsCount.ts b/frontend/src/client/schemas/DocumentWithRecordsCount.ts index 2e9424a..b104486 100644 --- a/frontend/src/client/schemas/DocumentWithRecordsCount.ts +++ b/frontend/src/client/schemas/DocumentWithRecordsCount.ts @@ -8,8 +8,9 @@ export interface DocumentWithRecordsCount { status: DocumentStatus created_by: number type: string + project_id: number | null 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/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..a2bb95c --- /dev/null +++ b/frontend/src/client/schemas/ProjectResponse.ts @@ -0,0 +1,9 @@ +// This file is autogenerated, do not edit directly. + +export interface ProjectResponse { + id: number + created_at: string + updated_at: string + name: string + created_by: 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..aa72190 100644 --- a/frontend/src/client/services/DocumentService.ts +++ b/frontend/src/client/services/DocumentService.ts @@ -3,63 +3,36 @@ 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' 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' 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}`) } 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}}) } 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`) } @@ -78,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 new file mode 100644 index 0000000..cfc7ae5 --- /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 {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 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}`) +} 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/AddProjectModal.vue b/frontend/src/components/AddProjectModal.vue new file mode 100644 index 0000000..97bab12 --- /dev/null +++ b/frontend/src/components/AddProjectModal.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/TmSettingsModal.vue b/frontend/src/components/DocSettingsModal.vue similarity index 66% rename from frontend/src/components/TmSettingsModal.vue rename to frontend/src/components/DocSettingsModal.vue index 133bae1..e8c54a1 100644 --- a/frontend/src/components/TmSettingsModal.vue +++ b/frontend/src/components/DocSettingsModal.vue @@ -1,23 +1,22 @@ 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..3783908 --- /dev/null +++ b/frontend/src/components/ProjectList.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ProjectListItem.vue b/frontend/src/components/ProjectListItem.vue new file mode 100644 index 0000000..4012fac --- /dev/null +++ b/frontend/src/components/ProjectListItem.vue @@ -0,0 +1,90 @@ + + + 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/queries/projects.ts b/frontend/src/queries/projects.ts new file mode 100644 index 0000000..9d6f912 --- /dev/null +++ b/frontend/src/queries/projects.ts @@ -0,0 +1,18 @@ +import {defineQuery, useQuery} from '@pinia/colada' +import {listProjects} from '../client/services/ProjectsService' + +export const PROJECT_KEYS = { + root: ['projects'] as const, + byId: (projectId: number) => [...PROJECT_KEYS.root, projectId] as const, +} + +export const useProjects = defineQuery(() => { + const data = useQuery({ + key: () => PROJECT_KEYS.root, + query: async () => { + return await listProjects() + }, + placeholderData: (prevData: T) => prevData, + }) + return data +}) 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 40b2eaa..b120dd9 100644 --- a/frontend/src/views/DocView.vue +++ b/frontend/src/views/DocView.vue @@ -1,7 +1,7 @@