feat: add like/unlike toggle on blog posts with per-user tracking
- PostLike domain entity (post_id, liked_by) with BaseEntity integration
- Post entity: add like_count field (default 0) and to_dict serialization
- PostRepository interface: add get_like, add_like, remove_like methods
- TogglePostLikeUseCase: toggle logic (like → unlike, unlike → like)
- PostResponseDTO/PostResponseSchema: add like_count field
- PostLikeORM model with FK to posts and cascade delete
- SQLAlchemyPostRepository: implement like query/add/remove with ORM mapping
- DI provider registration for TogglePostLikeUseCase
- API endpoint POST /api/v1/posts/{id}/like (auth required)
- Unit tests: PostLike entity, Post.like_count, TogglePostLikeUseCase (7 tests)
- API tests: POST /api/v1/posts/{id}/like (4 tests)
- Test model files: FEATURE_LIKES.md, TEST_MODEL.md updated
This commit is contained in:
@@ -12,6 +12,7 @@ from app.application.use_cases import (
|
||||
GetPostUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
|
||||
@@ -26,4 +27,5 @@ __all__ = [
|
||||
"DeletePostUseCase",
|
||||
"ListPostsUseCase",
|
||||
"PublishPostUseCase",
|
||||
"TogglePostLikeUseCase",
|
||||
]
|
||||
|
||||
@@ -100,3 +100,4 @@ class PostResponseDTO:
|
||||
tags: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
like_count: int = 0
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.application.use_cases.delete_post import DeletePostUseCase
|
||||
from app.application.use_cases.get_post import GetPostUseCase
|
||||
from app.application.use_cases.list_posts import ListPostsUseCase
|
||||
from app.application.use_cases.publish_post import PublishPostUseCase
|
||||
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
|
||||
from app.application.use_cases.update_post import UpdatePostUseCase
|
||||
|
||||
__all__ = [
|
||||
@@ -18,4 +19,5 @@ __all__ = [
|
||||
"DeletePostUseCase",
|
||||
"ListPostsUseCase",
|
||||
"PublishPostUseCase",
|
||||
"TogglePostLikeUseCase",
|
||||
]
|
||||
|
||||
102
app/application/use_cases/toggle_like.py
Normal file
102
app/application/use_cases/toggle_like.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Toggle post like use case.
|
||||
|
||||
This module implements the use case for toggling likes on blog posts.
|
||||
If the user already liked the post, the like is removed (unlike).
|
||||
If not, a new like is added.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.post import PostResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.entities import Post
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.exceptions import NotFoundException
|
||||
from app.domain.repositories import PostRepository
|
||||
|
||||
|
||||
class TogglePostLikeUseCase:
|
||||
"""Use case for toggling a like on a blog post.
|
||||
|
||||
Handles like/unlike toggle logic. If the user or device has already
|
||||
liked the post, the like is removed. Otherwise, a new like is created.
|
||||
|
||||
Attributes:
|
||||
_post_repo: Repository for post and like data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
|
||||
Example:
|
||||
>>> use_case = TogglePostLikeUseCase(post_repo, tx_manager)
|
||||
>>> result = await use_case.execute("my-post-slug", "user-123")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
post_repo: Repository for post and like operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._post_repo = post_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(self, post_id: UUID, liked_by: str) -> PostResponseDTO:
|
||||
"""Toggle like on a post.
|
||||
|
||||
If the user/device already liked the post, remove the like.
|
||||
Otherwise, add a new like.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to toggle like on.
|
||||
liked_by: User ID or device identifier.
|
||||
|
||||
Returns:
|
||||
PostResponseDTO with updated like_count.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If post with given ID 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")
|
||||
|
||||
existing_like = await self._post_repo.get_like(post_id, liked_by)
|
||||
|
||||
if existing_like:
|
||||
await self._post_repo.remove_like(post_id, liked_by)
|
||||
post.like_count = max(0, post.like_count - 1)
|
||||
else:
|
||||
new_like = PostLike(post_id=post_id, liked_by=liked_by)
|
||||
await self._post_repo.add_like(new_like)
|
||||
post.like_count += 1
|
||||
|
||||
await self._post_repo.update(post)
|
||||
await self._tx_manager.commit()
|
||||
|
||||
return self._map_to_dto(post)
|
||||
|
||||
def _map_to_dto(self, post: Post) -> PostResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
post: Domain post entity.
|
||||
|
||||
Returns:
|
||||
PostResponseDTO with all post attributes including like_count.
|
||||
"""
|
||||
return PostResponseDTO(
|
||||
id=post.id,
|
||||
title=post.title.value,
|
||||
content=post.content.value,
|
||||
slug=post.slug.value,
|
||||
author_id=post.author_id,
|
||||
published=post.published,
|
||||
like_count=post.like_count,
|
||||
tags=post.tags.copy(),
|
||||
created_at=post.created_at,
|
||||
updated_at=post.updated_at,
|
||||
)
|
||||
@@ -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
|
||||
from app.domain.entities import BaseEntity, Post, PostLike
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
DomainException,
|
||||
@@ -19,6 +19,7 @@ from app.domain.value_objects import Content, Slug, Title, ValueObject
|
||||
__all__ = [
|
||||
"BaseEntity",
|
||||
"Post",
|
||||
"PostLike",
|
||||
"ValueObject",
|
||||
"Title",
|
||||
"Content",
|
||||
|
||||
@@ -5,6 +5,7 @@ core business objects with identity.
|
||||
"""
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.entities.post import Post
|
||||
|
||||
__all__ = ["BaseEntity", "Post"]
|
||||
__all__ = ["BaseEntity", "Post", "PostLike"]
|
||||
|
||||
40
app/domain/entities/like.py
Normal file
40
app/domain/entities/like.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Domain entity for PostLike.
|
||||
|
||||
This module defines the PostLike entity that tracks which users
|
||||
or devices have liked which posts.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PostLike(BaseEntity):
|
||||
"""Post like domain entity.
|
||||
|
||||
Tracks a like on a blog post by a user or device.
|
||||
Each like is uniquely identified by its entity ID.
|
||||
|
||||
Attributes:
|
||||
post_id: UUID of the liked post.
|
||||
liked_by: Identifier of the user or device that liked.
|
||||
"""
|
||||
|
||||
post_id: UUID
|
||||
liked_by: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary with all PostLike attributes.
|
||||
"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"post_id": str(self.post_id),
|
||||
"liked_by": self.liked_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -44,6 +44,7 @@ class Post(BaseEntity):
|
||||
slug: Slug
|
||||
author_id: str
|
||||
published: bool = False
|
||||
like_count: int = 0
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
def publish(self) -> None:
|
||||
@@ -114,6 +115,7 @@ class Post(BaseEntity):
|
||||
"slug": self.slug.value,
|
||||
"author_id": self.author_id,
|
||||
"published": self.published,
|
||||
"like_count": self.like_count,
|
||||
"tags": self.tags.copy(),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Post repository interface.
|
||||
|
||||
This module extends the base repository interface with post-specific
|
||||
query methods including slug lookup, author filtering, and search.
|
||||
query methods including slug lookup, author filtering, search, and
|
||||
like management.
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.entities.post import Post
|
||||
from app.domain.repositories.base import Repository
|
||||
|
||||
@@ -101,6 +104,38 @@ class PostRepository(Repository[Post]):
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None:
|
||||
"""Get a like by post and user/device.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
liked_by: User ID or device ID.
|
||||
|
||||
Returns:
|
||||
PostLike if found, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def add_like(self, like: PostLike) -> None:
|
||||
"""Add a new like.
|
||||
|
||||
Args:
|
||||
like: PostLike entity to add.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def remove_like(self, post_id: UUID, liked_by: str) -> None:
|
||||
"""Remove a like by post and user/device.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
liked_by: User ID or device ID.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
|
||||
@@ -7,8 +7,8 @@ Models are used by repositories for data persistence.
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -42,6 +42,10 @@ class PostORM(Base): # type: ignore[valid-type,misc]
|
||||
slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
|
||||
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(
|
||||
back_populates="post", cascade="all, delete-orphan"
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
@@ -54,3 +58,32 @@ class PostORM(Base): # type: ignore[valid-type,misc]
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class PostLikeORM(Base): # type: ignore[valid-type,misc]
|
||||
"""SQLAlchemy model for PostLike.
|
||||
|
||||
Database table representation of post likes.
|
||||
Maps to the 'post_likes' table tracking which users/devices liked which posts.
|
||||
|
||||
Attributes:
|
||||
id: Primary key as UUID string.
|
||||
post_id: Foreign key to the liked post.
|
||||
liked_by: User ID or device identifier.
|
||||
created_at: Creation timestamp.
|
||||
"""
|
||||
|
||||
__tablename__ = "post_likes"
|
||||
|
||||
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
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
post: Mapped[PostORM] = relationship(back_populates="likes")
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.application import (
|
||||
GetPostUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
from app.application.interfaces import TransactionManager
|
||||
@@ -236,6 +237,26 @@ class UseCaseProvider(Provider):
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_toggle_like_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> TogglePostLikeUseCase:
|
||||
"""Provide TogglePostLikeUseCase.
|
||||
|
||||
Args:
|
||||
post_repo: Post repository dependency.
|
||||
tx_manager: Transaction manager dependency.
|
||||
|
||||
Returns:
|
||||
Configured TogglePostLikeUseCase instance.
|
||||
"""
|
||||
return TogglePostLikeUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
|
||||
class KeycloakProvider(Provider):
|
||||
"""Provider for Keycloak authentication client.
|
||||
|
||||
@@ -10,9 +10,10 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities import Post
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.repositories import PostRepository
|
||||
from app.domain.value_objects import Content, Slug, Title
|
||||
from app.infrastructure.database.models import PostORM
|
||||
from app.infrastructure.database.models import PostLikeORM, PostORM
|
||||
|
||||
|
||||
class SQLAlchemyPostRepository(PostRepository):
|
||||
@@ -53,6 +54,7 @@ class SQLAlchemyPostRepository(PostRepository):
|
||||
slug=Slug(orm.slug),
|
||||
author_id=orm.author_id,
|
||||
published=orm.published,
|
||||
like_count=orm.like_count,
|
||||
tags=orm.tags or [],
|
||||
created_at=orm.created_at,
|
||||
updated_at=orm.updated_at,
|
||||
@@ -74,6 +76,7 @@ class SQLAlchemyPostRepository(PostRepository):
|
||||
slug=post.slug.value,
|
||||
author_id=post.author_id,
|
||||
published=post.published,
|
||||
like_count=post.like_count,
|
||||
tags=post.tags,
|
||||
created_at=post.created_at,
|
||||
updated_at=post.updated_at,
|
||||
@@ -124,6 +127,7 @@ class SQLAlchemyPostRepository(PostRepository):
|
||||
orm.content = entity.content.value
|
||||
orm.slug = entity.slug.value
|
||||
orm.published = entity.published
|
||||
orm.like_count = entity.like_count
|
||||
orm.tags = entity.tags
|
||||
orm.updated_at = entity.updated_at
|
||||
|
||||
@@ -284,3 +288,60 @@ class SQLAlchemyPostRepository(PostRepository):
|
||||
result = await self._session.execute(stmt)
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None:
|
||||
"""Get a like by post and user/device.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
liked_by: User ID or device ID.
|
||||
|
||||
Returns:
|
||||
PostLike if found, None otherwise.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(PostLikeORM).where(
|
||||
PostLikeORM.post_id == str(post_id),
|
||||
PostLikeORM.liked_by == liked_by,
|
||||
)
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
if not orm:
|
||||
return None
|
||||
return PostLike(
|
||||
id=UUID(orm.id),
|
||||
post_id=UUID(orm.post_id),
|
||||
liked_by=orm.liked_by,
|
||||
created_at=orm.created_at,
|
||||
)
|
||||
|
||||
async def add_like(self, like: PostLike) -> None:
|
||||
"""Add a new like.
|
||||
|
||||
Args:
|
||||
like: PostLike entity to add.
|
||||
"""
|
||||
orm = PostLikeORM(
|
||||
id=str(like.id),
|
||||
post_id=str(like.post_id),
|
||||
liked_by=like.liked_by,
|
||||
created_at=like.created_at,
|
||||
)
|
||||
self._session.add(orm)
|
||||
|
||||
async def remove_like(self, post_id: UUID, liked_by: str) -> None:
|
||||
"""Remove a like by post and user/device.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
liked_by: User ID or device ID.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(PostLikeORM).where(
|
||||
PostLikeORM.post_id == str(post_id),
|
||||
PostLikeORM.liked_by == liked_by,
|
||||
)
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
if orm:
|
||||
await self._session.delete(orm)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.application import (
|
||||
GetPostUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
from app.domain.exceptions import ForbiddenException, UnauthorizedException
|
||||
@@ -28,6 +29,7 @@ UpdatePostDep = FromDishka[UpdatePostUseCase]
|
||||
DeletePostDep = FromDishka[DeletePostUseCase]
|
||||
ListPostsDep = FromDishka[ListPostsUseCase]
|
||||
PublishPostDep = FromDishka[PublishPostUseCase]
|
||||
ToggleLikeDep = FromDishka[TogglePostLikeUseCase]
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.presentation.api.deps import (
|
||||
GetPostDep,
|
||||
ListPostsDep,
|
||||
PublishPostDep,
|
||||
ToggleLikeDep,
|
||||
UpdatePostDep,
|
||||
)
|
||||
from app.presentation.schemas import (
|
||||
@@ -344,3 +345,30 @@ async def unpublish_post(
|
||||
"""
|
||||
result = await use_case.unpublish(post_id, current_user_id, role)
|
||||
return PostResponseSchema(**result.__dict__)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{post_id}/like",
|
||||
response_model=PostResponseSchema,
|
||||
summary="Toggle like on a post",
|
||||
)
|
||||
async def toggle_like(
|
||||
post_id: UUID,
|
||||
use_case: ToggleLikeDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
) -> PostResponseSchema:
|
||||
"""Toggle like/unlike on a post.
|
||||
|
||||
If the user already liked the post, the like is removed (unlike).
|
||||
Otherwise, a new like is added.
|
||||
|
||||
Args:
|
||||
post_id: Unique identifier of the post.
|
||||
use_case: TogglePostLikeUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
|
||||
Returns:
|
||||
PostResponseSchema with updated like_count.
|
||||
"""
|
||||
result = await use_case.execute(post_id, current_user_id)
|
||||
return PostResponseSchema(**result.__dict__)
|
||||
|
||||
@@ -81,6 +81,7 @@ class PostResponseSchema(BaseModel):
|
||||
slug: str
|
||||
author_id: str
|
||||
published: bool
|
||||
like_count: int = 0
|
||||
tags: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user