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,7 +4,7 @@ This module re-exports all domain layer components including
|
||||
entities, value objects, repositories, and exceptions.
|
||||
"""
|
||||
|
||||
from app.domain.entities import BaseEntity, Post, PostLike
|
||||
from app.domain.entities import BaseEntity, Comment, CommentLike, Post, PostLike
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
DomainException,
|
||||
@@ -13,19 +13,22 @@ from app.domain.exceptions import (
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.domain.repositories import PostRepository, Repository
|
||||
from app.domain.repositories import CommentRepository, PostRepository, Repository
|
||||
from app.domain.value_objects import Content, Slug, Title, ValueObject
|
||||
|
||||
__all__ = [
|
||||
"BaseEntity",
|
||||
"Post",
|
||||
"PostLike",
|
||||
"Comment",
|
||||
"CommentLike",
|
||||
"ValueObject",
|
||||
"Title",
|
||||
"Content",
|
||||
"Slug",
|
||||
"Repository",
|
||||
"PostRepository",
|
||||
"CommentRepository",
|
||||
"DomainException",
|
||||
"ValidationException",
|
||||
"NotFoundException",
|
||||
|
||||
@@ -5,7 +5,9 @@ core business objects with identity.
|
||||
"""
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.entities.post import Post
|
||||
|
||||
__all__ = ["BaseEntity", "Post", "PostLike"]
|
||||
__all__ = ["BaseEntity", "Post", "PostLike", "Comment", "CommentLike"]
|
||||
|
||||
79
app/domain/entities/comment.py
Normal file
79
app/domain/entities/comment.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Domain entity for Comment.
|
||||
|
||||
This module defines the Comment entity that represents a comment on a blog
|
||||
post. Comments can be top-level (parent_id=None) or replies to other
|
||||
comments (parent_id set).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Comment(BaseEntity):
|
||||
"""Comment domain entity.
|
||||
|
||||
Represents a comment on a blog post with optional parent reference
|
||||
for nested replies. Supports Markdown content and like tracking.
|
||||
|
||||
Attributes:
|
||||
post_id: UUID of the post this comment belongs to.
|
||||
author_id: Identifier of the comment author.
|
||||
content: CommentContent value object with Markdown text.
|
||||
parent_id: UUID of parent comment for replies, or None.
|
||||
like_count: Number of likes on this comment.
|
||||
"""
|
||||
|
||||
post_id: UUID
|
||||
author_id: str
|
||||
content: CommentContent
|
||||
parent_id: UUID | None = None
|
||||
like_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation with all comment attributes.
|
||||
"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"post_id": str(self.post_id),
|
||||
"author_id": self.author_id,
|
||||
"content": self.content.value,
|
||||
"parent_id": str(self.parent_id) if self.parent_id else None,
|
||||
"like_count": self.like_count,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
post_id: UUID,
|
||||
author_id: str,
|
||||
content_str: str,
|
||||
parent_id: UUID | None = None,
|
||||
) -> "Comment":
|
||||
"""Factory method to create a new comment.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to comment on.
|
||||
author_id: Identifier of the comment author.
|
||||
content_str: Comment content string (Markdown supported).
|
||||
parent_id: Optional UUID of parent comment for replies.
|
||||
|
||||
Returns:
|
||||
New Comment instance with validated content.
|
||||
"""
|
||||
content = CommentContent(content_str)
|
||||
return cls(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
40
app/domain/entities/comment_like.py
Normal file
40
app/domain/entities/comment_like.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Domain entity for CommentLike.
|
||||
|
||||
This module defines the CommentLike entity that tracks which users
|
||||
have liked which comments.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class CommentLike(BaseEntity):
|
||||
"""Comment like domain entity.
|
||||
|
||||
Tracks a like on a comment by a user. Each like is uniquely
|
||||
identified by its entity ID.
|
||||
|
||||
Attributes:
|
||||
comment_id: UUID of the liked comment.
|
||||
liked_by: Identifier of the user who liked.
|
||||
"""
|
||||
|
||||
comment_id: UUID
|
||||
liked_by: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary with all CommentLike attributes.
|
||||
"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"comment_id": str(self.comment_id),
|
||||
"liked_by": self.liked_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -5,6 +5,7 @@ the contract for data access operations.
|
||||
"""
|
||||
|
||||
from app.domain.repositories.base import Repository
|
||||
from app.domain.repositories.comment import CommentRepository
|
||||
from app.domain.repositories.post import PostRepository
|
||||
|
||||
__all__ = ["Repository", "PostRepository"]
|
||||
__all__ = ["Repository", "PostRepository", "CommentRepository"]
|
||||
|
||||
80
app/domain/repositories/comment.py
Normal file
80
app/domain/repositories/comment.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Comment repository interface.
|
||||
|
||||
This module defines the repository interface for Comment entities
|
||||
including nested comment queries and like management.
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.repositories.base import Repository
|
||||
|
||||
|
||||
class CommentRepository(Repository[Comment]):
|
||||
"""Repository interface for Comments.
|
||||
|
||||
Extends the generic repository with comment-specific operations
|
||||
including post-based listing and like management.
|
||||
|
||||
Example:
|
||||
>>> comments = await repo.get_by_post(post_id)
|
||||
>>> like = await repo.get_like(comment_id, "user-123")
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_post(self, post_id: UUID) -> list[Comment]:
|
||||
"""Get all comments for a post, ordered by creation time.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
List of Comment entities for the post.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
|
||||
"""Get a like by comment and user.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
liked_by: User ID.
|
||||
|
||||
Returns:
|
||||
CommentLike if found, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def add_like(self, like: CommentLike) -> None:
|
||||
"""Add a new like to a comment.
|
||||
|
||||
Args:
|
||||
like: CommentLike entity to add.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def count_by_post(self, post_id: UUID) -> int:
|
||||
"""Get comment count for a post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
Number of comments on the post.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
|
||||
"""Remove a like from a comment by user.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
liked_by: User ID.
|
||||
"""
|
||||
...
|
||||
@@ -5,8 +5,9 @@ immutable validated domain concepts.
|
||||
"""
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
from app.domain.value_objects.content import Content
|
||||
from app.domain.value_objects.slug import Slug
|
||||
from app.domain.value_objects.title import Title
|
||||
|
||||
__all__ = ["ValueObject", "Title", "Content", "Slug"]
|
||||
__all__ = ["ValueObject", "Title", "Content", "Slug", "CommentContent"]
|
||||
|
||||
47
app/domain/value_objects/comment_content.py
Normal file
47
app/domain/value_objects/comment_content.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Value object for comment content.
|
||||
|
||||
This module defines the CommentContent value object that validates
|
||||
and encapsulates comment text content.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommentContent(ValueObject[str]):
|
||||
"""Comment content value object.
|
||||
|
||||
Wraps and validates comment content ensuring it meets length
|
||||
requirements and is not empty.
|
||||
|
||||
Attributes:
|
||||
value: The comment content string.
|
||||
MAX_LENGTH: Maximum allowed content length (5000 characters).
|
||||
|
||||
Raises:
|
||||
ValueError: If content is empty or too long.
|
||||
|
||||
Example:
|
||||
>>> content = CommentContent("This is a **bold** comment.")
|
||||
>>> content.value
|
||||
'This is a **bold** comment.'
|
||||
"""
|
||||
|
||||
MAX_LENGTH: int = 5000
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate comment content.
|
||||
|
||||
Checks that content is a non-empty string within length bounds.
|
||||
|
||||
Raises:
|
||||
ValueError: If content fails validation criteria.
|
||||
"""
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("Comment content must be a string")
|
||||
if not self.value.strip():
|
||||
raise ValueError("Comment content cannot be empty")
|
||||
if len(self.value) > self.MAX_LENGTH:
|
||||
raise ValueError(f"Comment content must be at most {self.MAX_LENGTH} characters")
|
||||
Reference in New Issue
Block a user