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,10 +4,12 @@ This module defines the database ORM models that map to database tables.
Models are used by repositories for data persistence.
"""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
@@ -43,7 +45,10 @@ class PostORM(Base): # type: ignore[valid-type,misc]
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
like_count: Mapped[int] = mapped_column(default=0, nullable=False)
likes: Mapped[list["PostLikeORM"]] = relationship(
likes: Mapped[list[PostLikeORM]] = relationship(
back_populates="post", cascade="all, delete-orphan"
)
comments: Mapped[list[CommentORM]] = relationship(
back_populates="post", cascade="all, delete-orphan"
)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
@@ -87,3 +92,87 @@ class PostLikeORM(Base): # type: ignore[valid-type,misc]
)
post: Mapped[PostORM] = relationship(back_populates="likes")
class CommentORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for Comment.
Database table representation of comments on blog posts.
Maps to the 'comments' table with support for nested replies.
Attributes:
id: Primary key as UUID string.
post_id: Foreign key to the parent post.
author_id: Comment author reference.
content: Comment text content (Markdown supported).
parent_id: Optional foreign key to parent comment (replies).
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
__tablename__ = "comments"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
post_id: Mapped[str] = mapped_column(
String(36), ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True
)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("comments.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
like_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
post: Mapped[PostORM] = relationship(back_populates="comments")
replies: Mapped[list[CommentORM]] = relationship(
back_populates="parent",
)
parent: Mapped[CommentORM | None] = relationship(
back_populates="replies",
remote_side="CommentORM.id",
)
class CommentLikeORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for CommentLike.
Database table representation of comment likes.
Maps to the 'comment_likes' table tracking which users liked which comments.
Attributes:
id: Primary key as UUID string.
comment_id: Foreign key to the liked comment.
liked_by: User identifier.
created_at: Creation timestamp.
"""
__tablename__ = "comment_likes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
comment_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("comments.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
liked_by: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)

View File

@@ -10,19 +10,24 @@ from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.application import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
from app.application.interfaces import TransactionManager
from app.domain.repositories import PostRepository
from app.domain.repositories import CommentRepository, PostRepository
from app.infrastructure.auth import KeycloakAuthClient, MockKeycloakClient
from app.infrastructure.config.settings import settings
from app.infrastructure.database.connection import AsyncSessionLocal, engine
from app.infrastructure.repositories.comment import SQLAlchemyCommentRepository
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
@@ -81,6 +86,18 @@ class RepositoryProvider(Provider):
"""
return SQLAlchemyPostRepository(session)
@provide(scope=Scope.REQUEST)
def get_comment_repository(self, session: AsyncSession) -> CommentRepository:
"""Provide CommentRepository implementation.
Args:
session: Database session from DI container.
Returns:
SQLAlchemyCommentRepository instance.
"""
return SQLAlchemyCommentRepository(session)
class TransactionManagerProvider(Provider):
"""Provider for transaction manager.
@@ -257,6 +274,86 @@ class UseCaseProvider(Provider):
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_create_comment_use_case(
self,
post_repo: PostRepository,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> CreateCommentUseCase:
"""Provide CreateCommentUseCase.
Args:
post_repo: Post repository dependency.
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured CreateCommentUseCase instance.
"""
return CreateCommentUseCase(
post_repo=post_repo,
comment_repo=comment_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_list_comments_use_case(
self,
comment_repo: CommentRepository,
) -> ListCommentsUseCase:
"""Provide ListCommentsUseCase.
Args:
comment_repo: Comment repository dependency.
Returns:
Configured ListCommentsUseCase instance.
"""
return ListCommentsUseCase(
comment_repo=comment_repo,
)
@provide(scope=Scope.REQUEST)
def get_delete_comment_use_case(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> DeleteCommentUseCase:
"""Provide DeleteCommentUseCase.
Args:
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured DeleteCommentUseCase instance.
"""
return DeleteCommentUseCase(
comment_repo=comment_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_toggle_comment_like_use_case(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> ToggleCommentLikeUseCase:
"""Provide ToggleCommentLikeUseCase.
Args:
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured ToggleCommentLikeUseCase instance.
"""
return ToggleCommentLikeUseCase(
comment_repo=comment_repo,
tx_manager=tx_manager,
)
class KeycloakProvider(Provider):
"""Provider for Keycloak authentication client.

View File

@@ -43,6 +43,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"post.edit": "Edit",
"post.delete": "Delete",
"post.delete_confirm": "Are you sure you want to delete this post?",
"post.comments": "Comments",
"post.write_comment": "Write a Comment",
"post.comment_placeholder": "Write a comment... Markdown is supported.",
"post.replying_to": "Replying to",
"post.cancel_reply": "Cancel reply",
"post.cancel": "Cancel",
"post.submit_comment": "Submit Comment",
"post.reply": "Reply",
"post.no_comments": "No comments yet. Be the first to comment!",
"post.comment_error": "Failed to post comment. Please try again.",
"post_form.title_edit": "Edit Post",
"post_form.title_new": "New Post",
"post_form.page_title_edit": "Edit Post",
@@ -125,6 +135,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"post.edit": "Редактировать",
"post.delete": "Удалить",
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
"post.comments": "Комментарии",
"post.write_comment": "Написать комментарий",
"post.comment_placeholder": "Напишите комментарий... Поддерживается Markdown.",
"post.replying_to": "Ответ",
"post.cancel_reply": "Отменить ответ",
"post.cancel": "Отмена",
"post.submit_comment": "Отправить",
"post.reply": "Ответить",
"post.no_comments": "Пока нет комментариев. Будьте первым!",
"post.comment_error": "Не удалось отправить комментарий. Попробуйте снова.",
"post_form.title_edit": "Редактировать статью",
"post_form.title_new": "Новая статья",
"post_form.page_title_edit": "Редактировать статью",
@@ -207,6 +227,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"post.edit": "Modifier",
"post.delete": "Supprimer",
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
"post.comments": "Commentaires",
"post.write_comment": "Écrire un commentaire",
"post.comment_placeholder": "Écrivez un commentaire... Markdown est supporté.",
"post.replying_to": "Répondre à",
"post.cancel_reply": "Annuler la réponse",
"post.cancel": "Annuler",
"post.submit_comment": "Soumettre",
"post.reply": "Répondre",
"post.no_comments": "Aucun commentaire pour le moment. Soyez le premier !",
"post.comment_error": "Échec de l'envoi du commentaire. Veuillez réessayer.",
"post_form.title_edit": "Modifier l'article",
"post_form.title_new": "Nouvel article",
"post_form.page_title_edit": "Modifier l'article",
@@ -289,6 +319,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"post.edit": "Bearbeiten",
"post.delete": "Löschen",
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
"post.comments": "Kommentare",
"post.write_comment": "Kommentar schreiben",
"post.comment_placeholder": "Schreiben Sie einen Kommentar... Markdown wird unterstützt.",
"post.replying_to": "Antwort an",
"post.cancel_reply": "Antwort abbrechen",
"post.cancel": "Abbrechen",
"post.submit_comment": "Absenden",
"post.reply": "Antworten",
"post.no_comments": "Noch keine Kommentare. Seien Sie der Erste!",
"post.comment_error": "Kommentar konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
"post_form.title_edit": "Beitrag bearbeiten",
"post_form.title_new": "Neuer Beitrag",
"post_form.page_title_edit": "Beitrag bearbeiten",

View File

@@ -0,0 +1,240 @@
"""SQLAlchemy implementation of CommentRepository.
This module provides the concrete implementation of CommentRepository
using SQLAlchemy ORM for data persistence.
"""
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.repositories import CommentRepository
from app.domain.value_objects.comment_content import CommentContent
from app.infrastructure.database.models import CommentLikeORM, CommentORM
class SQLAlchemyCommentRepository(CommentRepository):
"""SQLAlchemy implementation of Comment repository.
Provides data access methods for Comment entities using SQLAlchemy ORM.
Handles conversion between domain entities and ORM models.
Attributes:
_session: SQLAlchemy async session for database operations.
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with session.
Args:
session: SQLAlchemy async session instance.
"""
self._session = session
def _to_domain(self, orm: CommentORM) -> Comment:
"""Convert ORM model to domain entity.
Args:
orm: SQLAlchemy ORM model instance.
Returns:
Domain Comment entity with validated value objects.
"""
return Comment(
id=UUID(orm.id),
post_id=UUID(orm.post_id),
author_id=orm.author_id,
content=CommentContent(orm.content),
parent_id=UUID(orm.parent_id) if orm.parent_id else None,
like_count=orm.like_count,
created_at=orm.created_at,
updated_at=orm.updated_at,
)
def _to_orm(self, comment: Comment) -> CommentORM:
"""Convert domain entity to ORM model.
Args:
comment: Domain Comment entity.
Returns:
SQLAlchemy ORM model instance.
"""
return CommentORM(
id=str(comment.id),
post_id=str(comment.post_id),
author_id=comment.author_id,
content=comment.content.value,
parent_id=str(comment.parent_id) if comment.parent_id else None,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)
async def get_by_id(self, entity_id: UUID) -> Comment | None:
"""Get comment by ID.
Args:
entity_id: Unique identifier of the comment.
Returns:
Comment entity if found, None otherwise.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None
async def get_all(self) -> list[Comment]:
"""Get all comments.
Returns:
List of all Comment entities.
"""
result = await self._session.execute(select(CommentORM))
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def add(self, entity: Comment) -> None:
"""Add new comment.
Args:
entity: Comment entity to add.
"""
orm = self._to_orm(entity)
self._session.add(orm)
async def update(self, entity: Comment) -> None:
"""Update existing comment.
Args:
entity: Comment entity with updated data.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity.id))
)
orm = result.scalar_one()
orm.content = entity.content.value
orm.like_count = entity.like_count
orm.updated_at = entity.updated_at
async def delete(self, entity_id: UUID) -> None:
"""Delete comment by ID.
Args:
entity_id: Unique identifier of the comment to delete.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)
async def exists(self, entity_id: UUID) -> bool:
"""Check if comment exists.
Args:
entity_id: Unique identifier of the comment.
Returns:
True if comment exists, False otherwise.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
return result.scalar_one_or_none() is not None
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.
"""
result = await self._session.execute(
select(CommentORM)
.where(CommentORM.post_id == str(post_id))
.order_by(CommentORM.created_at.asc())
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
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.
"""
result = await self._session.execute(
select(func.count()).select_from(CommentORM).where(CommentORM.post_id == str(post_id))
)
count: int = result.scalar() or 0
return count
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.
"""
result = await self._session.execute(
select(CommentLikeORM).where(
CommentLikeORM.comment_id == str(comment_id),
CommentLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if not orm:
return None
return CommentLike(
id=UUID(orm.id),
comment_id=UUID(orm.comment_id),
liked_by=orm.liked_by,
created_at=orm.created_at,
)
async def add_like(self, like: CommentLike) -> None:
"""Add a new like to a comment.
Args:
like: CommentLike entity to add.
"""
orm = CommentLikeORM(
id=str(like.id),
comment_id=str(like.comment_id),
liked_by=like.liked_by,
created_at=like.created_at,
)
self._session.add(orm)
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.
"""
result = await self._session.execute(
select(CommentLikeORM).where(
CommentLikeORM.comment_id == str(comment_id),
CommentLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)