diff --git a/app/application/__init__.py b/app/application/__init__.py index 5452052..0ac06f9 100644 --- a/app/application/__init__.py +++ b/app/application/__init__.py @@ -4,14 +4,24 @@ This module re-exports all application layer components including DTOs, interfaces, and use cases for convenient importing. """ -from app.application.dtos import CreatePostDTO, PostResponseDTO, UpdatePostDTO +from app.application.dtos import ( + CommentResponseDTO, + CreateCommentDTO, + CreatePostDTO, + PostResponseDTO, + UpdatePostDTO, +) from app.application.interfaces import TransactionManager from app.application.use_cases import ( + CreateCommentUseCase, CreatePostUseCase, + DeleteCommentUseCase, DeletePostUseCase, GetPostUseCase, + ListCommentsUseCase, ListPostsUseCase, PublishPostUseCase, + ToggleCommentLikeUseCase, TogglePostLikeUseCase, UpdatePostUseCase, ) @@ -20,6 +30,8 @@ __all__ = [ "CreatePostDTO", "UpdatePostDTO", "PostResponseDTO", + "CreateCommentDTO", + "CommentResponseDTO", "TransactionManager", "CreatePostUseCase", "GetPostUseCase", @@ -28,4 +40,8 @@ __all__ = [ "ListPostsUseCase", "PublishPostUseCase", "TogglePostLikeUseCase", + "CreateCommentUseCase", + "DeleteCommentUseCase", + "ListCommentsUseCase", + "ToggleCommentLikeUseCase", ] diff --git a/app/application/dtos/__init__.py b/app/application/dtos/__init__.py index 08b757b..7ffb1cf 100644 --- a/app/application/dtos/__init__.py +++ b/app/application/dtos/__init__.py @@ -4,6 +4,13 @@ This module re-exports all Data Transfer Objects used in the application layer for data communication. """ +from app.application.dtos.comment import CommentResponseDTO, CreateCommentDTO from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO -__all__ = ["CreatePostDTO", "UpdatePostDTO", "PostResponseDTO"] +__all__ = [ + "CreatePostDTO", + "UpdatePostDTO", + "PostResponseDTO", + "CreateCommentDTO", + "CommentResponseDTO", +] diff --git a/app/application/dtos/comment.py b/app/application/dtos/comment.py new file mode 100644 index 0000000..682e3a7 --- /dev/null +++ b/app/application/dtos/comment.py @@ -0,0 +1,55 @@ +"""DTOs for comment use cases. + +This module defines Data Transfer Objects used for communication between +application layer comment use cases and presentation layer. +""" + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + + +@dataclass(frozen=True) +class CreateCommentDTO: + """DTO for creating a comment. + + Carries comment creation data from API to use case. + + Attributes: + post_id: UUID of the post to comment on. + author_id: Identifier of the comment author. + content: Comment content string (Markdown supported). + parent_id: Optional UUID of parent comment for replies. + """ + + post_id: UUID + author_id: str + content: str + parent_id: UUID | None = None + + +@dataclass(frozen=True) +class CommentResponseDTO: + """DTO for comment response. + + Carries complete comment data for API responses. + + Attributes: + id: Unique comment identifier. + post_id: UUID of the parent post. + author_id: Comment author identifier. + content: Comment content string. + parent_id: Optional UUID of parent comment. + like_count: Number of likes on this comment. + created_at: Creation timestamp. + updated_at: Last update timestamp. + """ + + id: UUID + post_id: UUID + author_id: str + content: str + parent_id: UUID | None = None + like_count: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/app/application/dtos/post.py b/app/application/dtos/post.py index 344f81e..d54896c 100644 --- a/app/application/dtos/post.py +++ b/app/application/dtos/post.py @@ -101,3 +101,4 @@ class PostResponseDTO: created_at: datetime updated_at: datetime like_count: int = 0 + comment_count: int = 0 diff --git a/app/application/use_cases/__init__.py b/app/application/use_cases/__init__.py index e35bd5b..71103c6 100644 --- a/app/application/use_cases/__init__.py +++ b/app/application/use_cases/__init__.py @@ -4,11 +4,15 @@ This module re-exports all application use cases that implement business logic operations for the blog API. """ +from app.application.use_cases.create_comment import CreateCommentUseCase from app.application.use_cases.create_post import CreatePostUseCase +from app.application.use_cases.delete_comment import DeleteCommentUseCase from app.application.use_cases.delete_post import DeletePostUseCase from app.application.use_cases.get_post import GetPostUseCase +from app.application.use_cases.list_comments import ListCommentsUseCase from app.application.use_cases.list_posts import ListPostsUseCase from app.application.use_cases.publish_post import PublishPostUseCase +from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase from app.application.use_cases.toggle_like import TogglePostLikeUseCase from app.application.use_cases.update_post import UpdatePostUseCase @@ -20,4 +24,8 @@ __all__ = [ "ListPostsUseCase", "PublishPostUseCase", "TogglePostLikeUseCase", + "CreateCommentUseCase", + "DeleteCommentUseCase", + "ListCommentsUseCase", + "ToggleCommentLikeUseCase", ] diff --git a/app/application/use_cases/create_comment.py b/app/application/use_cases/create_comment.py new file mode 100644 index 0000000..eec3696 --- /dev/null +++ b/app/application/use_cases/create_comment.py @@ -0,0 +1,100 @@ +"""Create comment use case. + +This module implements the use case for creating comments on blog posts. +Supports both top-level comments and nested replies via parent_id. +""" + +from uuid import UUID + +from app.application.dtos.comment import CommentResponseDTO +from app.application.interfaces import TransactionManager +from app.domain.entities.comment import Comment +from app.domain.exceptions import NotFoundException +from app.domain.repositories import CommentRepository, PostRepository + + +class CreateCommentUseCase: + """Use case for creating a comment on a blog post. + + Handles top-level comments and replies to existing comments. + Validates that the target post exists before creating. + + Attributes: + _post_repo: Repository for post data access. + _comment_repo: Repository for comment data access. + _tx_manager: Transaction manager for commit control. + """ + + def __init__( + self, + post_repo: PostRepository, + comment_repo: CommentRepository, + tx_manager: TransactionManager, + ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + comment_repo: Repository for comment operations. + tx_manager: Transaction manager instance. + """ + self._post_repo = post_repo + self._comment_repo = comment_repo + self._tx_manager = tx_manager + + async def execute( + self, + post_id: UUID, + author_id: str, + content: str, + parent_id: UUID | None = None, + ) -> CommentResponseDTO: + """Execute the use case to create a comment. + + Args: + post_id: UUID of the post to comment on. + author_id: Identifier of the comment author. + content: Comment content (Markdown supported). + parent_id: Optional UUID of parent comment for replies. + + Returns: + CommentResponseDTO with created comment data. + + Raises: + NotFoundException: If the target post does not exist. + """ + post = await self._post_repo.get_by_id(post_id) + if not post: + raise NotFoundException(f"Post with id '{post_id}' not found") + + comment = Comment.create( + post_id=post_id, + author_id=author_id, + content_str=content, + parent_id=parent_id, + ) + + await self._comment_repo.add(comment) + await self._tx_manager.commit() + + return self._map_to_dto(comment) + + def _map_to_dto(self, comment: Comment) -> CommentResponseDTO: + """Map domain entity to response DTO. + + Args: + comment: Domain Comment entity. + + Returns: + CommentResponseDTO with all comment attributes. + """ + return CommentResponseDTO( + id=comment.id, + post_id=comment.post_id, + author_id=comment.author_id, + content=comment.content.value, + parent_id=comment.parent_id, + like_count=comment.like_count, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) diff --git a/app/application/use_cases/delete_comment.py b/app/application/use_cases/delete_comment.py new file mode 100644 index 0000000..3f585be --- /dev/null +++ b/app/application/use_cases/delete_comment.py @@ -0,0 +1,60 @@ +"""Delete comment use case. + +This module implements the use case for deleting comments. +Users can delete their own comments. +""" + +from uuid import UUID + +from app.application.interfaces import TransactionManager +from app.domain.exceptions import ForbiddenException, NotFoundException +from app.domain.repositories import CommentRepository + + +class DeleteCommentUseCase: + """Use case for deleting a comment. + + Allows users to delete their own comments. + + Attributes: + _comment_repo: Repository for comment data access. + _tx_manager: Transaction manager for commit control. + """ + + def __init__( + self, + comment_repo: CommentRepository, + tx_manager: TransactionManager, + ) -> None: + """Initialize use case with dependencies. + + Args: + comment_repo: Repository for comment operations. + tx_manager: Transaction manager instance. + """ + self._comment_repo = comment_repo + self._tx_manager = tx_manager + + async def execute( + self, + comment_id: UUID, + user_id: str, + ) -> None: + """Delete a comment. + + Args: + comment_id: UUID of the comment to delete. + user_id: Identifier of the user requesting deletion. + + Raises: + NotFoundException: If the comment does not exist. + """ + comment = await self._comment_repo.get_by_id(comment_id) + if not comment: + raise NotFoundException(f"Comment with id '{comment_id}' not found") + + if comment.author_id != user_id: + raise ForbiddenException("You are not allowed to delete this comment") + + await self._comment_repo.delete(comment_id) + await self._tx_manager.commit() diff --git a/app/application/use_cases/list_comments.py b/app/application/use_cases/list_comments.py new file mode 100644 index 0000000..92bac8d --- /dev/null +++ b/app/application/use_cases/list_comments.py @@ -0,0 +1,63 @@ +"""List comments use case. + +This module implements the use case for listing comments on a blog post. +""" + +from uuid import UUID + +from app.application.dtos.comment import CommentResponseDTO +from app.domain.entities.comment import Comment +from app.domain.repositories import CommentRepository + + +class ListCommentsUseCase: + """Use case for listing comments on a blog post. + + Retrieves all comments for a given post ordered by creation time. + + Attributes: + _comment_repo: Repository for comment data access. + """ + + def __init__( + self, + comment_repo: CommentRepository, + ) -> None: + """Initialize use case with dependencies. + + Args: + comment_repo: Repository for comment operations. + """ + self._comment_repo = comment_repo + + async def execute(self, post_id: UUID) -> list[CommentResponseDTO]: + """List all comments for a post. + + Args: + post_id: UUID of the post. + + Returns: + List of CommentResponseDTO for the post. + """ + comments = await self._comment_repo.get_by_post(post_id) + return [self._map_to_dto(c) for c in comments] + + def _map_to_dto(self, comment: Comment) -> CommentResponseDTO: + """Map domain entity to response DTO. + + Args: + comment: Domain Comment entity. + + Returns: + CommentResponseDTO with all comment attributes. + """ + return CommentResponseDTO( + id=comment.id, + post_id=comment.post_id, + author_id=comment.author_id, + content=comment.content.value, + parent_id=comment.parent_id, + like_count=comment.like_count, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) diff --git a/app/application/use_cases/toggle_comment_like.py b/app/application/use_cases/toggle_comment_like.py new file mode 100644 index 0000000..493c3af --- /dev/null +++ b/app/application/use_cases/toggle_comment_like.py @@ -0,0 +1,96 @@ +"""Toggle comment like use case. + +This module implements the use case for toggling likes on comments. +If the user already liked the comment, the like is removed (unlike). +If not, a new like is added. +""" + +from uuid import UUID + +from app.application.dtos.comment import CommentResponseDTO +from app.application.interfaces import TransactionManager +from app.domain.entities.comment import Comment +from app.domain.entities.comment_like import CommentLike +from app.domain.exceptions import NotFoundException +from app.domain.repositories import CommentRepository + + +class ToggleCommentLikeUseCase: + """Use case for toggling a like on a comment. + + Handles like/unlike toggle logic. If the user has already liked + the comment, the like is removed. Otherwise, a new like is created. + + Attributes: + _comment_repo: Repository for comment and like data access. + _tx_manager: Transaction manager for commit control. + """ + + def __init__( + self, + comment_repo: CommentRepository, + tx_manager: TransactionManager, + ) -> None: + """Initialize use case with dependencies. + + Args: + comment_repo: Repository for comment and like operations. + tx_manager: Transaction manager instance. + """ + self._comment_repo = comment_repo + self._tx_manager = tx_manager + + async def execute(self, comment_id: UUID, liked_by: str) -> CommentResponseDTO: + """Toggle like on a comment. + + If the user already liked the comment, remove the like. + Otherwise, add a new like. + + Args: + comment_id: UUID of the comment to toggle like on. + liked_by: User ID. + + Returns: + CommentResponseDTO with updated like_count. + + Raises: + NotFoundException: If comment with given ID does not exist. + """ + comment = await self._comment_repo.get_by_id(comment_id) + if not comment: + raise NotFoundException(f"Comment with id '{comment_id}' not found") + + existing_like = await self._comment_repo.get_like(comment_id, liked_by) + + if existing_like: + await self._comment_repo.remove_like(comment_id, liked_by) + comment.like_count = max(0, comment.like_count - 1) + else: + new_like = CommentLike(comment_id=comment_id, liked_by=liked_by) + await self._comment_repo.add_like(new_like) + comment.like_count += 1 + + await self._comment_repo.update(comment) + await self._tx_manager.commit() + + return self._map_to_dto(comment) + + def _map_to_dto(self, comment: Comment) -> CommentResponseDTO: + """Map domain entity to response DTO. + + Args: + comment: Domain Comment entity. + + Returns: + CommentResponseDTO with all comment attributes including like_count. + """ + return CommentResponseDTO( + id=comment.id, + post_id=comment.post_id, + author_id=comment.author_id, + content=comment.content.value, + parent_id=comment.parent_id, + like_count=comment.like_count, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) diff --git a/app/domain/__init__.py b/app/domain/__init__.py index 867643d..b3da5d0 100644 --- a/app/domain/__init__.py +++ b/app/domain/__init__.py @@ -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", diff --git a/app/domain/entities/__init__.py b/app/domain/entities/__init__.py index 00bb7f0..0b3346f 100644 --- a/app/domain/entities/__init__.py +++ b/app/domain/entities/__init__.py @@ -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"] diff --git a/app/domain/entities/comment.py b/app/domain/entities/comment.py new file mode 100644 index 0000000..09d3be8 --- /dev/null +++ b/app/domain/entities/comment.py @@ -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, + ) diff --git a/app/domain/entities/comment_like.py b/app/domain/entities/comment_like.py new file mode 100644 index 0000000..c5afbeb --- /dev/null +++ b/app/domain/entities/comment_like.py @@ -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(), + } diff --git a/app/domain/repositories/__init__.py b/app/domain/repositories/__init__.py index 0ee23bc..958be12 100644 --- a/app/domain/repositories/__init__.py +++ b/app/domain/repositories/__init__.py @@ -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"] diff --git a/app/domain/repositories/comment.py b/app/domain/repositories/comment.py new file mode 100644 index 0000000..ca71d2a --- /dev/null +++ b/app/domain/repositories/comment.py @@ -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. + """ + ... diff --git a/app/domain/value_objects/__init__.py b/app/domain/value_objects/__init__.py index ce6277a..8365235 100644 --- a/app/domain/value_objects/__init__.py +++ b/app/domain/value_objects/__init__.py @@ -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"] diff --git a/app/domain/value_objects/comment_content.py b/app/domain/value_objects/comment_content.py new file mode 100644 index 0000000..cb73539 --- /dev/null +++ b/app/domain/value_objects/comment_content.py @@ -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") diff --git a/app/infrastructure/database/models.py b/app/infrastructure/database/models.py index c35b6f7..cec23dc 100644 --- a/app/infrastructure/database/models.py +++ b/app/infrastructure/database/models.py @@ -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, + ) diff --git a/app/infrastructure/di/providers.py b/app/infrastructure/di/providers.py index 6970db4..c139eb3 100644 --- a/app/infrastructure/di/providers.py +++ b/app/infrastructure/di/providers.py @@ -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. diff --git a/app/infrastructure/i18n/translations.py b/app/infrastructure/i18n/translations.py index 9e8a0df..8bfe614 100644 --- a/app/infrastructure/i18n/translations.py +++ b/app/infrastructure/i18n/translations.py @@ -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", diff --git a/app/infrastructure/repositories/comment.py b/app/infrastructure/repositories/comment.py new file mode 100644 index 0000000..aa2fdbe --- /dev/null +++ b/app/infrastructure/repositories/comment.py @@ -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) diff --git a/app/presentation/api/deps.py b/app/presentation/api/deps.py index dd288e7..82c105a 100644 --- a/app/presentation/api/deps.py +++ b/app/presentation/api/deps.py @@ -11,11 +11,15 @@ from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.application import ( + CreateCommentUseCase, CreatePostUseCase, + DeleteCommentUseCase, DeletePostUseCase, GetPostUseCase, + ListCommentsUseCase, ListPostsUseCase, PublishPostUseCase, + ToggleCommentLikeUseCase, TogglePostLikeUseCase, UpdatePostUseCase, ) @@ -31,6 +35,11 @@ ListPostsDep = FromDishka[ListPostsUseCase] PublishPostDep = FromDishka[PublishPostUseCase] ToggleLikeDep = FromDishka[TogglePostLikeUseCase] +CreateCommentDep = FromDishka[CreateCommentUseCase] +DeleteCommentDep = FromDishka[DeleteCommentUseCase] +ListCommentsDep = FromDishka[ListCommentsUseCase] +ToggleCommentLikeDep = FromDishka[ToggleCommentLikeUseCase] + security = HTTPBearer(auto_error=False) diff --git a/app/presentation/api/v1/__init__.py b/app/presentation/api/v1/__init__.py index 87ffa60..122f8e4 100644 --- a/app/presentation/api/v1/__init__.py +++ b/app/presentation/api/v1/__init__.py @@ -6,7 +6,9 @@ all v1 endpoint routers. from fastapi import APIRouter +from app.presentation.api.v1.comments import router as comments_router from app.presentation.api.v1.posts import router as posts_router router = APIRouter(prefix="/v1") router.include_router(posts_router) +router.include_router(comments_router) diff --git a/app/presentation/api/v1/comments.py b/app/presentation/api/v1/comments.py new file mode 100644 index 0000000..76d41e6 --- /dev/null +++ b/app/presentation/api/v1/comments.py @@ -0,0 +1,131 @@ +"""Comments API routes. + +This module defines FastAPI routes for comment operations including +CRUD and like/unlike toggle. +""" + +from uuid import UUID + +from dishka.integrations.fastapi import DishkaRoute +from fastapi import APIRouter, status + +from app.presentation.api.deps import ( + CreateCommentDep, + CurrentRoleDep, + CurrentUserDep, + DeleteCommentDep, + ListCommentsDep, + ToggleCommentLikeDep, +) +from app.presentation.schemas import ( + CommentCreateSchema, + CommentLikeResponseSchema, + CommentResponseSchema, +) + +router = APIRouter(tags=["comments"], route_class=DishkaRoute) + + +@router.post( + "/posts/{post_id}/comments", + response_model=CommentResponseSchema, + status_code=status.HTTP_201_CREATED, + summary="Create a comment on a post", +) +async def create_comment( + post_id: UUID, + schema: CommentCreateSchema, + use_case: CreateCommentDep, + current_user_id: CurrentUserDep, +) -> CommentResponseSchema: + """Create a comment on a blog post. + + Args: + post_id: UUID of the post to comment on. + schema: Comment creation data. + use_case: CreateCommentUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + CommentResponseSchema with created comment data. + """ + result = await use_case.execute( + post_id=post_id, + author_id=current_user_id, + content=schema.content, + parent_id=schema.parent_id, + ) + return CommentResponseSchema(**result.__dict__) + + +@router.get( + "/posts/{post_id}/comments", + response_model=list[CommentResponseSchema], + summary="List comments for a post", +) +async def list_comments( + post_id: UUID, + use_case: ListCommentsDep, +) -> list[CommentResponseSchema]: + """Get all comments for a blog post. + + Args: + post_id: UUID of the post. + use_case: ListCommentsUseCase dependency. + + Returns: + List of CommentResponseSchema for the post. + """ + results = await use_case.execute(post_id=post_id) + return [CommentResponseSchema(**r.__dict__) for r in results] + + +@router.delete( + "/comments/{comment_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a comment", +) +async def delete_comment( + comment_id: UUID, + use_case: DeleteCommentDep, + current_user_id: CurrentUserDep, + role: CurrentRoleDep, +) -> None: + """Delete a comment. + + Users can delete their own comments. + + Args: + comment_id: UUID of the comment to delete. + use_case: DeleteCommentUseCase dependency. + current_user_id: Authenticated user ID. + role: Current user role. + """ + await use_case.execute(comment_id=comment_id, user_id=current_user_id) + + +@router.post( + "/comments/{comment_id}/like", + response_model=CommentLikeResponseSchema, + summary="Toggle like on a comment", +) +async def toggle_comment_like( + comment_id: UUID, + use_case: ToggleCommentLikeDep, + current_user_id: CurrentUserDep, +) -> CommentLikeResponseSchema: + """Toggle like/unlike on a comment. + + If the user already liked the comment, the like is removed (unlike). + Otherwise, a new like is added. + + Args: + comment_id: UUID of the comment. + use_case: ToggleCommentLikeUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + CommentLikeResponseSchema with updated like_count. + """ + result = await use_case.execute(comment_id, current_user_id) + return CommentLikeResponseSchema(id=result.id, like_count=result.like_count) diff --git a/app/presentation/schemas/__init__.py b/app/presentation/schemas/__init__.py index 90fa5ec..b6938dd 100644 --- a/app/presentation/schemas/__init__.py +++ b/app/presentation/schemas/__init__.py @@ -4,6 +4,11 @@ This module re-exports all Pydantic schemas used for request/response validation in the API layer. """ +from app.presentation.schemas.comment import ( + CommentCreateSchema, + CommentLikeResponseSchema, + CommentResponseSchema, +) from app.presentation.schemas.post import ( PostBaseSchema, PostCreateSchema, @@ -22,4 +27,7 @@ __all__ = [ "PostListResponseSchema", "PostSearchSchema", "PostPublishSchema", + "CommentCreateSchema", + "CommentResponseSchema", + "CommentLikeResponseSchema", ] diff --git a/app/presentation/schemas/comment.py b/app/presentation/schemas/comment.py new file mode 100644 index 0000000..89fa14e --- /dev/null +++ b/app/presentation/schemas/comment.py @@ -0,0 +1,58 @@ +"""Pydantic schemas for comments. + +This module defines Pydantic models for comment request/response +validation in the API layer. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class CommentCreateSchema(BaseModel): + """Schema for creating a comment. + + Attributes: + content: Comment text content (Markdown supported). + parent_id: Optional parent comment ID for replies. + """ + + content: str = Field(..., min_length=1, max_length=5000, description="Comment content") + parent_id: UUID | None = Field(default=None, description="Parent comment ID for replies") + + +class CommentResponseSchema(BaseModel): + """Schema for comment response. + + Attributes: + id: Unique comment identifier. + post_id: UUID of the parent post. + author_id: Comment author identifier. + content: Comment content text. + parent_id: Optional parent comment ID. + like_count: Number of likes on this comment. + created_at: Creation timestamp. + updated_at: Last update timestamp. + """ + + id: UUID + post_id: UUID + author_id: str + content: str + parent_id: UUID | None = None + like_count: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None + + +class CommentLikeResponseSchema(BaseModel): + """Schema for comment like response. + + Attributes: + id: Comment identifier. + like_count: Updated like count. + """ + + id: UUID + like_count: int diff --git a/app/presentation/templates/pages/index.html b/app/presentation/templates/pages/index.html index 6191e41..331f615 100644 --- a/app/presentation/templates/pages/index.html +++ b/app/presentation/templates/pages/index.html @@ -55,6 +55,9 @@ 👍 {{ post.like_count }} + + 💬 {{ post.comment_count }} +
diff --git a/app/presentation/templates/pages/post_detail.html b/app/presentation/templates/pages/post_detail.html index 042d9c2..b66c97a 100644 --- a/app/presentation/templates/pages/post_detail.html +++ b/app/presentation/templates/pages/post_detail.html @@ -19,7 +19,7 @@

{{ post.title }}

- +
{{ post.author_id[0]|upper }} @@ -40,22 +40,25 @@ 👍 {{ post.like_count }} + + 💬 {{ post.comment_count }} +
- +
{{ post.content|markdown|safe }}
- +
+ +
+
+

+ 💬 {{ _('post.comments', current_locale) }} + ({{ post.comment_count }}) +

+ {% if user %} + + {% endif %} +
+ + {% if user %} + + {% endif %} + + {% macro render_comment(comment, depth) %} +
+
+ {{ comment.author_id[0]|upper }} +
+
+
+ {{ comment.author_id }} + + {% if comment.created_at %}{{ comment.created_at.strftime('%B %d, %Y') }}{% endif %} + +
+
{{ comment.content }}
+
+ {% if user %} + + {% endif %} + +
+ + {% set key = comment.id|string %} + {% if key in reply_comments %} +
+ {% for child in reply_comments[key] %} + {{ render_comment(child, depth + 1) }} + {% endfor %} +
+ {% endif %} +
+
+ {% endmacro %} + +
+ {% if top_level_comments %} + {% for comment in top_level_comments %} + {{ render_comment(comment, 0) }} + {% endfor %} + {% else %} +
+

{{ _('post.no_comments', current_locale) }}

+
+ {% endif %} +
+
{% endblock %} {% block extra_js %} -