From 3eae65c9859131eb812596881deaf0e8c29310da Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Sun, 30 Nov 2025 22:34:23 +0300 Subject: [PATCH 01/10] Add initial comments backend routes --- .../32d5a77e6615_add_comment_table.py | 38 ++ backend/app/__init__.py | 2 + backend/app/comments/__init__.py | 1 + backend/app/comments/models.py | 24 + backend/app/comments/query.py | 76 +++ backend/app/comments/schema.py | 20 + backend/app/documents/models.py | 6 + backend/app/routers/comments.py | 77 +++ backend/app/routers/document.py | 55 ++ backend/app/schema.py | 4 + backend/main.py | 12 +- backend/tests/routers/test_routes_comments.py | 478 ++++++++++++++++++ .../tests/routers/test_routes_doc_records.py | 4 +- 13 files changed, 794 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/32d5a77e6615_add_comment_table.py create mode 100644 backend/app/comments/__init__.py create mode 100644 backend/app/comments/models.py create mode 100644 backend/app/comments/query.py create mode 100644 backend/app/comments/schema.py create mode 100644 backend/app/routers/comments.py create mode 100644 backend/tests/routers/test_routes_comments.py diff --git a/backend/alembic/versions/32d5a77e6615_add_comment_table.py b/backend/alembic/versions/32d5a77e6615_add_comment_table.py new file mode 100644 index 0000000..6b842e3 --- /dev/null +++ b/backend/alembic/versions/32d5a77e6615_add_comment_table.py @@ -0,0 +1,38 @@ +"""Add comment table + +Revision ID: 32d5a77e6615 +Revises: 9bb8ccd3ee99 +Create Date: 2025-11-30 20:42:40.011040 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# pylint: disable=E1101 + +# revision identifiers, used by Alembic. +revision: str = '32d5a77e6615' +down_revision: Union[str, None] = '9bb8ccd3ee99' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "comment", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("text", sa.String(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("document_record_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["user.id"], ), + sa.ForeignKeyConstraint(["document_record_id"], ["document_record.id"], ), + sa.PrimaryKeyConstraint("id") + ) + + +def downgrade() -> None: + op.drop_table("comment") diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f08cf4e..811a063 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,3 +1,4 @@ +from app.comments.models import Comment from app.db import Base from app.documents.models import ( Document, @@ -17,6 +18,7 @@ __all__ = [ "Base", + "Comment", "DocumentTask", "TranslationMemory", "TranslationMemoryRecord", diff --git a/backend/app/comments/__init__.py b/backend/app/comments/__init__.py new file mode 100644 index 0000000..d190bcc --- /dev/null +++ b/backend/app/comments/__init__.py @@ -0,0 +1 @@ +# Comments module diff --git a/backend/app/comments/models.py b/backend/app/comments/models.py new file mode 100644 index 0000000..21728ae --- /dev/null +++ b/backend/app/comments/models.py @@ -0,0 +1,24 @@ +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 DocumentRecord + from app.models import User + + +class Comment(Base): + __tablename__ = "comment" + + id: Mapped[int] = mapped_column(primary_key=True) + text: Mapped[str] = mapped_column() + updated_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC)) + author_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + document_record_id: Mapped[int] = mapped_column(ForeignKey("document_record.id")) + + author: Mapped["User"] = relationship("User", back_populates="comments") + document_record: Mapped["DocumentRecord"] = relationship(back_populates="comments") diff --git a/backend/app/comments/query.py b/backend/app/comments/query.py new file mode 100644 index 0000000..30c2433 --- /dev/null +++ b/backend/app/comments/query.py @@ -0,0 +1,76 @@ +from datetime import UTC, datetime +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.base.exceptions import BaseQueryException +from app.comments.models import Comment +from app.comments.schema import CommentCreate, CommentUpdate + + +class CommentNotFoundExc(BaseQueryException): + """Exception raised when comment is not found""" + + +class CommentsQuery: + """Contain queries for Comment operations""" + + def __init__(self, db: Session) -> None: + self.__db = db + + def create_comment( + self, comment_data: CommentCreate, author_id: int, document_record_id: int + ) -> Comment: + """Create a new comment""" + comment = Comment( + text=comment_data.text, + author_id=author_id, + document_record_id=document_record_id, + updated_at=datetime.now(UTC), + ) + self.__db.add(comment) + self.__db.commit() + self.__db.refresh(comment) + return comment + + def get_comment(self, comment_id: int) -> Comment | None: + """Get a comment by ID""" + return self.__db.execute( + select(Comment).filter(Comment.id == comment_id) + ).scalar_one_or_none() + + def get_comments_by_document_record( + self, document_record_id: int + ) -> Sequence[Comment]: + """Get all comments for a document record""" + return ( + self.__db.execute( + select(Comment) + .filter(Comment.document_record_id == document_record_id) + .order_by(Comment.updated_at) + ) + .scalars() + .all() + ) + + def update_comment(self, comment_id: int, comment_data: CommentUpdate) -> Comment: + """Update an existing comment""" + comment = self.get_comment(comment_id) + if not comment: + raise CommentNotFoundExc() + + comment.text = comment_data.text + comment.updated_at = datetime.now(UTC) + self.__db.commit() + self.__db.refresh(comment) + return comment + + def delete_comment(self, comment_id: int) -> None: + """Delete a comment""" + comment = self.get_comment(comment_id) + if not comment: + raise CommentNotFoundExc() + + self.__db.delete(comment) + self.__db.commit() diff --git a/backend/app/comments/schema.py b/backend/app/comments/schema.py new file mode 100644 index 0000000..38ee680 --- /dev/null +++ b/backend/app/comments/schema.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.base.schema import Identified + + +class CommentCreate(BaseModel): + text: str = Field(min_length=1, description="Comment text") + + +class CommentUpdate(BaseModel): + text: str = Field(min_length=1, description="Updated comment text") + + +class CommentResponse(Identified): + text: str + updated_at: datetime + author_id: int + document_record_id: int diff --git a/backend/app/documents/models.py b/backend/app/documents/models.py index 7d559c9..b5547ba 100644 --- a/backend/app/documents/models.py +++ b/backend/app/documents/models.py @@ -10,6 +10,7 @@ from app.db import Base if TYPE_CHECKING: + from app.comments.models import Comment from app.glossary.models import Glossary from app.models import User from app.translation_memory.models import TranslationMemory @@ -106,6 +107,11 @@ class DocumentRecord(Base): approved: Mapped[bool] = mapped_column(default=False) document: Mapped["Document"] = relationship(back_populates="records") + comments: Mapped[list["Comment"]] = relationship( + back_populates="document_record", + cascade="all, delete-orphan", + order_by="Comment.id", + ) class TxtDocument(Base): diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py new file mode 100644 index 0000000..54d03b7 --- /dev/null +++ b/backend/app/routers/comments.py @@ -0,0 +1,77 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import schema +from app.comments.query import CommentsQuery +from app.comments.schema import CommentResponse, CommentUpdate +from app.db import get_db +from app.models import StatusMessage +from app.user.depends import get_current_user_id, has_user_role + +router = APIRouter( + prefix="/comments", tags=["comments"], dependencies=[Depends(has_user_role)] +) + + +def get_comment_by_id(db: Session, comment_id: int): + """Helper function to get comment by ID""" + comment = CommentsQuery(db).get_comment(comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found" + ) + return comment + + +def check_comment_authorship(comment, current_user_id: int, is_admin: bool = False): + """Check if user can modify/delete comment""" + if not is_admin and comment.author_id != current_user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only modify your own comments", + ) + + +@router.put("/{comment_id}") +def update_comment( + comment_id: int, + comment_data: CommentUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[int, Depends(get_current_user_id)], +) -> CommentResponse: + """Update an existing comment""" + comment = get_comment_by_id(db, comment_id) + + # Check if user can modify this comment + user = db.query(schema.User).filter_by(id=current_user).one() + is_admin = user.role == "admin" + check_comment_authorship(comment, current_user, is_admin) + + updated_comment = CommentsQuery(db).update_comment(comment_id, comment_data) + return CommentResponse( + id=updated_comment.id, + text=updated_comment.text, + updated_at=updated_comment.updated_at, + author_id=updated_comment.author_id, + document_record_id=updated_comment.document_record_id, + ) + + +@router.delete("/{comment_id}") +def delete_comment( + comment_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[int, Depends(get_current_user_id)], +) -> StatusMessage: + """Delete a comment""" + comment = get_comment_by_id(db, comment_id) + + # Check if user can delete this comment + user = db.query(schema.User).filter_by(id=current_user).one() + is_admin = user.role == "admin" + check_comment_authorship(comment, current_user, is_admin) + + CommentsQuery(db).delete_comment(comment_id) + return StatusMessage(message="Comment deleted successfully") diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index 7536093..3af4992 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -6,6 +6,8 @@ from sqlalchemy.orm import Session from app import models, schema +from app.comments.query import CommentsQuery +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 ( @@ -40,6 +42,16 @@ def get_doc_by_id(db: Session, document_id: int) -> Document: return doc +def get_doc_record_by_id(db: Session, record_id: int): + """Helper function to get document record by ID""" + record = GenericDocsQuery(db).get_record(record_id) + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Document record not found" + ) + return record + + @router.get("/") def get_docs( db: Annotated[Session, Depends(get_db)], @@ -125,6 +137,49 @@ def get_doc_records( ) +@router.get("/{record_id}/comments") +def get_comments( + record_id: int, + db: Annotated[Session, Depends(get_db)], +) -> list[CommentResponse]: + """Get all comments for a document record""" + # Verify document record exists + get_doc_record_by_id(db, record_id) + + comments = CommentsQuery(db).get_comments_by_document_record(record_id) + return [ + CommentResponse( + id=comment.id, + text=comment.text, + updated_at=comment.updated_at, + author_id=comment.author_id, + document_record_id=comment.document_record_id, + ) + for comment in comments + ] + + +@router.post("/{record_id}/comments") +def create_comment( + record_id: int, + comment_data: CommentCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[int, Depends(get_current_user_id)], +) -> CommentResponse: + """Create a new comment for a document record""" + # Verify document record exists + get_doc_record_by_id(db, record_id) + + comment = CommentsQuery(db).create_comment(comment_data, current_user, record_id) + return CommentResponse( + id=comment.id, + text=comment.text, + updated_at=comment.updated_at, + author_id=comment.author_id, + document_record_id=comment.document_record_id, + ) + + @router.get("/{doc_id}/records/{record_id}/substitutions") def get_record_substitutions( doc_id: int, record_id: int, db: Annotated[Session, Depends(get_db)] diff --git a/backend/app/schema.py b/backend/app/schema.py index 003ac40..3fe7475 100644 --- a/backend/app/schema.py +++ b/backend/app/schema.py @@ -5,6 +5,7 @@ from app.db import Base if TYPE_CHECKING: + from app.comments.models import Comment from app.documents.models import Document from app.glossary.models import Glossary from app.translation_memory.models import TranslationMemory @@ -41,3 +42,6 @@ class User(Base): cascade="all, delete-orphan", order_by="Glossary.id", ) + comments: Mapped[list["Comment"]] = relationship( + back_populates="author", cascade="all, delete-orphan", order_by="Comment.id" + ) diff --git a/backend/main.py b/backend/main.py index f333a3e..01ed0f9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,18 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.routers import auth, document, glossary, translation_memory, user, users +from app.routers import ( + auth, + comments, + document, + glossary, + translation_memory, + user, + users, +) from app.settings import settings -ROUTERS = (auth, document, translation_memory, user, users, glossary) +ROUTERS = (auth, comments, document, translation_memory, user, users, glossary) def create_app(): diff --git a/backend/tests/routers/test_routes_comments.py b/backend/tests/routers/test_routes_comments.py new file mode 100644 index 0000000..16ffa4b --- /dev/null +++ b/backend/tests/routers/test_routes_comments.py @@ -0,0 +1,478 @@ +import datetime + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.comments.models import Comment +from app.documents.models import Document, DocumentRecord, DocumentType +from app.models import UserRole + +# pylint: disable=C0116 + + +def test_can_get_comments_for_record(user_logged_client: TestClient, session: Session): + """Test getting all comments for a document record""" + with session as s: + # Create document with records + records = [ + DocumentRecord(source="Hello World", target="Привет Мир"), + DocumentRecord(source="Goodbye", target="Пока"), + ] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + # Add comments for the first record + comments = [ + Comment( + text="First comment", + author_id=1, + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ), + Comment( + text="Second comment", + author_id=1, + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ), + ] + for comment in comments: + s.add(comment) + s.commit() + + response = user_logged_client.get("/document/1/comments") + assert response.status_code == 200 + response_data = response.json() + assert len(response_data) == 2 + assert response_data[0]["text"] == "First comment" + assert response_data[0]["author_id"] == 1 + assert response_data[0]["document_record_id"] == 1 + assert response_data[1]["text"] == "Second comment" + + +def test_get_comments_returns_empty_for_no_comments( + user_logged_client: TestClient, session: Session +): + """Test getting comments for record with no comments""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + s.commit() + + response = user_logged_client.get("/document/1/comments") + assert response.status_code == 200 + response_data = response.json() + assert response_data == [] + + +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/999/comments") + assert response.status_code == 404 + assert response.json()["detail"] == "Document record not found" + + +def test_can_create_comment(user_logged_client: TestClient, session: Session): + """Test creating a new comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + s.commit() + + comment_data = {"text": "This is a test comment"} + response = user_logged_client.post("/document/1/comments", json=comment_data) + assert response.status_code == 200 + response_data = response.json() + assert response_data["text"] == "This is a test comment" + assert response_data["author_id"] == 1 + assert response_data["document_record_id"] == 1 + assert "id" in response_data + assert "updated_at" in response_data + + +def test_create_comment_returns_404_for_nonexistent_record( + user_logged_client: TestClient, +): + """Test creating comment for nonexistent record""" + comment_data = {"text": "This is a test comment"} + response = user_logged_client.post("/document/999/comments", json=comment_data) + assert response.status_code == 404 + assert response.json()["detail"] == "Document record not found" + + +def test_create_comment_requires_text(user_logged_client: TestClient, session: Session): + """Test that creating comment requires text field""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + s.commit() + + response = user_logged_client.post("/document/1/comments", json={}) + assert response.status_code == 422 # Validation error + + +def test_create_comment_requires_min_length_text( + user_logged_client: TestClient, session: Session +): + """Test that creating comment requires minimum length text""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + s.commit() + + response = user_logged_client.post("/document/1/comments", json={"text": ""}) + assert response.status_code == 422 # Validation error + + +def test_create_comment_requires_authentication( + fastapi_client: TestClient, session: Session +): + """Test that creating comment requires authentication""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + s.commit() + + comment_data = {"text": "This is a test comment"} + response = fastapi_client.post("/document/1/comments", json=comment_data) + assert response.status_code == 401 # Unauthorized + + +def test_can_update_own_comment(user_logged_client: TestClient, session: Session): + """Test that user can update their own comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + comment = Comment( + text="Original text", + author_id=1, # Same user as logged in + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ) + s.add(comment) + s.commit() + comment_id = comment.id + + update_data = {"text": "Updated text"} + response = user_logged_client.put(f"/comments/{comment_id}", json=update_data) + assert response.status_code == 200 + response_data = response.json() + assert response_data["text"] == "Updated text" + assert response_data["id"] == comment_id + + +def test_cannot_update_others_comment(user_logged_client: TestClient, session: Session): + """Test that user cannot update another user's comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + # Create comment by different user (id=2) + from app import schema + + other_user = ( + s.query(schema.User).filter(schema.User.username == "test-admin").one() + ) + comment = Comment( + text="Original text", + author_id=other_user.id, # Different user + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ) + s.add(comment) + s.commit() + comment_id = comment.id + + update_data = {"text": "Updated text"} + response = user_logged_client.put(f"/comments/{comment_id}", json=update_data) + assert response.status_code == 403 # Forbidden + assert response.json()["detail"] == "You can only modify your own comments" + + +def test_can_delete_own_comment(user_logged_client: TestClient, session: Session): + """Test that user can delete their own comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + comment = Comment( + text="Original text", + author_id=1, # Same user as logged in + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ) + s.add(comment) + s.commit() + comment_id = comment.id + + response = user_logged_client.delete(f"/comments/{comment_id}") + assert response.status_code == 200 + response_data = response.json() + assert response_data["message"] == "Comment deleted successfully" + + # Verify comment is deleted + with session as s: + deleted_comment = s.query(Comment).filter(Comment.id == comment_id).first() + assert deleted_comment is None + + +def test_cannot_delete_others_comment(user_logged_client: TestClient, session: Session): + """Test that user cannot delete another user's comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + # Create comment by different user (id=2) + from app import schema + + other_user = ( + s.query(schema.User).filter(schema.User.username == "test-admin").one() + ) + comment = Comment( + text="Original text", + author_id=other_user.id, # Different user + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ) + s.add(comment) + s.commit() + comment_id = comment.id + + response = user_logged_client.delete(f"/comments/{comment_id}") + assert response.status_code == 403 # Forbidden + assert response.json()["detail"] == "You can only modify your own comments" + + # Verify comment still exists + with session as s: + existing_comment = s.query(Comment).filter(Comment.id == comment_id).first() + assert existing_comment is not None + + +def test_update_comment_returns_404_for_nonexistent_comment( + user_logged_client: TestClient, +): + """Test updating nonexistent comment""" + update_data = {"text": "Updated text"} + response = user_logged_client.put("/comments/999", json=update_data) + assert response.status_code == 404 + assert response.json()["detail"] == "Comment not found" + + +def test_delete_comment_returns_404_for_nonexistent_comment( + user_logged_client: TestClient, +): + """Test deleting nonexistent comment""" + response = user_logged_client.delete("/comments/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Comment not found" + + +def test_comment_endpoints_require_authentication(fastapi_client: TestClient): + """Test that comment endpoints require authentication""" + # Clear any existing cookies to ensure clean state + fastapi_client.cookies.clear() + + # Test GET comments + response = fastapi_client.get("/document/1/comments") + assert response.status_code == 401 + + # Test POST comment + response = fastapi_client.post("/document/1/comments", json={"text": "test"}) + assert response.status_code == 401 + + # Test PUT comment + response = fastapi_client.put("/comments/1", json={"text": "test"}) + assert response.status_code == 401 + + # Test DELETE comment + response = fastapi_client.delete("/comments/1") + assert response.status_code == 401 + + +def test_admin_can_update_any_comment( + admin_logged_client: TestClient, session: Session +): + """Test that admin can update any comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + # Create comment by regular user (id=1) + comment = Comment( + text="Original text", + author_id=1, # Regular user + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ) + s.add(comment) + s.commit() + comment_id = comment.id + + update_data = {"text": "Admin updated text"} + response = admin_logged_client.put(f"/comments/{comment_id}", json=update_data) + assert response.status_code == 200 + response_data = response.json() + assert response_data["text"] == "Admin updated text" + + +def test_admin_can_delete_any_comment( + admin_logged_client: TestClient, session: Session +): + """Test that admin can delete any comment""" + with session as s: + records = [DocumentRecord(source="Hello World", target="Привет Мир")] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="done", + created_by=1, + ) + ) + + # Create comment by regular user (id=1) + comment = Comment( + text="Original text", + author_id=1, # Regular user + document_record_id=1, + updated_at=datetime.datetime.now(datetime.UTC), + ) + s.add(comment) + s.commit() + comment_id = comment.id + + response = admin_logged_client.delete(f"/comments/{comment_id}") + assert response.status_code == 200 + response_data = response.json() + assert response_data["message"] == "Comment deleted successfully" + + # Verify comment is deleted + with session as s: + deleted_comment = s.query(Comment).filter(Comment.id == comment_id).first() + assert deleted_comment is None + + +@pytest.fixture() +def admin_logged_client(fastapi_client: TestClient, session: Session): + """Create a client logged in as admin user""" + from app import schema + + with session as s: + s.add( + schema.User( + username="test", + password="$pbkdf2-sha256$29000$R4gxRkjpnXNOqXXundP6Xw$pzr2kyXZjurvt6sUv7NF4dQhpHdv9RBtlGbOStnFyUM", + email="test@test.com", + role=UserRole.USER.value, + disabled=False, + ) + ) + s.add( + schema.User( + username="test-admin", + password="$pbkdf2-sha256$29000$R4gxRkjpnXNOqXXundP6Xw$pzr2kyXZjurvt6sUv7NF4dQhpHdv9RBtlGbOStnFyUM", + email="admin@test.com", + role=UserRole.ADMIN.value, + disabled=False, + ) + ) + s.commit() + + fastapi_client.post( + "/auth/login", + json={"email": "admin@test.com", "password": "1234", "remember": False}, + ) + + yield fastapi_client diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index ac6b898..4d61b16 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -694,5 +694,7 @@ def test_update_repetitions_only_when_approved( assert record1.target == "Final Hello" assert record1.approved is True - assert record2.target == "Final Hello" # updated - repetition update since approved + assert ( + record2.target == "Final Hello" + ) # updated - repetition update since approved assert record2.approved is True From b4985d2a7262d17af66c27a1ec8789b7d88395af Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Sun, 30 Nov 2025 23:01:06 +0300 Subject: [PATCH 02/10] Make doc records path consistent --- backend/app/routers/document.py | 20 +++--- backend/tests/conftest.py | 31 +++++++++ backend/tests/routers/test_routes_comments.py | 64 ++++++------------- .../tests/routers/test_routes_doc_records.py | 18 +++--- .../tests/routers/test_routes_documents.py | 11 +--- 5 files changed, 69 insertions(+), 75 deletions(-) diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index 3af4992..82c0ef2 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -137,7 +137,7 @@ def get_doc_records( ) -@router.get("/{record_id}/comments") +@router.get("/records/{record_id}/comments") def get_comments( record_id: int, db: Annotated[Session, Depends(get_db)], @@ -159,7 +159,7 @@ def get_comments( ] -@router.post("/{record_id}/comments") +@router.post("/records/{record_id}/comments") def create_comment( record_id: int, comment_data: CommentCreate, @@ -180,18 +180,17 @@ def create_comment( ) -@router.get("/{doc_id}/records/{record_id}/substitutions") +@router.get("/records/{record_id}/substitutions") def get_record_substitutions( - doc_id: int, record_id: int, db: Annotated[Session, Depends(get_db)] + record_id: int, db: Annotated[Session, Depends(get_db)] ) -> list[MemorySubstitution]: - doc = get_doc_by_id(db, doc_id) original_segment = GenericDocsQuery(db).get_record(record_id) if not original_segment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Segment not found" ) - tm_ids = [tm.id for tm in doc.memories] + tm_ids = [tm.id for tm in original_segment.document.memories] return ( TranslationMemoryQuery(db).get_substitutions(original_segment.source, tm_ids) if tm_ids @@ -199,18 +198,17 @@ def get_record_substitutions( ) -@router.get("/{doc_id}/records/{record_id}/glossary_records") +@router.get("/records/{record_id}/glossary_records") def get_record_glossary_records( - doc_id: int, record_id: int, db: Annotated[Session, Depends(get_db)] + record_id: int, db: Annotated[Session, Depends(get_db)] ) -> list[GlossaryRecordSchema]: - doc = get_doc_by_id(db, doc_id) original_segment = GenericDocsQuery(db).get_record(record_id) if not original_segment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Segment not found" ) - glossary_ids = [gl.id for gl in doc.glossaries] + glossary_ids = [gl.id for gl in original_segment.document.glossaries] return ( [ GlossaryRecordSchema.model_validate(record) @@ -223,7 +221,7 @@ def get_record_glossary_records( ) -@router.put("/record/{record_id}") +@router.put("/records/{record_id}") def update_doc_record( record_id: int, record: doc_schema.DocumentRecordUpdate, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3e0a748..712a3c4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -77,3 +77,34 @@ def user_logged_client(fastapi_client: TestClient, session: Session): ) yield fastapi_client + + +@pytest.fixture() +def admin_logged_client(fastapi_client: TestClient, session: Session): + with session as s: + s.add( + schema.User( + username="test", + password="$pbkdf2-sha256$29000$R4gxRkjpnXNOqXXundP6Xw$pzr2kyXZjurvt6sUv7NF4dQhpHdv9RBtlGbOStnFyUM", + email="test@test.com", + role=models.UserRole.USER.value, + disabled=False, + ) + ) + s.add( + schema.User( + username="test-admin", + password="$pbkdf2-sha256$29000$R4gxRkjpnXNOqXXundP6Xw$pzr2kyXZjurvt6sUv7NF4dQhpHdv9RBtlGbOStnFyUM", + email="admin@test.com", + role=models.UserRole.ADMIN.value, + disabled=False, + ) + ) + s.commit() + + fastapi_client.post( + "/auth/login", + json={"email": "admin@test.com", "password": "1234", "remember": False}, + ) + + yield fastapi_client diff --git a/backend/tests/routers/test_routes_comments.py b/backend/tests/routers/test_routes_comments.py index 16ffa4b..30eb111 100644 --- a/backend/tests/routers/test_routes_comments.py +++ b/backend/tests/routers/test_routes_comments.py @@ -1,12 +1,10 @@ import datetime -import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.comments.models import Comment from app.documents.models import Document, DocumentRecord, DocumentType -from app.models import UserRole # pylint: disable=C0116 @@ -48,7 +46,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/1/comments") + response = user_logged_client.get("/document/records/1/comments") assert response.status_code == 200 response_data = response.json() assert len(response_data) == 2 @@ -75,7 +73,7 @@ def test_get_comments_returns_empty_for_no_comments( ) s.commit() - response = user_logged_client.get("/document/1/comments") + response = user_logged_client.get("/document/records/1/comments") assert response.status_code == 200 response_data = response.json() assert response_data == [] @@ -85,7 +83,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/999/comments") + response = user_logged_client.get("/document/records/999/comments") assert response.status_code == 404 assert response.json()["detail"] == "Document record not found" @@ -106,7 +104,9 @@ 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/1/comments", json=comment_data) + response = user_logged_client.post( + "/document/records/1/comments", json=comment_data + ) assert response.status_code == 200 response_data = response.json() assert response_data["text"] == "This is a test comment" @@ -121,7 +121,9 @@ 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/999/comments", json=comment_data) + response = user_logged_client.post( + "/document/records/999/comments", json=comment_data + ) assert response.status_code == 404 assert response.json()["detail"] == "Document record not found" @@ -141,7 +143,7 @@ def test_create_comment_requires_text(user_logged_client: TestClient, session: S ) s.commit() - response = user_logged_client.post("/document/1/comments", json={}) + response = user_logged_client.post("/document/records/1/comments", json={}) assert response.status_code == 422 # Validation error @@ -162,7 +164,9 @@ def test_create_comment_requires_min_length_text( ) s.commit() - response = user_logged_client.post("/document/1/comments", json={"text": ""}) + response = user_logged_client.post( + "/document/records/1/comments", json={"text": ""} + ) assert response.status_code == 422 # Validation error @@ -184,7 +188,7 @@ def test_create_comment_requires_authentication( s.commit() comment_data = {"text": "This is a test comment"} - response = fastapi_client.post("/document/1/comments", json=comment_data) + response = fastapi_client.post("/document/records/1/comments", json=comment_data) assert response.status_code == 401 # Unauthorized @@ -356,11 +360,13 @@ def test_comment_endpoints_require_authentication(fastapi_client: TestClient): fastapi_client.cookies.clear() # Test GET comments - response = fastapi_client.get("/document/1/comments") + response = fastapi_client.get("/document/records/1/comments") assert response.status_code == 401 # Test POST comment - response = fastapi_client.post("/document/1/comments", json={"text": "test"}) + response = fastapi_client.post( + "/document/records/1/comments", json={"text": "test"} + ) assert response.status_code == 401 # Test PUT comment @@ -442,37 +448,3 @@ def test_admin_can_delete_any_comment( with session as s: deleted_comment = s.query(Comment).filter(Comment.id == comment_id).first() assert deleted_comment is None - - -@pytest.fixture() -def admin_logged_client(fastapi_client: TestClient, session: Session): - """Create a client logged in as admin user""" - from app import schema - - with session as s: - s.add( - schema.User( - username="test", - password="$pbkdf2-sha256$29000$R4gxRkjpnXNOqXXundP6Xw$pzr2kyXZjurvt6sUv7NF4dQhpHdv9RBtlGbOStnFyUM", - email="test@test.com", - role=UserRole.USER.value, - disabled=False, - ) - ) - s.add( - schema.User( - username="test-admin", - password="$pbkdf2-sha256$29000$R4gxRkjpnXNOqXXundP6Xw$pzr2kyXZjurvt6sUv7NF4dQhpHdv9RBtlGbOStnFyUM", - email="admin@test.com", - role=UserRole.ADMIN.value, - disabled=False, - ) - ) - s.commit() - - fastapi_client.post( - "/auth/login", - json={"email": "admin@test.com", "password": "1234", "remember": False}, - ) - - yield fastapi_client diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index 4d61b16..4aae2a2 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -163,7 +163,7 @@ def test_can_update_doc_record( ) s.commit() - response = user_logged_client.put("/document/record/2", json=arguments) + response = user_logged_client.put("/document/records/2", json=arguments) assert response.status_code == 200, response.text assert response.json() == { "id": 2, @@ -210,7 +210,7 @@ def test_record_approving_creates_memory( s.commit() response = user_logged_client.put( - "/document/record/1", + "/document/records/1", json={ "target": "Updated", "approved": True, @@ -265,7 +265,7 @@ def test_record_approving_updates_memory( s.commit() response = user_logged_client.put( - "/document/record/1", + "/document/records/1", json={ "target": "Updated", "approved": True, @@ -293,7 +293,7 @@ def test_returns_404_for_nonexistent_doc_when_updating_record( user_logged_client: TestClient, ): response = user_logged_client.put( - "/document/record/3", + "/document/records/3", json={ "target": "Updated", "approved": None, @@ -319,7 +319,7 @@ def test_returns_404_for_nonexistent_record( s.commit() response = user_logged_client.put( - "/document/1/record/3", json={"target": "Updated"} + "/document/1/records/3", json={"target": "Updated"} ) assert response.status_code == 404 @@ -347,7 +347,7 @@ def test_can_update_doc_record_with_repetitions( # Update record 1 with repetition update enabled response = user_logged_client.put( - "/document/record/1", + "/document/records/1", json={"target": "Updated Hello", "approved": True, "update_repetitions": True}, ) assert response.status_code == 200 @@ -390,7 +390,7 @@ def test_update_repetitions_default_behavior( # Update without specifying update_repetitions (should default to False) response = user_logged_client.put( - "/document/record/1", + "/document/records/1", json={ "target": "Updated Hello", "approved": True, @@ -659,7 +659,7 @@ def test_update_repetitions_only_when_approved( # Update record 1 with repetition update enabled but NOT approved response = user_logged_client.put( - "/document/record/1", + "/document/records/1", json={"target": "Updated Hello", "approved": False, "update_repetitions": True}, ) assert response.status_code == 200 @@ -681,7 +681,7 @@ def test_update_repetitions_only_when_approved( # Now update record 1 with repetition update enabled AND approved response = user_logged_client.put( - "/document/record/1", + "/document/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 b98358f..9e6366f 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -754,7 +754,7 @@ def test_can_get_glossaries_substitutions( ) dq.set_document_glossaries(dq.get_document(1), [g]) - response = user_logged_client.get("/document/1/records/1/glossary_records") + response = user_logged_client.get("/document/records/1/glossary_records") assert response.status_code == 200 response_json = response.json() assert len(response_json) == 1 @@ -765,13 +765,6 @@ def test_can_get_glossaries_substitutions( assert response_json[0]["created_by_user"]["id"] == 1 -def test_glossary_substitution_returns_404_for_non_existent_document( - user_logged_client: TestClient, -): - response = user_logged_client.get("/document/999/records/1/glossary_records") - assert response.status_code == 404 - - def test_glossary_substitution_returns_404_for_non_existent_record( user_logged_client: TestClient, session: Session ): @@ -797,7 +790,7 @@ def test_glossary_substitution_returns_404_for_non_existent_record( ) s.commit() - response = user_logged_client.get("/document/1/records/999/glossary_records") + response = user_logged_client.get("/document/records/999/glossary_records") assert response.status_code == 404 From 91037cf8369e574ca760e50759a7092dea66406b Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Sun, 30 Nov 2025 23:06:35 +0300 Subject: [PATCH 03/10] Rename comments table --- backend/alembic/versions/32d5a77e6615_add_comment_table.py | 6 +++--- backend/app/comments/models.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/alembic/versions/32d5a77e6615_add_comment_table.py b/backend/alembic/versions/32d5a77e6615_add_comment_table.py index 6b842e3..781f312 100644 --- a/backend/alembic/versions/32d5a77e6615_add_comment_table.py +++ b/backend/alembic/versions/32d5a77e6615_add_comment_table.py @@ -1,4 +1,4 @@ -"""Add comment table +"""Add record comment table Revision ID: 32d5a77e6615 Revises: 9bb8ccd3ee99 @@ -22,7 +22,7 @@ def upgrade() -> None: op.create_table( - "comment", + "record_comment", sa.Column("id", sa.Integer(), nullable=False), sa.Column("text", sa.String(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), @@ -35,4 +35,4 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_table("comment") + op.drop_table("record_comment") diff --git a/backend/app/comments/models.py b/backend/app/comments/models.py index 21728ae..7188501 100644 --- a/backend/app/comments/models.py +++ b/backend/app/comments/models.py @@ -12,7 +12,7 @@ class Comment(Base): - __tablename__ = "comment" + __tablename__ = "record_comment" id: Mapped[int] = mapped_column(primary_key=True) text: Mapped[str] = mapped_column() From a3eee099b0adc65b8467f815fa71268f22d5110c Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Sun, 30 Nov 2025 23:08:23 +0300 Subject: [PATCH 04/10] Generate client --- frontend/src/client/schemas/CommentCreate.ts | 5 +++++ frontend/src/client/schemas/CommentResponse.ts | 11 +++++++++++ frontend/src/client/schemas/CommentUpdate.ts | 5 +++++ frontend/src/client/schemas/DocumentRecord.ts | 1 + .../src/client/services/CommentsService.ts | 14 ++++++++++++++ .../src/client/services/DocumentService.ts | 18 +++++++++++++----- 6 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 frontend/src/client/schemas/CommentCreate.ts create mode 100644 frontend/src/client/schemas/CommentResponse.ts create mode 100644 frontend/src/client/schemas/CommentUpdate.ts create mode 100644 frontend/src/client/services/CommentsService.ts diff --git a/frontend/src/client/schemas/CommentCreate.ts b/frontend/src/client/schemas/CommentCreate.ts new file mode 100644 index 0000000..6b99a27 --- /dev/null +++ b/frontend/src/client/schemas/CommentCreate.ts @@ -0,0 +1,5 @@ +// This file is autogenerated, do not edit directly. + +export interface CommentCreate { + text: string +} diff --git a/frontend/src/client/schemas/CommentResponse.ts b/frontend/src/client/schemas/CommentResponse.ts new file mode 100644 index 0000000..7766ae5 --- /dev/null +++ b/frontend/src/client/schemas/CommentResponse.ts @@ -0,0 +1,11 @@ +// This file is autogenerated, do not edit directly. + +import {ShortUser} from './ShortUser' + +export interface CommentResponse { + id: number + text: string + updated_at: string + record_id: number + created_by_user: ShortUser +} diff --git a/frontend/src/client/schemas/CommentUpdate.ts b/frontend/src/client/schemas/CommentUpdate.ts new file mode 100644 index 0000000..4653dee --- /dev/null +++ b/frontend/src/client/schemas/CommentUpdate.ts @@ -0,0 +1,5 @@ +// This file is autogenerated, do not edit directly. + +export interface CommentUpdate { + text: string +} diff --git a/frontend/src/client/schemas/DocumentRecord.ts b/frontend/src/client/schemas/DocumentRecord.ts index db1df65..1a545fa 100644 --- a/frontend/src/client/schemas/DocumentRecord.ts +++ b/frontend/src/client/schemas/DocumentRecord.ts @@ -6,4 +6,5 @@ export interface DocumentRecord { target: string approved: boolean repetitions_count: number + has_comments: boolean } diff --git a/frontend/src/client/services/CommentsService.ts b/frontend/src/client/services/CommentsService.ts new file mode 100644 index 0000000..1161dc7 --- /dev/null +++ b/frontend/src/client/services/CommentsService.ts @@ -0,0 +1,14 @@ +// This file is autogenerated, do not edit directly. + +import {getApiBase, api} from '../defaults' + +import {CommentResponse} from '../schemas/CommentResponse' +import {CommentUpdate} from '../schemas/CommentUpdate' +import {StatusMessage} from '../schemas/StatusMessage' + +export const updateComment = async (comment_id: number, content: CommentUpdate): Promise => { + return await api.put(`/comments/${comment_id}`, content) +} +export const deleteComment = async (comment_id: number): Promise => { + return await api.delete(`/comments/${comment_id}`) +} diff --git a/frontend/src/client/services/DocumentService.ts b/frontend/src/client/services/DocumentService.ts index 0b9812d..5e77025 100644 --- a/frontend/src/client/services/DocumentService.ts +++ b/frontend/src/client/services/DocumentService.ts @@ -7,6 +7,8 @@ import {Document} from '../schemas/Document' import {Body_create_doc_document__post} from '../schemas/Body_create_doc_document__post' import {StatusMessage} from '../schemas/StatusMessage' import {DocumentRecordListResponse} from '../schemas/DocumentRecordListResponse' +import {CommentResponse} from '../schemas/CommentResponse' +import {CommentCreate} from '../schemas/CommentCreate' import {MemorySubstitution} from '../schemas/MemorySubstitution' import {GlossaryRecordSchema} from '../schemas/GlossaryRecordSchema' import {DocumentRecordUpdateResponse} from '../schemas/DocumentRecordUpdateResponse' @@ -36,14 +38,20 @@ export const deleteDoc = async (doc_id: number): Promise => { 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 getRecordSubstitutions = async (doc_id: number, record_id: number): Promise => { - return await api.get(`/document/${doc_id}/records/${record_id}/substitutions`) +export const getComments = async (record_id: number): Promise => { + return await api.get(`/document/records/${record_id}/comments`) } -export const getRecordGlossaryRecords = async (doc_id: number, record_id: number): Promise => { - return await api.get(`/document/${doc_id}/records/${record_id}/glossary_records`) +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 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/record/${record_id}`, content) + 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`) From c88761624e4eca1383a703a0752ad45f81278cc5 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Sun, 30 Nov 2025 23:52:09 +0300 Subject: [PATCH 05/10] Add comments flag to document records response --- backend/app/documents/query.py | 21 +++- backend/app/documents/schema.py | 1 + backend/app/routers/document.py | 3 +- .../tests/routers/test_routes_doc_records.py | 100 ++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index deb9c99..cb2a36c 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -2,6 +2,7 @@ from typing import Iterable from sqlalchemy import Row, and_, case, func, select +from app.comments.models import Comment from sqlalchemy.orm import Session from app.base.exceptions import BaseQueryException @@ -117,7 +118,7 @@ def get_document_records_paged( page: int, page_records=100, filters: DocumentRecordFilter | None = None, - ) -> Iterable[Row[tuple[DocumentRecord, int]]]: + ) -> Iterable[Row[tuple[DocumentRecord, int, bool]]]: # Subquery to count repetitions for each source text within the document repetitions_subquery = ( select( @@ -129,6 +130,16 @@ def get_document_records_paged( .subquery() ) + # Subquery to count comments for each document record + comments_subquery = ( + select( + Comment.document_record_id, + func.count(Comment.id).label("comments_count"), + ) + .group_by(Comment.document_record_id) + .subquery() + ) + # Build the base query query = ( select( @@ -136,12 +147,20 @@ def get_document_records_paged( func.coalesce(repetitions_subquery.c.repetitions_count, 0).label( "repetitions_count" ), + case( + (func.coalesce(comments_subquery.c.comments_count, 0) > 0, True), + else_=False, + ).label("has_comments"), ) .filter(DocumentRecord.document_id == doc.id) .outerjoin( repetitions_subquery, DocumentRecord.source == repetitions_subquery.c.source, ) + .outerjoin( + comments_subquery, + DocumentRecord.id == comments_subquery.c.document_record_id, + ) ) # Apply filters if provided diff --git a/backend/app/documents/schema.py b/backend/app/documents/schema.py index 4442c57..ff964c0 100644 --- a/backend/app/documents/schema.py +++ b/backend/app/documents/schema.py @@ -30,6 +30,7 @@ class DocumentRecord(Identified): target: str approved: bool repetitions_count: int + has_comments: bool class DocumentRecordListResponse(BaseModel): diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index 82c0ef2..b0f8ed0 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -126,8 +126,9 @@ def get_doc_records( target=record.target, approved=record.approved, repetitions_count=repetitions_count, + has_comments=has_comments, ) - for record, repetitions_count in records + for record, repetitions_count, has_comments in records ] return doc_schema.DocumentRecordListResponse( diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index 4aae2a2..c4da3af 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -11,6 +11,7 @@ DocumentType, ) from app.translation_memory.models import TranslationMemory, TranslationMemoryRecord +from app.comments.models import Comment # pylint: disable=C0116 @@ -44,6 +45,7 @@ def test_can_get_doc_records(user_logged_client: TestClient, session: Session): "target": "Translation", "approved": False, "repetitions_count": 1, + "has_comments": False, }, { "id": 2, @@ -51,6 +53,7 @@ def test_can_get_doc_records(user_logged_client: TestClient, session: Session): "target": "UI", "approved": True, "repetitions_count": 1, + "has_comments": False, }, ] @@ -90,6 +93,7 @@ def test_doc_records_returns_second_page( "target": "line100", "approved": False, "repetitions_count": 1, + "has_comments": False, } @@ -698,3 +702,99 @@ def test_update_repetitions_only_when_approved( record2.target == "Final Hello" ) # updated - repetition update since approved assert record2.approved is True + + +def test_has_comments_field(user_logged_client: TestClient, session: Session): + """Test that has_comments field correctly shows if document record has comments""" + with session as s: + # Create document records + records = [ + DocumentRecord(source="Hello World", target="Привет мир"), + DocumentRecord(source="Goodbye", target="Пока"), + DocumentRecord(source="Thank you", target="Спасибо"), + ] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="pending", + created_by=1, + ) + ) + s.commit() + + # Add comments to first and third records + comment1 = Comment( + text="First comment", + author_id=1, + document_record_id=records[0].id, # First record + ) + comment2 = Comment( + text="Second comment", + author_id=1, + document_record_id=records[2].id, # Third record + ) + s.add_all([comment1, comment2]) + s.commit() + + response = user_logged_client.get("/document/1/records") + assert response.status_code == 200 + response_data = response.json() + assert response_data["page"] == 0 + assert response_data["total_records"] == 3 + + records_response = response_data["records"] + assert len(records_response) == 3 + + # Check has_comments values + # Record 1 should have comments (has 1 comment) + assert records_response[0]["id"] == 1 + assert records_response[0]["source"] == "Hello World" + assert records_response[0]["has_comments"] + + # Record 2 should not have comments + assert records_response[1]["id"] == 2 + assert records_response[1]["source"] == "Goodbye" + assert not records_response[1]["has_comments"] + + # Record 3 should have comments (has 1 comment) + assert records_response[2]["id"] == 3 + assert records_response[2]["source"] == "Thank you" + assert records_response[2]["has_comments"] + + +def test_has_comments_with_multiple_comments(user_logged_client: TestClient, session: Session): + """Test that has_comments is True when record has multiple comments""" + with session as s: + # Create document record + record = DocumentRecord(source="Test", target="Тест") + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=[record], + processing_status="pending", + created_by=1, + ) + ) + s.commit() + + # Add multiple comments to the same record + comments = [ + Comment(text="Comment 1", author_id=1, document_record_id=record.id), + Comment(text="Comment 2", author_id=1, document_record_id=record.id), + Comment(text="Comment 3", author_id=1, document_record_id=record.id), + ] + s.add_all(comments) + s.commit() + + response = user_logged_client.get("/document/1/records") + assert response.status_code == 200 + response_data = response.json() + + records_response = response_data["records"] + assert len(records_response) == 1 + + # Should still be True even with multiple comments + assert records_response[0]["has_comments"] From 6a91e2aaab1c5d7c495a0056b243f9278cc8c382 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 1 Dec 2025 00:33:18 +0300 Subject: [PATCH 06/10] Update one field --- .../32d5a77e6615_add_comment_table.py | 4 ++-- backend/app/comments/models.py | 2 +- backend/app/comments/query.py | 10 ++++------ backend/app/comments/schema.py | 2 +- backend/app/documents/query.py | 8 ++++---- backend/app/routers/comments.py | 2 +- backend/app/routers/document.py | 4 ++-- backend/tests/routers/test_routes_comments.py | 20 +++++++++---------- .../tests/routers/test_routes_doc_records.py | 16 ++++++++------- 9 files changed, 34 insertions(+), 34 deletions(-) diff --git a/backend/alembic/versions/32d5a77e6615_add_comment_table.py b/backend/alembic/versions/32d5a77e6615_add_comment_table.py index 781f312..28e9481 100644 --- a/backend/alembic/versions/32d5a77e6615_add_comment_table.py +++ b/backend/alembic/versions/32d5a77e6615_add_comment_table.py @@ -27,9 +27,9 @@ def upgrade() -> None: sa.Column("text", sa.String(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("author_id", sa.Integer(), nullable=False), - sa.Column("document_record_id", sa.Integer(), nullable=False), + sa.Column("record_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint(["author_id"], ["user.id"], ), - sa.ForeignKeyConstraint(["document_record_id"], ["document_record.id"], ), + sa.ForeignKeyConstraint(["record_id"], ["document_record.id"], ), sa.PrimaryKeyConstraint("id") ) diff --git a/backend/app/comments/models.py b/backend/app/comments/models.py index 7188501..d7cb057 100644 --- a/backend/app/comments/models.py +++ b/backend/app/comments/models.py @@ -18,7 +18,7 @@ class Comment(Base): text: Mapped[str] = mapped_column() updated_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC)) author_id: Mapped[int] = mapped_column(ForeignKey("user.id")) - document_record_id: Mapped[int] = mapped_column(ForeignKey("document_record.id")) + record_id: Mapped[int] = mapped_column(ForeignKey("document_record.id")) author: Mapped["User"] = relationship("User", back_populates="comments") document_record: Mapped["DocumentRecord"] = relationship(back_populates="comments") diff --git a/backend/app/comments/query.py b/backend/app/comments/query.py index 30c2433..70849ab 100644 --- a/backend/app/comments/query.py +++ b/backend/app/comments/query.py @@ -20,13 +20,13 @@ def __init__(self, db: Session) -> None: self.__db = db def create_comment( - self, comment_data: CommentCreate, author_id: int, document_record_id: int + self, comment_data: CommentCreate, author_id: int, record_id: int ) -> Comment: """Create a new comment""" comment = Comment( text=comment_data.text, author_id=author_id, - document_record_id=document_record_id, + record_id=record_id, updated_at=datetime.now(UTC), ) self.__db.add(comment) @@ -40,14 +40,12 @@ def get_comment(self, comment_id: int) -> Comment | None: select(Comment).filter(Comment.id == comment_id) ).scalar_one_or_none() - def get_comments_by_document_record( - self, document_record_id: int - ) -> Sequence[Comment]: + def get_comments_by_document_record(self, record_id: int) -> Sequence[Comment]: """Get all comments for a document record""" return ( self.__db.execute( select(Comment) - .filter(Comment.document_record_id == document_record_id) + .filter(Comment.record_id == record_id) .order_by(Comment.updated_at) ) .scalars() diff --git a/backend/app/comments/schema.py b/backend/app/comments/schema.py index 38ee680..016611d 100644 --- a/backend/app/comments/schema.py +++ b/backend/app/comments/schema.py @@ -17,4 +17,4 @@ class CommentResponse(Identified): text: str updated_at: datetime author_id: int - document_record_id: int + record_id: int diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index cb2a36c..de0b370 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -2,10 +2,10 @@ from typing import Iterable from sqlalchemy import Row, and_, case, func, select -from app.comments.models import Comment from sqlalchemy.orm import Session from app.base.exceptions import BaseQueryException +from app.comments.models import Comment from app.documents.schema import DocumentRecordFilter, DocumentRecordUpdate from app.glossary.models import Glossary from app.models import DocumentStatus @@ -133,10 +133,10 @@ def get_document_records_paged( # Subquery to count comments for each document record comments_subquery = ( select( - Comment.document_record_id, + Comment.record_id, func.count(Comment.id).label("comments_count"), ) - .group_by(Comment.document_record_id) + .group_by(Comment.record_id) .subquery() ) @@ -159,7 +159,7 @@ def get_document_records_paged( ) .outerjoin( comments_subquery, - DocumentRecord.id == comments_subquery.c.document_record_id, + DocumentRecord.id == comments_subquery.c.record_id, ) ) diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index 54d03b7..7b556ef 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -55,7 +55,7 @@ def update_comment( text=updated_comment.text, updated_at=updated_comment.updated_at, author_id=updated_comment.author_id, - document_record_id=updated_comment.document_record_id, + record_id=updated_comment.record_id, ) diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index b0f8ed0..8551247 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -154,7 +154,7 @@ def get_comments( text=comment.text, updated_at=comment.updated_at, author_id=comment.author_id, - document_record_id=comment.document_record_id, + record_id=comment.record_id, ) for comment in comments ] @@ -177,7 +177,7 @@ def create_comment( text=comment.text, updated_at=comment.updated_at, author_id=comment.author_id, - document_record_id=comment.document_record_id, + record_id=comment.record_id, ) diff --git a/backend/tests/routers/test_routes_comments.py b/backend/tests/routers/test_routes_comments.py index 30eb111..9d7fc43 100644 --- a/backend/tests/routers/test_routes_comments.py +++ b/backend/tests/routers/test_routes_comments.py @@ -32,13 +32,13 @@ def test_can_get_comments_for_record(user_logged_client: TestClient, session: Se Comment( text="First comment", author_id=1, - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ), Comment( text="Second comment", author_id=1, - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ), ] @@ -52,7 +52,7 @@ def test_can_get_comments_for_record(user_logged_client: TestClient, session: Se assert len(response_data) == 2 assert response_data[0]["text"] == "First comment" assert response_data[0]["author_id"] == 1 - assert response_data[0]["document_record_id"] == 1 + assert response_data[0]["record_id"] == 1 assert response_data[1]["text"] == "Second comment" @@ -111,7 +111,7 @@ def test_can_create_comment(user_logged_client: TestClient, session: Session): response_data = response.json() assert response_data["text"] == "This is a test comment" assert response_data["author_id"] == 1 - assert response_data["document_record_id"] == 1 + assert response_data["record_id"] == 1 assert "id" in response_data assert "updated_at" in response_data @@ -209,7 +209,7 @@ def test_can_update_own_comment(user_logged_client: TestClient, session: Session comment = Comment( text="Original text", author_id=1, # Same user as logged in - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) s.add(comment) @@ -247,7 +247,7 @@ def test_cannot_update_others_comment(user_logged_client: TestClient, session: S comment = Comment( text="Original text", author_id=other_user.id, # Different user - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) s.add(comment) @@ -277,7 +277,7 @@ def test_can_delete_own_comment(user_logged_client: TestClient, session: Session comment = Comment( text="Original text", author_id=1, # Same user as logged in - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) s.add(comment) @@ -318,7 +318,7 @@ def test_cannot_delete_others_comment(user_logged_client: TestClient, session: S comment = Comment( text="Original text", author_id=other_user.id, # Different user - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) s.add(comment) @@ -398,7 +398,7 @@ def test_admin_can_update_any_comment( comment = Comment( text="Original text", author_id=1, # Regular user - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) s.add(comment) @@ -432,7 +432,7 @@ def test_admin_can_delete_any_comment( comment = Comment( text="Original text", author_id=1, # Regular user - document_record_id=1, + record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) s.add(comment) diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index c4da3af..94265ee 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session +from app.comments.models import Comment from app.documents.models import ( DocMemoryAssociation, Document, @@ -11,7 +12,6 @@ DocumentType, ) from app.translation_memory.models import TranslationMemory, TranslationMemoryRecord -from app.comments.models import Comment # pylint: disable=C0116 @@ -728,12 +728,12 @@ def test_has_comments_field(user_logged_client: TestClient, session: Session): comment1 = Comment( text="First comment", author_id=1, - document_record_id=records[0].id, # First record + record_id=records[0].id, # First record ) comment2 = Comment( text="Second comment", author_id=1, - document_record_id=records[2].id, # Third record + record_id=records[2].id, # Third record ) s.add_all([comment1, comment2]) s.commit() @@ -764,7 +764,9 @@ def test_has_comments_field(user_logged_client: TestClient, session: Session): assert records_response[2]["has_comments"] -def test_has_comments_with_multiple_comments(user_logged_client: TestClient, session: Session): +def test_has_comments_with_multiple_comments( + user_logged_client: TestClient, session: Session +): """Test that has_comments is True when record has multiple comments""" with session as s: # Create document record @@ -782,9 +784,9 @@ def test_has_comments_with_multiple_comments(user_logged_client: TestClient, ses # Add multiple comments to the same record comments = [ - Comment(text="Comment 1", author_id=1, document_record_id=record.id), - Comment(text="Comment 2", author_id=1, document_record_id=record.id), - Comment(text="Comment 3", author_id=1, document_record_id=record.id), + Comment(text="Comment 1", author_id=1, record_id=record.id), + Comment(text="Comment 2", author_id=1, record_id=record.id), + Comment(text="Comment 3", author_id=1, record_id=record.id), ] s.add_all(comments) s.commit() From dead21734e111414ece5fd580b4ca7c35b898ab7 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 1 Dec 2025 01:23:06 +0300 Subject: [PATCH 07/10] Return user info with comments --- .../32d5a77e6615_add_comment_table.py | 4 ++-- backend/app/comments/models.py | 4 ++-- backend/app/comments/query.py | 6 +++--- backend/app/comments/schema.py | 7 +++++-- backend/app/routers/comments.py | 10 ++-------- backend/app/routers/document.py | 19 ++---------------- backend/app/schema.py | 4 +++- backend/tests/routers/test_routes_comments.py | 20 +++++++++---------- .../tests/routers/test_routes_doc_records.py | 10 +++++----- 9 files changed, 34 insertions(+), 50 deletions(-) diff --git a/backend/alembic/versions/32d5a77e6615_add_comment_table.py b/backend/alembic/versions/32d5a77e6615_add_comment_table.py index 28e9481..50e60fc 100644 --- a/backend/alembic/versions/32d5a77e6615_add_comment_table.py +++ b/backend/alembic/versions/32d5a77e6615_add_comment_table.py @@ -26,9 +26,9 @@ def upgrade() -> None: sa.Column("id", sa.Integer(), nullable=False), sa.Column("text", sa.String(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("created_by", sa.Integer(), nullable=False), sa.Column("record_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(["author_id"], ["user.id"], ), + sa.ForeignKeyConstraint(["created_by"], ["user.id"], ), sa.ForeignKeyConstraint(["record_id"], ["document_record.id"], ), sa.PrimaryKeyConstraint("id") ) diff --git a/backend/app/comments/models.py b/backend/app/comments/models.py index d7cb057..6f698fa 100644 --- a/backend/app/comments/models.py +++ b/backend/app/comments/models.py @@ -17,8 +17,8 @@ class Comment(Base): id: Mapped[int] = mapped_column(primary_key=True) text: Mapped[str] = mapped_column() updated_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC)) - author_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + created_by: Mapped[int] = mapped_column(ForeignKey("user.id")) record_id: Mapped[int] = mapped_column(ForeignKey("document_record.id")) - author: Mapped["User"] = relationship("User", back_populates="comments") + created_by_user: Mapped["User"] = relationship("User", back_populates="comments") document_record: Mapped["DocumentRecord"] = relationship(back_populates="comments") diff --git a/backend/app/comments/query.py b/backend/app/comments/query.py index 70849ab..7b5ebf9 100644 --- a/backend/app/comments/query.py +++ b/backend/app/comments/query.py @@ -20,12 +20,12 @@ def __init__(self, db: Session) -> None: self.__db = db def create_comment( - self, comment_data: CommentCreate, author_id: int, record_id: int + self, comment_data: CommentCreate, created_by: int, record_id: int ) -> Comment: """Create a new comment""" comment = Comment( text=comment_data.text, - author_id=author_id, + created_by=created_by, record_id=record_id, updated_at=datetime.now(UTC), ) @@ -46,7 +46,7 @@ def get_comments_by_document_record(self, record_id: int) -> Sequence[Comment]: self.__db.execute( select(Comment) .filter(Comment.record_id == record_id) - .order_by(Comment.updated_at) + .order_by(Comment.updated_at.desc()) ) .scalars() .all() diff --git a/backend/app/comments/schema.py b/backend/app/comments/schema.py index 016611d..c719ef6 100644 --- a/backend/app/comments/schema.py +++ b/backend/app/comments/schema.py @@ -1,8 +1,9 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from app.base.schema import Identified +from app.models import ShortUser class CommentCreate(BaseModel): @@ -16,5 +17,7 @@ class CommentUpdate(BaseModel): class CommentResponse(Identified): text: str updated_at: datetime - author_id: int record_id: int + created_by_user: ShortUser + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index 7b556ef..a936592 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -27,7 +27,7 @@ def get_comment_by_id(db: Session, comment_id: int): def check_comment_authorship(comment, current_user_id: int, is_admin: bool = False): """Check if user can modify/delete comment""" - if not is_admin and comment.author_id != current_user_id: + if not is_admin and comment.created_by != current_user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only modify your own comments", @@ -50,13 +50,7 @@ def update_comment( check_comment_authorship(comment, current_user, is_admin) updated_comment = CommentsQuery(db).update_comment(comment_id, comment_data) - return CommentResponse( - id=updated_comment.id, - text=updated_comment.text, - updated_at=updated_comment.updated_at, - author_id=updated_comment.author_id, - record_id=updated_comment.record_id, - ) + return CommentResponse.model_validate(updated_comment) @router.delete("/{comment_id}") diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index 8551247..e60f476 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -148,16 +148,7 @@ def get_comments( get_doc_record_by_id(db, record_id) comments = CommentsQuery(db).get_comments_by_document_record(record_id) - return [ - CommentResponse( - id=comment.id, - text=comment.text, - updated_at=comment.updated_at, - author_id=comment.author_id, - record_id=comment.record_id, - ) - for comment in comments - ] + return [CommentResponse.model_validate(comment) for comment in comments] @router.post("/records/{record_id}/comments") @@ -172,13 +163,7 @@ def create_comment( get_doc_record_by_id(db, record_id) comment = CommentsQuery(db).create_comment(comment_data, current_user, record_id) - return CommentResponse( - id=comment.id, - text=comment.text, - updated_at=comment.updated_at, - author_id=comment.author_id, - record_id=comment.record_id, - ) + return CommentResponse.model_validate(comment) @router.get("/records/{record_id}/substitutions") diff --git a/backend/app/schema.py b/backend/app/schema.py index 3fe7475..6826c13 100644 --- a/backend/app/schema.py +++ b/backend/app/schema.py @@ -43,5 +43,7 @@ class User(Base): order_by="Glossary.id", ) comments: Mapped[list["Comment"]] = relationship( - back_populates="author", cascade="all, delete-orphan", order_by="Comment.id" + back_populates="created_by_user", + cascade="all, delete-orphan", + order_by="Comment.id", ) diff --git a/backend/tests/routers/test_routes_comments.py b/backend/tests/routers/test_routes_comments.py index 9d7fc43..1e02a2d 100644 --- a/backend/tests/routers/test_routes_comments.py +++ b/backend/tests/routers/test_routes_comments.py @@ -31,13 +31,13 @@ def test_can_get_comments_for_record(user_logged_client: TestClient, session: Se comments = [ Comment( text="First comment", - author_id=1, + created_by=1, record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ), Comment( text="Second comment", - author_id=1, + created_by=1, record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ), @@ -51,7 +51,7 @@ def test_can_get_comments_for_record(user_logged_client: TestClient, session: Se response_data = response.json() assert len(response_data) == 2 assert response_data[0]["text"] == "First comment" - assert response_data[0]["author_id"] == 1 + assert response_data[0]["created_by_user"]["id"] == 1 assert response_data[0]["record_id"] == 1 assert response_data[1]["text"] == "Second comment" @@ -110,7 +110,7 @@ def test_can_create_comment(user_logged_client: TestClient, session: Session): assert response.status_code == 200 response_data = response.json() assert response_data["text"] == "This is a test comment" - assert response_data["author_id"] == 1 + assert response_data["created_by_user"]["id"] == 1 assert response_data["record_id"] == 1 assert "id" in response_data assert "updated_at" in response_data @@ -208,7 +208,7 @@ def test_can_update_own_comment(user_logged_client: TestClient, session: Session comment = Comment( text="Original text", - author_id=1, # Same user as logged in + created_by=1, # Same user as logged in record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) @@ -246,7 +246,7 @@ def test_cannot_update_others_comment(user_logged_client: TestClient, session: S ) comment = Comment( text="Original text", - author_id=other_user.id, # Different user + created_by=other_user.id, # Different user record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) @@ -276,7 +276,7 @@ def test_can_delete_own_comment(user_logged_client: TestClient, session: Session comment = Comment( text="Original text", - author_id=1, # Same user as logged in + created_by=1, # Same user as logged in record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) @@ -317,7 +317,7 @@ def test_cannot_delete_others_comment(user_logged_client: TestClient, session: S ) comment = Comment( text="Original text", - author_id=other_user.id, # Different user + created_by=other_user.id, # Different user record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) @@ -397,7 +397,7 @@ def test_admin_can_update_any_comment( # Create comment by regular user (id=1) comment = Comment( text="Original text", - author_id=1, # Regular user + created_by=1, # Regular user record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) @@ -431,7 +431,7 @@ def test_admin_can_delete_any_comment( # Create comment by regular user (id=1) comment = Comment( text="Original text", - author_id=1, # Regular user + created_by=1, # Regular user record_id=1, updated_at=datetime.datetime.now(datetime.UTC), ) diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index 94265ee..a4f9a90 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -727,12 +727,12 @@ def test_has_comments_field(user_logged_client: TestClient, session: Session): # Add comments to first and third records comment1 = Comment( text="First comment", - author_id=1, + created_by=1, record_id=records[0].id, # First record ) comment2 = Comment( text="Second comment", - author_id=1, + created_by=1, record_id=records[2].id, # Third record ) s.add_all([comment1, comment2]) @@ -784,9 +784,9 @@ def test_has_comments_with_multiple_comments( # Add multiple comments to the same record comments = [ - Comment(text="Comment 1", author_id=1, record_id=record.id), - Comment(text="Comment 2", author_id=1, record_id=record.id), - Comment(text="Comment 3", author_id=1, record_id=record.id), + Comment(text="Comment 1", created_by=1, record_id=record.id), + Comment(text="Comment 2", created_by=1, record_id=record.id), + Comment(text="Comment 3", created_by=1, record_id=record.id), ] s.add_all(comments) s.commit() From 98f47f9dcf5c56e7bdf9d0daf934600ea03ffda9 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 1 Dec 2025 01:30:22 +0300 Subject: [PATCH 08/10] Update frontend code with the latest path updates --- frontend/src/components/document/SubstitutionsList.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/document/SubstitutionsList.vue b/frontend/src/components/document/SubstitutionsList.vue index 9206862..9fae1ce 100644 --- a/frontend/src/components/document/SubstitutionsList.vue +++ b/frontend/src/components/document/SubstitutionsList.vue @@ -28,7 +28,7 @@ const {data: substitutions} = useQuery({ key: () => ['substitutions', documentId, currentSegmentId ?? -1], query: async () => { const memorySubs = ( - await getRecordSubstitutions(documentId, currentSegmentId!) + await getRecordSubstitutions(currentSegmentId!) ) .map((sub): MemorySubstitution => { return {type: 'memory', ...sub} @@ -36,7 +36,7 @@ const {data: substitutions} = useQuery({ .sort((a, b) => b.similarity - a.similarity) const glossarySubs = ( - await getRecordGlossaryRecords(documentId, currentSegmentId!) + await getRecordGlossaryRecords(currentSegmentId!) ) .map((sub): GlossarySubstitution => { return { From 33b3615e3209071faa7d8648e726511885961af3 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Mon, 1 Dec 2025 01:31:35 +0300 Subject: [PATCH 09/10] Add comments modal in frontend --- frontend/mocks/documentMocks.ts | 186 +++++++++++++++++- frontend/src/components/DocSegment.vue | 21 +- .../document/RecordCommentModal.vue | 137 +++++++++++++ frontend/src/views/DocView.vue | 16 ++ 4 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/document/RecordCommentModal.vue diff --git a/frontend/mocks/documentMocks.ts b/frontend/mocks/documentMocks.ts index f346993..16aa97d 100644 --- a/frontend/mocks/documentMocks.ts +++ b/frontend/mocks/documentMocks.ts @@ -1,8 +1,9 @@ import {http, HttpResponse} from 'msw' -import {faker} from '@faker-js/faker' +import {faker, fakerRU} from '@faker-js/faker' import {AwaitedReturnType} from './utils' import { + getComments, getDoc, getDocRecords, getDocs, @@ -12,14 +13,180 @@ import { } from '../src/client/services/DocumentService' import {DocumentStatus} from '../src/client/schemas/DocumentStatus' import {DocumentRecordUpdate} from '../src/client/schemas/DocumentRecordUpdate' +import {CommentResponse} from '../src/client/schemas/CommentResponse' +import {DocumentRecord} from '../src/client/schemas/DocumentRecord' -const segments = [ +const segmentComments: CommentResponse[] = [ + { + id: 1, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10001, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 2, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 3, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 4, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 5, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 6, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 7, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 8, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 9, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 10, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 11, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 12, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 13, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 14, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 15, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, + { + id: 16, + created_by_user: { + id: 42, + username: faker.internet.email(), + }, + record_id: 10002, + text: fakerRU.commerce.productDescription(), + updated_at: faker.date.recent().toISOString().split('.')[0], + }, +] + +const segments: DocumentRecord[] = [ { id: 10000, approved: false, source: 'Adventure Hooks', target: 'Зацепки приключения', repetitions_count: 2, + has_comments: false, }, { id: 10001, @@ -29,6 +196,7 @@ const segments = [ target: 'В тот момент, когда кинидийцы извлекли рог из монолита, их город был обречен.', repetitions_count: 1, + has_comments: true, }, { id: 10002, @@ -36,6 +204,7 @@ const segments = [ source: 'Adventure Hooks', target: 'Зацепки приключения', repetitions_count: 2, + has_comments: true, }, ] @@ -164,4 +333,17 @@ export const documentMocks = [ } } ), + + http.get<{segmentId: string}>( + 'http://localhost:8000/document/records/:segmentId/comments', + ({params}) => { + const output: AwaitedReturnType = [] + for (const comm of segmentComments) { + if (comm.record_id == Number(params.segmentId)) { + output.push(comm) + } + } + return HttpResponse.json(output) + } + ), ] diff --git a/frontend/src/components/DocSegment.vue b/frontend/src/components/DocSegment.vue index eab620b..959314d 100644 --- a/frontend/src/components/DocSegment.vue +++ b/frontend/src/components/DocSegment.vue @@ -14,6 +14,7 @@ const props = defineProps<{ disabled?: boolean approved?: boolean repetitionsCount?: number + hasComments?: boolean }>() const emit = defineEmits<{ @@ -21,6 +22,7 @@ const emit = defineEmits<{ updateRecord: [string, boolean] focus: [] startEdit: [] + addComment: [] }>() const targetInput = useTemplateRef('targetInput') @@ -64,6 +66,14 @@ const repetitionTitle = computed(() => { ` updating of the same segments. Repeated ${props.repetitionsCount} times.` ) }) + +const icon = computed( + () => 'pi' + (props.hasComments ? ' pi-comments' : ' pi-comment') +) + +const showCommentsDialog = () => { + emit('addComment') +}