feat: add comments feature with nested replies and recursive rendering
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:
2026-05-11 15:34:20 +03:00
parent 63da25174e
commit 7ff3fa0992
40 changed files with 3161 additions and 44 deletions

View File

@@ -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",

View File

@@ -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"]

View 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,
)

View 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(),
}

View File

@@ -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"]

View 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.
"""
...

View File

@@ -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"]

View 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")