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..50e60fc --- /dev/null +++ b/backend/alembic/versions/32d5a77e6615_add_comment_table.py @@ -0,0 +1,38 @@ +"""Add record 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( + "record_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("created_by", sa.Integer(), nullable=False), + sa.Column("record_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["created_by"], ["user.id"], ), + sa.ForeignKeyConstraint(["record_id"], ["document_record.id"], ), + sa.PrimaryKeyConstraint("id") + ) + + +def downgrade() -> None: + op.drop_table("record_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..6f698fa --- /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__ = "record_comment" + + id: Mapped[int] = mapped_column(primary_key=True) + text: Mapped[str] = mapped_column() + updated_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC)) + created_by: Mapped[int] = mapped_column(ForeignKey("user.id")) + record_id: Mapped[int] = mapped_column(ForeignKey("document_record.id")) + + 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 new file mode 100644 index 0000000..7b5ebf9 --- /dev/null +++ b/backend/app/comments/query.py @@ -0,0 +1,74 @@ +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, created_by: int, record_id: int + ) -> Comment: + """Create a new comment""" + comment = Comment( + text=comment_data.text, + created_by=created_by, + record_id=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, record_id: int) -> Sequence[Comment]: + """Get all comments for a document record""" + return ( + self.__db.execute( + select(Comment) + .filter(Comment.record_id == record_id) + .order_by(Comment.updated_at.desc()) + ) + .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..c719ef6 --- /dev/null +++ b/backend/app/comments/schema.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.base.schema import Identified +from app.models import ShortUser + + +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 + record_id: int + created_by_user: ShortUser + + model_config = ConfigDict(from_attributes=True) 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/documents/query.py b/backend/app/documents/query.py index deb9c99..de0b370 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -5,6 +5,7 @@ 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 @@ -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.record_id, + func.count(Comment.id).label("comments_count"), + ) + .group_by(Comment.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.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/comments.py b/backend/app/routers/comments.py new file mode 100644 index 0000000..a936592 --- /dev/null +++ b/backend/app/routers/comments.py @@ -0,0 +1,71 @@ +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.created_by != 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.model_validate(updated_comment) + + +@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..e60f476 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)], @@ -114,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( @@ -125,18 +138,45 @@ def get_doc_records( ) -@router.get("/{doc_id}/records/{record_id}/substitutions") +@router.get("/records/{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.model_validate(comment) for comment in comments] + + +@router.post("/records/{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.model_validate(comment) + + +@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 @@ -144,18 +184,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) @@ -168,7 +207,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/app/schema.py b/backend/app/schema.py index 003ac40..6826c13 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,8 @@ class User(Base): cascade="all, delete-orphan", order_by="Glossary.id", ) + comments: Mapped[list["Comment"]] = relationship( + back_populates="created_by_user", + 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/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 new file mode 100644 index 0000000..ba8b448 --- /dev/null +++ b/backend/tests/routers/test_routes_comments.py @@ -0,0 +1,452 @@ +import datetime + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.comments.models import Comment +from app.documents.models import Document, DocumentRecord, DocumentType + +# 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", + created_by=1, + record_id=1, + # this is more recent, so should be the first + updated_at=datetime.datetime.now(datetime.UTC) + + datetime.timedelta(days=1), + ), + Comment( + text="Second comment", + created_by=1, + 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/records/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]["created_by_user"]["id"] == 1 + assert response_data[0]["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/records/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/records/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/records/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["created_by_user"]["id"] == 1 + assert response_data["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/records/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/records/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/records/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/records/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", + created_by=1, # Same user as logged in + 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", + created_by=other_user.id, # Different user + 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", + created_by=1, # Same user as logged in + 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", + created_by=other_user.id, # Different user + 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/records/1/comments") + assert response.status_code == 401 + + # Test POST comment + response = fastapi_client.post( + "/document/records/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", + created_by=1, # Regular user + 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", + created_by=1, # Regular user + 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 diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index ac6b898..a4f9a90 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, @@ -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, } @@ -163,7 +167,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 +214,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 +269,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 +297,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 +323,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 +351,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 +394,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 +663,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 +685,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 @@ -694,5 +698,105 @@ 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 + + +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", + created_by=1, + record_id=records[0].id, # First record + ) + comment2 = Comment( + text="Second comment", + created_by=1, + 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", 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() + + 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"] 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 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/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`) 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') +}