Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/alembic/versions/32d5a77e6615_add_comment_table.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from app.comments.models import Comment
from app.db import Base
from app.documents.models import (
Document,
Expand All @@ -17,6 +18,7 @@

__all__ = [
"Base",
"Comment",
"DocumentTask",
"TranslationMemory",
"TranslationMemoryRecord",
Expand Down
1 change: 1 addition & 0 deletions backend/app/comments/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Comments module
24 changes: 24 additions & 0 deletions backend/app/comments/models.py
Original file line number Diff line number Diff line change
@@ -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")
74 changes: 74 additions & 0 deletions backend/app/comments/query.py
Original file line number Diff line number Diff line change
@@ -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()
23 changes: 23 additions & 0 deletions backend/app/comments/schema.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions backend/app/documents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
21 changes: 20 additions & 1 deletion backend/app/documents/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -129,19 +130,37 @@ 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(
DocumentRecord,
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
Expand Down
1 change: 1 addition & 0 deletions backend/app/documents/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class DocumentRecord(Identified):
target: str
approved: bool
repetitions_count: int
has_comments: bool


class DocumentRecordListResponse(BaseModel):
Expand Down
71 changes: 71 additions & 0 deletions backend/app/routers/comments.py
Original file line number Diff line number Diff line change
@@ -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")
Loading