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:
@@ -6,6 +6,7 @@ integration with the application's use cases and domain layer.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -19,11 +20,14 @@ from pygments.util import ClassNotFound
|
||||
|
||||
from app.application.dtos import CreatePostDTO, UpdatePostDTO
|
||||
from app.application.use_cases import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
@@ -32,6 +36,7 @@ from app.domain.exceptions import (
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.domain.repositories import CommentRepository
|
||||
from app.domain.roles import Role, get_effective_role
|
||||
from app.infrastructure.auth import TokenInfo
|
||||
from app.infrastructure.config.settings import settings
|
||||
@@ -177,6 +182,7 @@ async def home(
|
||||
request: Request,
|
||||
user: OptionalUserDep,
|
||||
list_use_case: FromDishka[ListPostsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render the home page with list of posts.
|
||||
|
||||
@@ -184,10 +190,13 @@ async def home(
|
||||
request: The HTTP request object for template context.
|
||||
user: Current user from dependency.
|
||||
list_use_case: Use case for listing posts.
|
||||
comment_repo: Repository for fetching comment counts.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
page_str = request.query_params.get("page", "1")
|
||||
page = max(1, int(page_str) if page_str.isdigit() else 1)
|
||||
offset = (page - 1) * _DEFAULT_PAGE_SIZE
|
||||
@@ -196,6 +205,10 @@ async def home(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
for i, post in enumerate(visible_posts):
|
||||
count = await comment_repo.count_by_post(post.id)
|
||||
visible_posts[i] = replace(post, comment_count=count)
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
@@ -217,6 +230,7 @@ async def list_posts(
|
||||
request: Request,
|
||||
user: OptionalUserDep,
|
||||
list_use_case: FromDishka[ListPostsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render the posts listing page.
|
||||
|
||||
@@ -224,10 +238,13 @@ async def list_posts(
|
||||
request: The HTTP request object for template context.
|
||||
user: Current user from dependency.
|
||||
list_use_case: Use case for listing posts.
|
||||
comment_repo: Repository for fetching comment counts.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
page_str = request.query_params.get("page", "1")
|
||||
page = max(1, int(page_str) if page_str.isdigit() else 1)
|
||||
offset = (page - 1) * _DEFAULT_PAGE_SIZE
|
||||
@@ -236,6 +253,10 @@ async def list_posts(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
for i, post in enumerate(visible_posts):
|
||||
count = await comment_repo.count_by_post(post.id)
|
||||
visible_posts[i] = replace(post, comment_count=count)
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
@@ -339,14 +360,18 @@ async def post_detail(
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
get_use_case: FromDishka[GetPostUseCase],
|
||||
list_comments_use_case: FromDishka[ListCommentsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render a single post detail page.
|
||||
"""Render a single post detail page with comments.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
post_slug: The URL-friendly slug of the post to display.
|
||||
user: Current user from dependency.
|
||||
get_use_case: Use case for retrieving posts.
|
||||
list_comments_use_case: Use case for listing comments.
|
||||
comment_repo: Repository for fetching comment count.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post detail template.
|
||||
@@ -354,6 +379,8 @@ async def post_detail(
|
||||
Raises:
|
||||
HTTPException: If post not found or not visible to user.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
try:
|
||||
post = await get_use_case.by_slug(post_slug)
|
||||
except NotFoundException:
|
||||
@@ -362,6 +389,19 @@ async def post_detail(
|
||||
if not post.published and not can_see_draft(user, post.author_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
comments = await list_comments_use_case.execute(post.id)
|
||||
comment_count = await comment_repo.count_by_post(post.id)
|
||||
post = replace(post, comment_count=comment_count)
|
||||
|
||||
children: dict[str, list[Any]] = {}
|
||||
for c in comments:
|
||||
pid = str(c.parent_id) if c.parent_id else ""
|
||||
if pid not in children:
|
||||
children[pid] = []
|
||||
children[pid].append(c)
|
||||
|
||||
top_level = children.pop("", [])
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
@@ -371,6 +411,8 @@ async def post_detail(
|
||||
{
|
||||
**context,
|
||||
"post": post,
|
||||
"top_level_comments": top_level,
|
||||
"reply_comments": children,
|
||||
"active_page": "posts",
|
||||
"can_edit": can_edit_post(user, post.author_id),
|
||||
"can_delete": can_delete_post(user, post.author_id),
|
||||
@@ -378,6 +420,89 @@ async def post_detail(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_slug}/comments")
|
||||
async def create_comment_web(
|
||||
request: Request,
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
get_use_case: FromDishka[GetPostUseCase],
|
||||
create_use_case: FromDishka[CreateCommentUseCase],
|
||||
) -> dict[str, object]:
|
||||
"""Create a comment on a post via web UI.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object with JSON body.
|
||||
post_slug: The URL-friendly slug of the post.
|
||||
user: Current user from cookie or None.
|
||||
get_use_case: Use case for retrieving post.
|
||||
create_use_case: Use case for creating comments.
|
||||
|
||||
Returns:
|
||||
JSON dict with created comment data.
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated or post not found.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
post = await get_use_case.by_slug(post_slug)
|
||||
except NotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Post not found") from None
|
||||
|
||||
body = await request.json()
|
||||
content = str(body.get("content", "")).strip()
|
||||
parent_id_str = body.get("parent_id")
|
||||
|
||||
parent_id: UUID | None = None
|
||||
if parent_id_str:
|
||||
parent_id = UUID(parent_id_str)
|
||||
|
||||
result = await create_use_case.execute(
|
||||
post_id=post.id,
|
||||
author_id=user.user_id,
|
||||
content=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(result.id),
|
||||
"post_id": str(result.post_id),
|
||||
"author_id": result.author_id,
|
||||
"content": result.content,
|
||||
"parent_id": str(result.parent_id) if result.parent_id else None,
|
||||
"like_count": result.like_count,
|
||||
"created_at": result.created_at.isoformat() if result.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/like")
|
||||
async def toggle_comment_like_web(
|
||||
comment_id: UUID,
|
||||
user: OptionalUserDep,
|
||||
toggle_use_case: FromDishka[ToggleCommentLikeUseCase],
|
||||
) -> dict[str, object]:
|
||||
"""Toggle like on a comment via web UI.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
user: Current user from cookie or None.
|
||||
toggle_use_case: Use case for toggling comment likes.
|
||||
|
||||
Returns:
|
||||
JSON dict with updated like_count.
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
result = await toggle_use_case.execute(comment_id, user.user_id)
|
||||
return {"like_count": result.like_count, "id": str(result.id)}
|
||||
|
||||
|
||||
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
|
||||
async def edit_post_form(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user