feat: add comments feature with nested replies and recursive rendering
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Implement full comments system: domain entities (Comment, CommentLike), value objects (CommentContent), use cases (CRUD, like toggle), SQLAlchemy repository, API v1 endpoints, web UI with comment form and nested replies, i18n translations (EN/RU/FR/DE), and E2E tests. Fix nested reply (reply-to-reply) not displaying — the flat reply_comments dict was only queried for top-level comment IDs, so deeply nested replies were saved to DB (incrementing comment count) but never rendered. Switch to a recursive Jinja2 macro that renders any nesting depth.
This commit is contained in:
@@ -4,11 +4,15 @@ This module re-exports all application use cases that implement
|
||||
business logic operations for the blog API.
|
||||
"""
|
||||
|
||||
from app.application.use_cases.create_comment import CreateCommentUseCase
|
||||
from app.application.use_cases.create_post import CreatePostUseCase
|
||||
from app.application.use_cases.delete_comment import DeleteCommentUseCase
|
||||
from app.application.use_cases.delete_post import DeletePostUseCase
|
||||
from app.application.use_cases.get_post import GetPostUseCase
|
||||
from app.application.use_cases.list_comments import ListCommentsUseCase
|
||||
from app.application.use_cases.list_posts import ListPostsUseCase
|
||||
from app.application.use_cases.publish_post import PublishPostUseCase
|
||||
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
|
||||
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
|
||||
from app.application.use_cases.update_post import UpdatePostUseCase
|
||||
|
||||
@@ -20,4 +24,8 @@ __all__ = [
|
||||
"ListPostsUseCase",
|
||||
"PublishPostUseCase",
|
||||
"TogglePostLikeUseCase",
|
||||
"CreateCommentUseCase",
|
||||
"DeleteCommentUseCase",
|
||||
"ListCommentsUseCase",
|
||||
"ToggleCommentLikeUseCase",
|
||||
]
|
||||
|
||||
100
app/application/use_cases/create_comment.py
Normal file
100
app/application/use_cases/create_comment.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Create comment use case.
|
||||
|
||||
This module implements the use case for creating comments on blog posts.
|
||||
Supports both top-level comments and nested replies via parent_id.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.exceptions import NotFoundException
|
||||
from app.domain.repositories import CommentRepository, PostRepository
|
||||
|
||||
|
||||
class CreateCommentUseCase:
|
||||
"""Use case for creating a comment on a blog post.
|
||||
|
||||
Handles top-level comments and replies to existing comments.
|
||||
Validates that the target post exists before creating.
|
||||
|
||||
Attributes:
|
||||
_post_repo: Repository for post data access.
|
||||
_comment_repo: Repository for comment data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
post_repo: Repository for post operations.
|
||||
comment_repo: Repository for comment operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._post_repo = post_repo
|
||||
self._comment_repo = comment_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
post_id: UUID,
|
||||
author_id: str,
|
||||
content: str,
|
||||
parent_id: UUID | None = None,
|
||||
) -> CommentResponseDTO:
|
||||
"""Execute the use case to create a comment.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to comment on.
|
||||
author_id: Identifier of the comment author.
|
||||
content: Comment content (Markdown supported).
|
||||
parent_id: Optional UUID of parent comment for replies.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with created comment data.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If the target post does not exist.
|
||||
"""
|
||||
post = await self._post_repo.get_by_id(post_id)
|
||||
if not post:
|
||||
raise NotFoundException(f"Post with id '{post_id}' not found")
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
await self._comment_repo.add(comment)
|
||||
await self._tx_manager.commit()
|
||||
|
||||
return self._map_to_dto(comment)
|
||||
|
||||
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with all comment attributes.
|
||||
"""
|
||||
return CommentResponseDTO(
|
||||
id=comment.id,
|
||||
post_id=comment.post_id,
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=comment.parent_id,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
60
app/application/use_cases/delete_comment.py
Normal file
60
app/application/use_cases/delete_comment.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Delete comment use case.
|
||||
|
||||
This module implements the use case for deleting comments.
|
||||
Users can delete their own comments.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.exceptions import ForbiddenException, NotFoundException
|
||||
from app.domain.repositories import CommentRepository
|
||||
|
||||
|
||||
class DeleteCommentUseCase:
|
||||
"""Use case for deleting a comment.
|
||||
|
||||
Allows users to delete their own comments.
|
||||
|
||||
Attributes:
|
||||
_comment_repo: Repository for comment data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
comment_repo: Repository for comment operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._comment_repo = comment_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
comment_id: UUID,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
"""Delete a comment.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment to delete.
|
||||
user_id: Identifier of the user requesting deletion.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If the comment does not exist.
|
||||
"""
|
||||
comment = await self._comment_repo.get_by_id(comment_id)
|
||||
if not comment:
|
||||
raise NotFoundException(f"Comment with id '{comment_id}' not found")
|
||||
|
||||
if comment.author_id != user_id:
|
||||
raise ForbiddenException("You are not allowed to delete this comment")
|
||||
|
||||
await self._comment_repo.delete(comment_id)
|
||||
await self._tx_manager.commit()
|
||||
63
app/application/use_cases/list_comments.py
Normal file
63
app/application/use_cases/list_comments.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""List comments use case.
|
||||
|
||||
This module implements the use case for listing comments on a blog post.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.repositories import CommentRepository
|
||||
|
||||
|
||||
class ListCommentsUseCase:
|
||||
"""Use case for listing comments on a blog post.
|
||||
|
||||
Retrieves all comments for a given post ordered by creation time.
|
||||
|
||||
Attributes:
|
||||
_comment_repo: Repository for comment data access.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
comment_repo: Repository for comment operations.
|
||||
"""
|
||||
self._comment_repo = comment_repo
|
||||
|
||||
async def execute(self, post_id: UUID) -> list[CommentResponseDTO]:
|
||||
"""List all comments for a post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
List of CommentResponseDTO for the post.
|
||||
"""
|
||||
comments = await self._comment_repo.get_by_post(post_id)
|
||||
return [self._map_to_dto(c) for c in comments]
|
||||
|
||||
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with all comment attributes.
|
||||
"""
|
||||
return CommentResponseDTO(
|
||||
id=comment.id,
|
||||
post_id=comment.post_id,
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=comment.parent_id,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
96
app/application/use_cases/toggle_comment_like.py
Normal file
96
app/application/use_cases/toggle_comment_like.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Toggle comment like use case.
|
||||
|
||||
This module implements the use case for toggling likes on comments.
|
||||
If the user already liked the comment, the like is removed (unlike).
|
||||
If not, a new like is added.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.exceptions import NotFoundException
|
||||
from app.domain.repositories import CommentRepository
|
||||
|
||||
|
||||
class ToggleCommentLikeUseCase:
|
||||
"""Use case for toggling a like on a comment.
|
||||
|
||||
Handles like/unlike toggle logic. If the user has already liked
|
||||
the comment, the like is removed. Otherwise, a new like is created.
|
||||
|
||||
Attributes:
|
||||
_comment_repo: Repository for comment and like data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
comment_repo: Repository for comment and like operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._comment_repo = comment_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(self, comment_id: UUID, liked_by: str) -> CommentResponseDTO:
|
||||
"""Toggle like on a comment.
|
||||
|
||||
If the user already liked the comment, remove the like.
|
||||
Otherwise, add a new like.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment to toggle like on.
|
||||
liked_by: User ID.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with updated like_count.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If comment with given ID does not exist.
|
||||
"""
|
||||
comment = await self._comment_repo.get_by_id(comment_id)
|
||||
if not comment:
|
||||
raise NotFoundException(f"Comment with id '{comment_id}' not found")
|
||||
|
||||
existing_like = await self._comment_repo.get_like(comment_id, liked_by)
|
||||
|
||||
if existing_like:
|
||||
await self._comment_repo.remove_like(comment_id, liked_by)
|
||||
comment.like_count = max(0, comment.like_count - 1)
|
||||
else:
|
||||
new_like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||
await self._comment_repo.add_like(new_like)
|
||||
comment.like_count += 1
|
||||
|
||||
await self._comment_repo.update(comment)
|
||||
await self._tx_manager.commit()
|
||||
|
||||
return self._map_to_dto(comment)
|
||||
|
||||
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with all comment attributes including like_count.
|
||||
"""
|
||||
return CommentResponseDTO(
|
||||
id=comment.id,
|
||||
post_id=comment.post_id,
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=comment.parent_id,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
Reference in New Issue
Block a user