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,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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
240
app/infrastructure/repositories/comment.py
Normal file
240
app/infrastructure/repositories/comment.py
Normal 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)
|
||||
Reference in New Issue
Block a user