diff --git a/AGENTS.md b/AGENTS.md index 967243f..83565bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,178 @@ tests/ - Use **Repository** pattern for data access - Use **Dependency Injection** via FastAPI's Depends() -## DDD Concepts Used +## AI Code Generation Requirements + +### Documentation Standards +- **Write self-documenting code** - use clear, descriptive variable names and function names +- **NO inline comments** - code should be readable without explanatory comments +- **Google-style docstrings** are REQUIRED for all modules, classes, and functions + +### Docstring Requirements + +#### Modules +Every module must have a module-level docstring: +```python +"""Module for managing blog posts. + +This module provides use cases for creating, updating, and deleting +blog posts in the application layer. +""" +``` + +#### Classes +Every class must have a detailed docstring: +```python +class CreatePostUseCase: + """Use case for creating a new blog post. + + This class encapsulates the business logic for creating posts, + including validation and slug generation. + + Attributes: + uow: Unit of Work for database transactions. + slug_service: Service for generating URL-friendly slugs. + + Example: + >>> use_case = CreatePostUseCase(uow, slug_service) + >>> result = await use_case.execute(dto) + """ +``` + +#### Functions/Methods +Every function must have a detailed docstring with Args, Returns, Raises: +```python +async def execute(self, dto: CreatePostDTO) -> PostDTO: + """Execute the use case to create a new post. + + Args: + dto: Data transfer object containing post creation data + including title, content, and author information. + + Returns: + PostDTO: The created post data transfer object with + generated ID and slug. + + Raises: + TitleValidationError: If the title is empty or too long. + ContentValidationError: If the content is empty. + DuplicateSlugError: If a post with the same slug exists. + + Note: + This method is idempotent - calling it multiple times with + the same data will create separate posts with unique slugs. + """ +``` + +### Google-Style Docstring Format + +Use the following sections as appropriate: +- `Args` - Parameter descriptions with types +- `Returns` - Return value description with type +- `Raises` - Exceptions that may be raised +- `Yields` - For generator functions +- `Example` - Usage examples +- `Note` - Additional important information +- `Warning` - Critical warnings +- `Attributes` - For class attributes +- `See Also` - References to related code + +## UI Development Requirements + +### HTML Templates (Jinja2) +- All HTML templates use **Jinja2** templating engine +- Templates are located in `app/presentation/templates/` +- Base template: `base.html` with theme support (light/dark) + +### data-testid Attributes (REQUIRED) +**Every interactive and significant HTML element MUST have a `data-testid` attribute** for automated testing. + +#### Required Elements: +- **Navigation**: `data-testid="nav-link-{name}"`, `data-testid="nav-logo"` +- **Buttons**: `data-testid="btn-{action}"` (e.g., `btn-create`, `btn-save`, `btn-delete`) +- **Forms**: `data-testid="form-{name}"`, `data-testid="input-{field}"`, `data-testid="submit-{action}"` +- **Cards/Posts**: `data-testid="post-card-{id}"`, `data-testid="post-title"`, `data-testid="post-content"` +- **Lists**: `data-testid="list-{name}"`, `data-testid="list-item-{index}"` +- **Theme Switcher**: `data-testid="theme-toggle"`, `data-testid="theme-{light|dark}"` +- **Messages/Alerts**: `data-testid="alert-{type}"`, `data-testid="alert-message" + +#### Example: +```html + + +
+

{{ post.title }}

+

{{ post.content }}

+
+``` + +### CSS Architecture (Gitea-inspired) +- **Theme files**: `static/css/themes/theme-{light|dark}.css` with CSS variables +- **Base styles**: `static/css/base.css` - reset, typography, CSS variables usage +- **Components**: `static/css/components.css` - buttons, cards, forms, inputs +- **Layout**: `static/css/layout.css` - grid, navigation, containers + +### Theme Support +- Light and dark themes based on Gitea color scheme +- Theme switching via `data-theme` attribute on `` element +- LocalStorage persistence for user preference +- All colors use CSS custom properties (variables) + +### Static Assets +- **All assets are local** - no external CDN dependencies +- Location: `static/` directory at project root +- Served via FastAPI `StaticFiles` middleware + +## Authentication & Authorization + +### Web UI Authentication +- **Token storage**: HTTP-only secure cookies +- **Login flow**: Redirect to Keycloak login page → Callback → Set cookie → Redirect back +- **Registration**: Only through Keycloak admin interface +- **Profile**: Read-only display of user info + +### Authorization Rules + +#### Post Visibility +| Role | Published Posts | Own Drafts | Other Drafts | +|------|----------------|------------|--------------| +| GUEST (unauthenticated) | ✅ | ❌ | ❌ | +| USER | ✅ | ✅ | ❌ | +| ADMIN | ✅ | ✅ | ✅ | + +#### UI Elements by Role +| Element | GUEST | USER | ADMIN | +|---------|-------|------|-------| +| "New Post" button | ❌ | ✅ | ✅ | +| "Edit" button on own posts | ❌ | ✅ | ✅ | +| "Edit" button on other posts | ❌ | ❌ | ✅ | +| "Delete" button on own posts | ❌ | ✅ | ✅ | +| "Delete" button on other posts | ❌ | ❌ | ✅ | +| Draft badges | ❌ | Own only | All | +| User menu in header | ❌ | ✅ | ✅ | +| Profile page access | ❌ | ✅ | ✅ | + +### Auth Routes +- `GET /auth/login` - Redirect to Keycloak +- `GET /auth/callback` - OAuth callback handler +- `GET /auth/logout` - Clear cookie and logout +- `GET /profile` - User profile page (read-only) + +### Cookie Settings +```python +response.set_cookie( + key="access_token", + value=token, + httponly=True, + secure=True, # In production + samesite="lax", + max_age=3600, # 1 hour +) +``` + +### DDD Concepts Used ### Entities - Have identity (UUID) diff --git a/app/__init__.py b/app/__init__.py index 18b665e..2ced946 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1,5 @@ -"""Application package.""" +"""Blog API Application. + +This package provides a complete blog API implementation following +Domain-Driven Design principles with FastAPI and SQLAlchemy. +""" diff --git a/app/application/__init__.py b/app/application/__init__.py index 383126a..eb4a716 100644 --- a/app/application/__init__.py +++ b/app/application/__init__.py @@ -1,4 +1,8 @@ -"""Application layer exports.""" +"""Application layer exports. + +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.interfaces import TransactionManager @@ -12,13 +16,10 @@ from app.application.use_cases import ( ) __all__ = [ - # DTOs "CreatePostDTO", "UpdatePostDTO", "PostResponseDTO", - # Interfaces "TransactionManager", - # Use Cases "CreatePostUseCase", "GetPostUseCase", "UpdatePostUseCase", diff --git a/app/application/dtos/__init__.py b/app/application/dtos/__init__.py index 3df1769..08b757b 100644 --- a/app/application/dtos/__init__.py +++ b/app/application/dtos/__init__.py @@ -1,4 +1,8 @@ -"""Application DTOs.""" +"""Application DTOs. + +This module re-exports all Data Transfer Objects used in the +application layer for data communication. +""" from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO diff --git a/app/application/dtos/post.py b/app/application/dtos/post.py index 8183845..0479eee 100644 --- a/app/application/dtos/post.py +++ b/app/application/dtos/post.py @@ -1,4 +1,9 @@ -"""DTOs for post use cases.""" +"""DTOs for post use cases. + +This module defines Data Transfer Objects used for communication between +application layer use cases and presentation layer. DTOs are immutable +dataclasses that carry data across process boundaries. +""" from dataclasses import dataclass from datetime import datetime @@ -7,7 +12,25 @@ from uuid import UUID @dataclass(frozen=True) class CreatePostDTO: - """DTO for creating a post.""" + """DTO for creating a post. + + Carries post creation data from API to use case. + Contains all required fields for post creation. + + Attributes: + title: Post title string. + content: Post content string. + author_id: Identifier of the post author. + tags: Optional list of tags for categorization. + + Example: + >>> dto = CreatePostDTO( + ... title="My Post", + ... content="Content here...", + ... author_id="user-123", + ... tags=["python"] + ... ) + """ title: str content: str @@ -17,7 +40,19 @@ class CreatePostDTO: @dataclass(frozen=True) class UpdatePostDTO: - """DTO for updating a post.""" + """DTO for updating a post. + + Carries optional post update data. All fields are optional + allowing partial updates. + + Attributes: + title: Optional new title. + content: Optional new content. + tags: Optional new tags list. + + Example: + >>> dto = UpdatePostDTO(title="Updated Title") + """ title: str | None = None content: str | None = None @@ -26,7 +61,35 @@ class UpdatePostDTO: @dataclass(frozen=True) class PostResponseDTO: - """DTO for post response.""" + """DTO for post response. + + Carries complete post data for API responses. + Includes all post attributes and metadata. + + Attributes: + id: Unique post identifier. + title: Post title. + content: Post content. + slug: URL-friendly slug. + author_id: Author identifier. + published: Publication status. + tags: List of tags. + created_at: Creation timestamp. + updated_at: Last update timestamp. + + Example: + >>> dto = PostResponseDTO( + ... id=uuid, + ... title="Post", + ... content="...", + ... slug="post", + ... author_id="user-123", + ... published=True, + ... tags=[], + ... created_at=datetime.now(), + ... updated_at=datetime.now() + ... ) + """ id: UUID title: str diff --git a/app/application/interfaces/__init__.py b/app/application/interfaces/__init__.py index 7984751..4d719f6 100644 --- a/app/application/interfaces/__init__.py +++ b/app/application/interfaces/__init__.py @@ -1,4 +1,8 @@ -"""Application interfaces.""" +"""Application interfaces. + +This module re-exports all abstract interfaces used in the +application layer for dependency inversion. +""" from app.application.interfaces.transaction_manager import TransactionManager diff --git a/app/application/interfaces/transaction_manager.py b/app/application/interfaces/transaction_manager.py index 2788751..3fc342d 100644 --- a/app/application/interfaces/transaction_manager.py +++ b/app/application/interfaces/transaction_manager.py @@ -1,17 +1,38 @@ -"""Transaction Manager interface for managing database transactions.""" +"""Transaction Manager interface for managing database transactions. + +This module defines the abstract interface for transaction management. +Implementations control transaction boundaries for use cases. +""" from abc import ABC, abstractmethod class TransactionManager(ABC): - """Abstract Transaction Manager for controlling transaction boundaries.""" + """Abstract Transaction Manager for controlling transaction boundaries. + + Provides interface for committing or rolling back database transactions. + Used by use cases to manage atomic operations. + + Example: + >>> async with transaction_manager: + ... await repository.add(entity) + ... await transaction_manager.commit() + """ @abstractmethod async def commit(self) -> None: - """Commit the current transaction.""" + """Commit the current transaction. + + Persists all pending changes to the database. + Should be called after all operations succeed. + """ ... @abstractmethod async def rollback(self) -> None: - """Rollback the current transaction.""" + """Rollback the current transaction. + + Discards all pending changes. + Should be called when an error occurs. + """ ... diff --git a/app/application/use_cases/__init__.py b/app/application/use_cases/__init__.py index e21672b..12d862d 100644 --- a/app/application/use_cases/__init__.py +++ b/app/application/use_cases/__init__.py @@ -1,4 +1,8 @@ -"""Use cases.""" +"""Use cases. + +This module re-exports all application use cases that implement +business logic operations for the blog API. +""" from app.application.use_cases.create_post import CreatePostUseCase from app.application.use_cases.delete_post import DeletePostUseCase diff --git a/app/application/use_cases/create_post.py b/app/application/use_cases/create_post.py index ff2fe37..141da46 100644 --- a/app/application/use_cases/create_post.py +++ b/app/application/use_cases/create_post.py @@ -1,4 +1,8 @@ -"""Create post use case.""" +"""Create post use case. + +This module implements the use case for creating new blog posts. +Handles slug generation, duplicate checking, and entity persistence. +""" from app.application.dtos.post import CreatePostDTO, PostResponseDTO from app.application.interfaces import TransactionManager @@ -8,28 +12,57 @@ from app.domain.repositories import PostRepository class CreatePostUseCase: - """Use case for creating a new blog post.""" + """Use case for creating a new blog post. + + Encapsulates the business logic for creating posts including + slug generation from title and duplicate slug detection. + + Attributes: + _post_repo: Repository for post data access. + _tx_manager: Transaction manager for commit control. + + Example: + >>> use_case = CreatePostUseCase(post_repo, tx_manager) + >>> result = await use_case.execute(dto) + """ def __init__( self, post_repo: PostRepository, tx_manager: TransactionManager, ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + tx_manager: Transaction manager instance. + """ self._post_repo = post_repo self._tx_manager = tx_manager async def execute(self, dto: CreatePostDTO) -> PostResponseDTO: - """Execute the use case.""" - # Generate slug from title + """Execute the use case to create a new post. + + Args: + dto: Data transfer object containing post creation data + including title, content, author ID, and optional tags. + + Returns: + PostResponseDTO with created post data including generated ID and slug. + + Raises: + AlreadyExistsException: If a post with the same slug exists. + + Note: + Slug is automatically generated from the title. + """ from app.domain.value_objects import Slug slug = Slug.from_title(dto.title) - # Check if slug already exists if await self._post_repo.slug_exists(slug.value): raise AlreadyExistsException(f"Post with slug '{slug.value}' already exists") - # Create domain entity post = Post.create( title_str=dto.title, content_str=dto.content, @@ -37,16 +70,20 @@ class CreatePostUseCase: tags=dto.tags or [], ) - # Persist entity await self._post_repo.add(post) - - # Commit transaction 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.""" + """Map domain entity to response DTO. + + Args: + post: Domain post entity. + + Returns: + PostResponseDTO with all post attributes. + """ return PostResponseDTO( id=post.id, title=post.title.value, diff --git a/app/application/use_cases/delete_post.py b/app/application/use_cases/delete_post.py index ab367b0..968d5b6 100644 --- a/app/application/use_cases/delete_post.py +++ b/app/application/use_cases/delete_post.py @@ -1,4 +1,8 @@ -"""Delete post use case.""" +"""Delete post use case. + +This module implements the use case for deleting blog posts. +Includes authorization checks to ensure users can only delete their own posts. +""" from uuid import UUID @@ -8,28 +12,51 @@ from app.domain.repositories import PostRepository class DeletePostUseCase: - """Use case for deleting a blog post.""" + """Use case for deleting a blog post. + + Handles post deletion with authorization checks. + Users can only delete posts they authored. + + Attributes: + _post_repo: Repository for post data access. + _tx_manager: Transaction manager for commit control. + + Example: + >>> use_case = DeletePostUseCase(post_repo, tx_manager) + >>> await use_case.execute(post_id, user_id) + """ def __init__( self, post_repo: PostRepository, tx_manager: TransactionManager, ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + tx_manager: Transaction manager instance. + """ self._post_repo = post_repo self._tx_manager = tx_manager async def execute(self, post_id: UUID, current_user_id: str) -> None: - """Execute the use case.""" + """Execute the use case to delete a post. + + Args: + post_id: Unique identifier of the post to delete. + current_user_id: ID of the user requesting deletion. + + Raises: + NotFoundException: If post with given ID does not exist. + ForbiddenException: If user is not the post author. + """ post = await self._post_repo.get_by_id(post_id) if not post: raise NotFoundException(f"Post with id '{post_id}' not found") - # Check authorization if post.author_id != current_user_id: raise ForbiddenException("You can only delete your own posts") - # Delete the post await self._post_repo.delete(post_id) - - # Commit transaction await self._tx_manager.commit() diff --git a/app/application/use_cases/get_post.py b/app/application/use_cases/get_post.py index 93c9bbd..4c7b6c8 100644 --- a/app/application/use_cases/get_post.py +++ b/app/application/use_cases/get_post.py @@ -1,4 +1,8 @@ -"""Get post use case.""" +"""Get post use case. + +This module implements the use case for retrieving blog posts. +Supports lookup by both ID and slug. +""" from uuid import UUID @@ -10,32 +14,78 @@ from app.domain.repositories import PostRepository class GetPostUseCase: - """Use case for retrieving a post by ID or slug.""" + """Use case for retrieving a post by ID or slug. + + Provides methods to fetch posts using different identifiers. + Handles not-found scenarios with appropriate exceptions. + + Attributes: + _post_repo: Repository for post data access. + _tx_manager: Transaction manager for transaction control. + + Example: + >>> use_case = GetPostUseCase(post_repo, tx_manager) + >>> post = await use_case.by_id(post_id) + >>> post = await use_case.by_slug("my-post") + """ def __init__( self, post_repo: PostRepository, tx_manager: TransactionManager, ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + tx_manager: Transaction manager instance. + """ self._post_repo = post_repo self._tx_manager = tx_manager async def by_id(self, post_id: UUID) -> PostResponseDTO: - """Get post by ID.""" + """Get post by ID. + + Args: + post_id: Unique identifier of the post. + + Returns: + PostResponseDTO with complete post data. + + 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") return self._map_to_dto(post) async def by_slug(self, slug: str) -> PostResponseDTO: - """Get post by slug.""" + """Get post by slug. + + Args: + slug: URL-friendly slug identifier. + + Returns: + PostResponseDTO with complete post data. + + Raises: + NotFoundException: If post with given slug does not exist. + """ post = await self._post_repo.get_by_slug(slug) if not post: raise NotFoundException(f"Post with slug '{slug}' not found") return self._map_to_dto(post) def _map_to_dto(self, post: Post) -> PostResponseDTO: - """Map domain entity to response DTO.""" + """Map domain entity to response DTO. + + Args: + post: Domain post entity. + + Returns: + PostResponseDTO with all post attributes. + """ return PostResponseDTO( id=post.id, title=post.title.value, diff --git a/app/application/use_cases/list_posts.py b/app/application/use_cases/list_posts.py index f14199b..bd5795e 100644 --- a/app/application/use_cases/list_posts.py +++ b/app/application/use_cases/list_posts.py @@ -1,4 +1,8 @@ -"""List posts use case.""" +"""List posts use case. + +This module implements the use case for listing blog posts. +Provides multiple query methods including filtering by author, tag, and search. +""" from app.application.dtos.post import PostResponseDTO from app.application.interfaces import TransactionManager @@ -7,18 +11,40 @@ from app.domain.repositories import PostRepository class ListPostsUseCase: - """Use case for listing blog posts with filtering.""" + """Use case for listing blog posts with filtering. + + Provides various methods to query posts with different criteria + including pagination support for large result sets. + + Attributes: + _post_repo: Repository for post data access. + _tx_manager: Transaction manager for transaction control. + + Example: + >>> use_case = ListPostsUseCase(post_repo, tx_manager) + >>> posts = await use_case.published_posts(limit=10, offset=0) + """ def __init__( self, post_repo: PostRepository, tx_manager: TransactionManager, ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + tx_manager: Transaction manager instance. + """ self._post_repo = post_repo self._tx_manager = tx_manager async def all_posts(self) -> list[PostResponseDTO]: - """Get all posts.""" + """Get all posts. + + Returns: + List of PostResponseDTO for all posts. + """ posts = await self._post_repo.get_all() return [self._map_to_dto(post) for post in posts] @@ -27,7 +53,15 @@ class ListPostsUseCase: limit: int | None = None, offset: int | None = None, ) -> list[PostResponseDTO]: - """Get all published posts.""" + """Get all published posts. + + Args: + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of PostResponseDTO for published posts. + """ posts = await self._post_repo.get_published(limit=limit, offset=offset) return [self._map_to_dto(post) for post in posts] @@ -37,7 +71,16 @@ class ListPostsUseCase: limit: int | None = None, offset: int | None = None, ) -> list[PostResponseDTO]: - """Get posts by author.""" + """Get posts by author. + + Args: + author_id: Identifier of the author. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of PostResponseDTO for posts by the author. + """ posts = await self._post_repo.get_by_author(author_id, limit=limit, offset=offset) return [self._map_to_dto(post) for post in posts] @@ -47,7 +90,16 @@ class ListPostsUseCase: limit: int | None = None, offset: int | None = None, ) -> list[PostResponseDTO]: - """Get posts by tag.""" + """Get posts by tag. + + Args: + tag: Tag to filter by. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of PostResponseDTO for posts with the tag. + """ posts = await self._post_repo.get_by_tag(tag, limit=limit, offset=offset) return [self._map_to_dto(post) for post in posts] @@ -57,12 +109,28 @@ class ListPostsUseCase: limit: int | None = None, offset: int | None = None, ) -> list[PostResponseDTO]: - """Search posts.""" + """Search posts. + + Args: + query: Search query string. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of PostResponseDTO for matching posts. + """ posts = await self._post_repo.search(query, limit=limit, offset=offset) return [self._map_to_dto(post) for post in posts] def _map_to_dto(self, post: Post) -> PostResponseDTO: - """Map domain entity to response DTO.""" + """Map domain entity to response DTO. + + Args: + post: Domain post entity. + + Returns: + PostResponseDTO with all post attributes. + """ return PostResponseDTO( id=post.id, title=post.title.value, diff --git a/app/application/use_cases/publish_post.py b/app/application/use_cases/publish_post.py index de2a7e7..a585d6c 100644 --- a/app/application/use_cases/publish_post.py +++ b/app/application/use_cases/publish_post.py @@ -1,4 +1,8 @@ -"""Publish post use case.""" +"""Publish post use case. + +This module implements the use case for publishing and unpublishing blog posts. +Includes authorization checks to ensure users can only manage their own posts. +""" from uuid import UUID @@ -10,18 +14,48 @@ from app.domain.repositories import PostRepository class PublishPostUseCase: - """Use case for publishing/unpublishing a blog post.""" + """Use case for publishing/unpublishing a blog post. + + Handles post publication state changes with authorization checks. + Users can only publish or unpublish posts they authored. + + Attributes: + _post_repo: Repository for post data access. + _tx_manager: Transaction manager for commit control. + + Example: + >>> use_case = PublishPostUseCase(post_repo, tx_manager) + >>> post = await use_case.publish(post_id, user_id) + """ def __init__( self, post_repo: PostRepository, tx_manager: TransactionManager, ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + tx_manager: Transaction manager instance. + """ self._post_repo = post_repo self._tx_manager = tx_manager async def publish(self, post_id: UUID, current_user_id: str) -> PostResponseDTO: - """Publish a post.""" + """Publish a post. + + Args: + post_id: Unique identifier of the post. + current_user_id: ID of the user requesting publication. + + Returns: + PostResponseDTO with updated post data. + + Raises: + NotFoundException: If post with given ID does not exist. + ForbiddenException: If user is not the post author. + """ post = await self._post_repo.get_by_id(post_id) if not post: raise NotFoundException(f"Post with id '{post_id}' not found") @@ -36,7 +70,19 @@ class PublishPostUseCase: return self._map_to_dto(post) async def unpublish(self, post_id: UUID, current_user_id: str) -> PostResponseDTO: - """Unpublish a post.""" + """Unpublish a post. + + Args: + post_id: Unique identifier of the post. + current_user_id: ID of the user requesting unpublish. + + Returns: + PostResponseDTO with updated post data. + + Raises: + NotFoundException: If post with given ID does not exist. + ForbiddenException: If user is not the post author. + """ post = await self._post_repo.get_by_id(post_id) if not post: raise NotFoundException(f"Post with id '{post_id}' not found") @@ -51,7 +97,14 @@ class PublishPostUseCase: return self._map_to_dto(post) def _map_to_dto(self, post: Post) -> PostResponseDTO: - """Map domain entity to response DTO.""" + """Map domain entity to response DTO. + + Args: + post: Domain post entity. + + Returns: + PostResponseDTO with all post attributes. + """ return PostResponseDTO( id=post.id, title=post.title.value, diff --git a/app/application/use_cases/update_post.py b/app/application/use_cases/update_post.py index 1ec8587..bb240c6 100644 --- a/app/application/use_cases/update_post.py +++ b/app/application/use_cases/update_post.py @@ -1,4 +1,8 @@ -"""Update post use case.""" +"""Update post use case. + +This module implements the use case for updating blog posts. +Supports partial updates and includes authorization checks. +""" from uuid import UUID @@ -11,13 +15,33 @@ from app.domain.value_objects import Content, Title class UpdatePostUseCase: - """Use case for updating a blog post.""" + """Use case for updating a blog post. + + Handles post updates with authorization checks. + Supports partial updates - only provided fields are changed. + Users can only update posts they authored. + + Attributes: + _post_repo: Repository for post data access. + _tx_manager: Transaction manager for commit control. + + Example: + >>> use_case = UpdatePostUseCase(post_repo, tx_manager) + >>> dto = UpdatePostDTO(title="New Title") + >>> result = await use_case.execute(post_id, dto, user_id) + """ def __init__( self, post_repo: PostRepository, tx_manager: TransactionManager, ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post operations. + tx_manager: Transaction manager instance. + """ self._post_repo = post_repo self._tx_manager = tx_manager @@ -27,16 +51,27 @@ class UpdatePostUseCase: dto: UpdatePostDTO, current_user_id: str, ) -> PostResponseDTO: - """Execute the use case.""" + """Execute the use case to update a post. + + Args: + post_id: Unique identifier of the post to update. + dto: Data transfer object with update data. + current_user_id: ID of the user requesting update. + + Returns: + PostResponseDTO with updated post data. + + Raises: + NotFoundException: If post with given ID does not exist. + ForbiddenException: If user is not the post author. + """ post = await self._post_repo.get_by_id(post_id) if not post: raise NotFoundException(f"Post with id '{post_id}' not found") - # Check authorization if post.author_id != current_user_id: raise ForbiddenException("You can only update your own posts") - # Update fields if dto.title is not None: post.update_title(Title(dto.title)) @@ -44,22 +79,25 @@ class UpdatePostUseCase: post.update_content(Content(dto.content)) if dto.tags is not None: - # Replace all tags for tag in post.tags[:]: post.remove_tag(tag) for tag in dto.tags: post.add_tag(tag) - # Persist changes await self._post_repo.update(post) - - # Commit transaction 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.""" + """Map domain entity to response DTO. + + Args: + post: Domain post entity. + + Returns: + PostResponseDTO with all post attributes. + """ return PostResponseDTO( id=post.id, title=post.title.value, diff --git a/app/domain/__init__.py b/app/domain/__init__.py index 13414bf..3c1e050 100644 --- a/app/domain/__init__.py +++ b/app/domain/__init__.py @@ -1,4 +1,8 @@ -"""Domain layer exports.""" +"""Domain layer exports. + +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.exceptions import ( @@ -13,18 +17,14 @@ from app.domain.repositories import PostRepository, Repository from app.domain.value_objects import Content, Slug, Title, ValueObject __all__ = [ - # Entities "BaseEntity", "Post", - # Value Objects "ValueObject", "Title", "Content", "Slug", - # Repositories "Repository", "PostRepository", - # Exceptions "DomainException", "ValidationException", "NotFoundException", diff --git a/app/domain/entities/__init__.py b/app/domain/entities/__init__.py index 32b8109..2e27852 100644 --- a/app/domain/entities/__init__.py +++ b/app/domain/entities/__init__.py @@ -1,4 +1,8 @@ -"""Domain entities.""" +"""Domain entities. + +This module re-exports all domain entities that represent +core business objects with identity. +""" from app.domain.entities.base import BaseEntity from app.domain.entities.post import Post diff --git a/app/domain/entities/base.py b/app/domain/entities/base.py index 8f20c51..161704e 100644 --- a/app/domain/entities/base.py +++ b/app/domain/entities/base.py @@ -1,4 +1,9 @@ -"""Base entity for DDD domain layer.""" +"""Base entity for DDD domain layer. + +This module provides the foundational BaseEntity class that all domain +entities must inherit from. It implements common entity patterns including +identity management, equality comparison, and timestamp tracking. +""" from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -9,25 +14,62 @@ from uuid import UUID, uuid4 @dataclass(kw_only=True) class BaseEntity(ABC): - """Base class for all domain entities.""" + """Base class for all domain entities. + + Provides common functionality for domain entities including unique + identification, creation/update timestamps, and equality comparison + based on identity. + + Attributes: + id: Unique identifier for the entity, automatically generated. + created_at: Timestamp when the entity was created. + updated_at: Timestamp when the entity was last updated. + + Example: + >>> class User(BaseEntity): + ... name: str + ... def to_dict(self) -> dict[str, Any]: + ... return {"id": str(self.id), "name": self.name} + """ id: UUID = field(default_factory=uuid4) created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) def __eq__(self, other: object) -> bool: + """Compare entities by identity. + + Args: + other: Another object to compare with. + + Returns: + True if both objects are BaseEntity instances with same ID. + """ if not isinstance(other, BaseEntity): return NotImplemented return self.id == other.id def __hash__(self) -> int: + """Get hash based on entity identity. + + Returns: + Hash of the entity ID. + """ return hash(self.id) def touch(self) -> None: - """Update the updated_at timestamp.""" + """Update the updated_at timestamp. + + Should be called whenever the entity is modified to track + the last modification time. + """ self.updated_at = datetime.now(UTC) @abstractmethod def to_dict(self) -> dict[str, Any]: - """Convert entity to dictionary.""" + """Convert entity to dictionary representation. + + Returns: + Dictionary containing all entity attributes. + """ ... diff --git a/app/domain/entities/post.py b/app/domain/entities/post.py index 27d567b..7cffe24 100644 --- a/app/domain/entities/post.py +++ b/app/domain/entities/post.py @@ -1,4 +1,9 @@ -"""Domain entity for Blog Post.""" +"""Domain entity for Blog Post. + +This module defines the Post aggregate root entity that encapsulates +all business logic related to blog posts including publishing, content +management, and tag operations. +""" from dataclasses import dataclass, field from typing import Any @@ -11,7 +16,28 @@ from app.domain.value_objects.title import Title @dataclass(kw_only=True) class Post(BaseEntity): - """Blog post domain entity.""" + """Blog post domain entity. + + Represents a blog post with title, content, slug, and metadata. + Encapsulates business logic for post lifecycle management. + + Attributes: + title: Post title value object with validation. + content: Post content value object with validation. + slug: URL-friendly identifier generated from title. + author_id: Identifier of the post author. + published: Publication status flag. + tags: List of tags associated with the post. + + Example: + >>> post = Post.create( + ... title_str="My First Post", + ... content_str="This is the content...", + ... author_id="user-123", + ... tags=["python", "fastapi"] + ... ) + >>> post.publish() + """ title: Title content: Content @@ -21,40 +47,66 @@ class Post(BaseEntity): tags: list[str] = field(default_factory=list) def publish(self) -> None: - """Publish the post.""" + """Publish the post. + + Sets the published flag to True and updates the timestamp. + """ self.published = True self.touch() def unpublish(self) -> None: - """Unpublish the post.""" + """Unpublish the post. + + Sets the published flag to False and updates the timestamp. + """ self.published = False self.touch() def update_content(self, content: Content) -> None: - """Update post content.""" + """Update post content. + + Args: + content: New content value object. + """ self.content = content self.touch() def update_title(self, title: Title) -> None: - """Update post title and regenerate slug.""" + """Update post title and regenerate slug. + + Args: + title: New title value object. + """ self.title = title self.slug = Slug.from_title(title.value) self.touch() def add_tag(self, tag: str) -> None: - """Add a tag to the post.""" + """Add a tag to the post. + + Args: + tag: Tag string to add. Only adds if not already present. + """ if tag not in self.tags: self.tags.append(tag) self.touch() def remove_tag(self, tag: str) -> None: - """Remove a tag from the post.""" + """Remove a tag from the post. + + Args: + tag: Tag string to remove. Only removes if present. + """ if tag in self.tags: self.tags.remove(tag) self.touch() def to_dict(self) -> dict[str, Any]: - """Convert entity to dictionary.""" + """Convert entity to dictionary. + + Returns: + Dictionary representation with all post attributes. + """ return { "id": str(self.id), "title": self.title.value, @@ -75,7 +127,17 @@ class Post(BaseEntity): author_id: str, tags: list[str] | None = None, ) -> "Post": - """Factory method to create a new post.""" + """Factory method to create a new post. + + Args: + title_str: Title string for the post. + content_str: Content string for the post. + author_id: Identifier of the post author. + tags: Optional list of tags. + + Returns: + New Post instance with validated value objects. + """ title = Title(title_str) content = Content(content_str) slug = Slug.from_title(title_str) diff --git a/app/domain/exceptions.py b/app/domain/exceptions.py index 019d2f2..9af6432 100644 --- a/app/domain/exceptions.py +++ b/app/domain/exceptions.py @@ -1,39 +1,78 @@ -"""Domain exceptions.""" +"""Domain exceptions for business logic errors. + +This module defines the exception hierarchy for domain layer errors. +All domain exceptions inherit from DomainException base class. +""" class DomainException(Exception): - """Base exception for domain layer.""" + """Base exception for domain layer. + + All domain-specific exceptions should inherit from this class. + Provides a consistent interface for error messages. + + Attributes: + message: Human-readable error description. + + Example: + >>> raise DomainException("Business rule violated") + """ def __init__(self, message: str) -> None: + """Initialize domain exception. + + Args: + message: Error message describing the exception. + """ self.message = message super().__init__(self.message) class ValidationException(DomainException): - """Raised when validation fails.""" + """Raised when validation fails. - pass + Used when entity or value object validation does not pass. + + Example: + >>> raise ValidationException("Title is too long") + """ class NotFoundException(DomainException): - """Raised when an entity is not found.""" + """Raised when an entity is not found. - pass + Used when requesting an entity that does not exist in the repository. + + Example: + >>> raise NotFoundException("Post with id 123 not found") + """ class AlreadyExistsException(DomainException): - """Raised when trying to create an entity that already exists.""" + """Raised when trying to create an entity that already exists. - pass + Used when attempting to create a duplicate entity. + + Example: + >>> raise AlreadyExistsException("Post with this slug already exists") + """ class UnauthorizedException(DomainException): - """Raised when user is not authorized.""" + """Raised when user is not authorized. - pass + Used when authentication is required but not provided or invalid. + + Example: + >>> raise UnauthorizedException("Authentication required") + """ class ForbiddenException(DomainException): - """Raised when access is forbidden.""" + """Raised when access is forbidden. - pass + Used when authenticated user lacks required permissions. + + Example: + >>> raise ForbiddenException("Only admins can delete posts") + """ diff --git a/app/domain/repositories/__init__.py b/app/domain/repositories/__init__.py index c8218b4..0ee23bc 100644 --- a/app/domain/repositories/__init__.py +++ b/app/domain/repositories/__init__.py @@ -1,4 +1,8 @@ -"""Repository interfaces.""" +"""Repository interfaces. + +This module re-exports all repository interfaces that define +the contract for data access operations. +""" from app.domain.repositories.base import Repository from app.domain.repositories.post import PostRepository diff --git a/app/domain/repositories/base.py b/app/domain/repositories/base.py index 254b4fc..c18d6ea 100644 --- a/app/domain/repositories/base.py +++ b/app/domain/repositories/base.py @@ -1,4 +1,8 @@ -"""Base repository interface for DDD.""" +"""Base repository interface for DDD. + +This module defines the generic repository pattern interface that all +repository implementations must follow. Provides standard CRUD operations. +""" from abc import ABC, abstractmethod from typing import Generic, TypeVar @@ -10,34 +14,76 @@ T = TypeVar("T", bound=BaseEntity) class Repository(ABC, Generic[T]): - """Generic repository interface.""" + """Generic repository interface. + + Defines the contract for repository implementations. All repositories + must provide standard CRUD operations for their entity type. + + Type Parameters: + T: Entity type that must inherit from BaseEntity. + + Example: + >>> class PostRepository(Repository[Post]): + ... async def get_by_id(self, entity_id: UUID) -> Post | None: + ... ... + """ @abstractmethod async def get_by_id(self, entity_id: UUID) -> T | None: - """Get entity by ID.""" + """Get entity by ID. + + Args: + entity_id: Unique identifier of the entity. + + Returns: + Entity instance if found, None otherwise. + """ ... @abstractmethod async def get_all(self) -> list[T]: - """Get all entities.""" + """Get all entities. + + Returns: + List of all entity instances. + """ ... @abstractmethod async def add(self, entity: T) -> None: - """Add new entity.""" + """Add new entity. + + Args: + entity: Entity instance to add. + """ ... @abstractmethod async def update(self, entity: T) -> None: - """Update existing entity.""" + """Update existing entity. + + Args: + entity: Entity instance with updated data. + """ ... @abstractmethod async def delete(self, entity_id: UUID) -> None: - """Delete entity by ID.""" + """Delete entity by ID. + + Args: + entity_id: Unique identifier of the entity to delete. + """ ... @abstractmethod async def exists(self, entity_id: UUID) -> bool: - """Check if entity exists.""" + """Check if entity exists. + + Args: + entity_id: Unique identifier of the entity. + + Returns: + True if entity exists, False otherwise. + """ ... diff --git a/app/domain/repositories/post.py b/app/domain/repositories/post.py index 2e0fa99..79414f0 100644 --- a/app/domain/repositories/post.py +++ b/app/domain/repositories/post.py @@ -1,4 +1,8 @@ -"""Post repository interface.""" +"""Post repository interface. + +This module extends the base repository interface with post-specific +query methods including slug lookup, author filtering, and search. +""" from abc import abstractmethod @@ -7,11 +11,27 @@ from app.domain.repositories.base import Repository class PostRepository(Repository[Post]): - """Repository interface for Blog Posts.""" + """Repository interface for Blog Posts. + + Extends the generic repository with post-specific operations + including slug-based lookup, author filtering, tag filtering, + and full-text search capabilities. + + Example: + >>> posts = await repo.get_by_author("user-123", limit=10) + >>> exists = await repo.slug_exists("my-first-post") + """ @abstractmethod async def get_by_slug(self, slug: str) -> Post | None: - """Get post by slug.""" + """Get post by slug. + + Args: + slug: URL-friendly slug identifier. + + Returns: + Post instance if found, None otherwise. + """ ... @abstractmethod @@ -21,7 +41,16 @@ class PostRepository(Repository[Post]): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Get all posts by author.""" + """Get all posts by author. + + Args: + author_id: Identifier of the author. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of posts by the specified author. + """ ... @abstractmethod @@ -30,7 +59,15 @@ class PostRepository(Repository[Post]): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Get all published posts.""" + """Get all published posts. + + Args: + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of published posts. + """ ... @abstractmethod @@ -40,12 +77,28 @@ class PostRepository(Repository[Post]): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Get posts by tag.""" + """Get posts by tag. + + Args: + tag: Tag to filter by. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of posts with the specified tag. + """ ... @abstractmethod async def slug_exists(self, slug: str) -> bool: - """Check if slug already exists.""" + """Check if slug already exists. + + Args: + slug: Slug to check for existence. + + Returns: + True if slug exists, False otherwise. + """ ... @abstractmethod @@ -55,5 +108,14 @@ class PostRepository(Repository[Post]): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Search posts by query string.""" + """Search posts by query string. + + Args: + query: Search query string. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of posts matching the search query. + """ ... diff --git a/app/domain/roles.py b/app/domain/roles.py index 08e3ed4..f85fb7a 100644 --- a/app/domain/roles.py +++ b/app/domain/roles.py @@ -1,4 +1,8 @@ -"""Role-based access control definitions.""" +"""Role-based access control definitions. + +This module provides role and permission definitions for the application +along with utility functions and decorators for permission checking. +""" from collections.abc import Callable from enum import Enum @@ -9,7 +13,20 @@ from app.domain.exceptions import ForbiddenException class Role(str, Enum): - """User roles in the system.""" + """User roles in the system. + + Defines the available user roles with hierarchical permissions. + ADMIN has full access, USER has standard access, GUEST has read-only. + + Attributes: + ADMIN: Administrator with full system access. + USER: Regular authenticated user. + GUEST: Unauthenticated or limited access user. + + Example: + >>> if role == Role.ADMIN: + ... grant_full_access() + """ ADMIN = "admin" USER = "user" @@ -17,9 +34,16 @@ class Role(str, Enum): class Permission: - """Permission definitions.""" + """Permission definitions. + + Contains string constants for all available permissions in the system. + Used for role-based access control checks. + + Example: + >>> if has_permission(role, Permission.POST_CREATE): + ... allow_post_creation() + """ - # Post permissions POST_CREATE = "post:create" POST_READ = "post:read" POST_READ_UNPUBLISHED = "post:read_unpublished" @@ -28,7 +52,6 @@ class Permission: POST_PUBLISH = "post:publish" -# Role-based permission mapping ROLE_PERMISSIONS: dict[Role, list[str]] = { Role.ADMIN: [ Permission.POST_CREATE, @@ -52,24 +75,52 @@ ROLE_PERMISSIONS: dict[Role, list[str]] = { def has_permission(role: Role, permission: str) -> bool: - """Check if role has specific permission.""" + """Check if role has specific permission. + + Args: + role: User role to check. + permission: Permission string to verify. + + Returns: + True if role has the permission, False otherwise. + + Example: + >>> has_permission(Role.ADMIN, Permission.POST_DELETE) + True + """ return permission in ROLE_PERMISSIONS.get(role, []) def require_permission( permission: str, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator to require specific permission.""" + """Decorator to require specific permission. + + Creates a decorator that checks if the user has the required permission + before executing the decorated function. + + Args: + permission: Permission string required for execution. + + Returns: + Decorator function for permission checking. + + Raises: + ForbiddenException: If user lacks the required permission. + + Example: + >>> @require_permission(Permission.POST_CREATE) + ... async def create_post(): + ... ... + """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: - # Get token_info from kwargs token_info = kwargs.get("token_info") if not token_info: raise ForbiddenException("Authentication required") - # Determine role from token or default to guest roles = getattr(token_info, "roles", []) if Role.ADMIN.value in roles: role = Role.ADMIN @@ -93,7 +144,18 @@ def require_permission( def get_effective_role(roles: list[str]) -> Role: """Determine effective role from list of roles. - Priority: admin > user > guest + Evaluates multiple roles and returns the highest privilege role. + Priority order: admin > user > guest. + + Args: + roles: List of role strings from token. + + Returns: + Highest privilege Role enum value. + + Example: + >>> get_effective_role(["user", "admin"]) + """ if Role.ADMIN.value in roles: return Role.ADMIN diff --git a/app/domain/value_objects/__init__.py b/app/domain/value_objects/__init__.py index 0c15930..ce6277a 100644 --- a/app/domain/value_objects/__init__.py +++ b/app/domain/value_objects/__init__.py @@ -1,4 +1,8 @@ -"""Value objects.""" +"""Value objects. + +This module re-exports all domain value objects that represent +immutable validated domain concepts. +""" from app.domain.value_objects.base import ValueObject from app.domain.value_objects.content import Content diff --git a/app/domain/value_objects/base.py b/app/domain/value_objects/base.py index 1da549d..e9f76a8 100644 --- a/app/domain/value_objects/base.py +++ b/app/domain/value_objects/base.py @@ -1,4 +1,9 @@ -"""Base value object for DDD domain layer.""" +"""Base value object for DDD domain layer. + +This module provides the foundational ValueObject class that all domain +value objects must inherit from. Implements equality, hashing, and +validation patterns for immutable value objects. +""" from abc import ABC, abstractmethod from dataclasses import dataclass @@ -9,29 +14,78 @@ T = TypeVar("T") @dataclass(frozen=True, slots=True) class ValueObject(ABC, Generic[T]): - """Base class for all value objects.""" + """Base class for all value objects. + + Value objects are immutable objects defined by their attributes rather + than identity. They are validated on creation and provide type safety. + + Attributes: + value: The underlying value wrapped by the value object. + + Type Parameters: + T: Type of the wrapped value. + + Example: + >>> class Email(ValueObject[str]): + ... def _validate(self) -> None: + ... if "@" not in self.value: + ... raise ValueError("Invalid email") + """ value: T def __post_init__(self) -> None: + """Validate value object after initialization. + + Automatically called by dataclass after __init__. Triggers + the validation method to ensure value integrity. + """ self._validate() @abstractmethod def _validate(self) -> None: - """Validate the value object. Raise ValueError if invalid.""" + """Validate the value object. + + Must be implemented by subclasses to enforce value constraints. + + Raises: + ValueError: If the value does not meet validation criteria. + """ ... def __eq__(self, other: object) -> bool: + """Compare value objects by value. + + Args: + other: Another object to compare with. + + Returns: + True if both are ValueObjects with equal values. + """ if not isinstance(other, ValueObject): return False return bool(self.value == other.value) def __hash__(self) -> int: + """Get hash based on wrapped value. + + Returns: + Hash of the underlying value. + """ return hash(self.value) def __str__(self) -> str: + """Convert value object to string. + + Returns: + String representation of the wrapped value. + """ return str(self.value) def to_primitive(self) -> Any: - """Convert value object to primitive type.""" + """Convert value object to primitive type. + + Returns: + The underlying primitive value. + """ return self.value diff --git a/app/domain/value_objects/content.py b/app/domain/value_objects/content.py index efeff58..4b34cd8 100644 --- a/app/domain/value_objects/content.py +++ b/app/domain/value_objects/content.py @@ -1,4 +1,8 @@ -"""Content value object.""" +"""Content value object. + +This module defines the Content value object for blog post content +with validation for minimum and maximum length constraints. +""" from dataclasses import dataclass @@ -7,12 +11,35 @@ from app.domain.value_objects.base import ValueObject @dataclass(frozen=True, slots=True) class Content(ValueObject[str]): - """Blog post content value object.""" + """Blog post content value object. + + Wraps and validates blog post content ensuring it meets length + requirements and is not empty. + + Attributes: + value: The content string value. + MIN_LENGTH: Minimum allowed content length (10 characters). + MAX_LENGTH: Maximum allowed content length (50000 characters). + + Raises: + ValueError: If content is empty, too short, or too long. + + Example: + >>> content = Content("This is valid content...") + >>> print(content.value) + """ MIN_LENGTH: int = 10 MAX_LENGTH: int = 50000 def _validate(self) -> None: + """Validate content string. + + 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("Content must be a string") if not self.value.strip(): diff --git a/app/domain/value_objects/slug.py b/app/domain/value_objects/slug.py index 13e27f3..2d74444 100644 --- a/app/domain/value_objects/slug.py +++ b/app/domain/value_objects/slug.py @@ -1,4 +1,9 @@ -"""Slug value object for URL-friendly identifiers.""" +"""Slug value object for URL-friendly identifiers. + +This module defines the Slug value object for generating and validating +URL-friendly slugs from titles. Enforces lowercase, alphanumeric, and +hyphen-only format. +""" import re from dataclasses import dataclass @@ -8,12 +13,36 @@ from app.domain.value_objects.base import ValueObject @dataclass(frozen=True, slots=True) class Slug(ValueObject[str]): - """URL slug value object.""" + """URL slug value object. + + Represents a URL-friendly identifier generated from titles. + Validates format and provides factory method for slug generation. + + Attributes: + value: The slug string value. + MAX_LENGTH: Maximum allowed slug length (200 characters). + SLUG_PATTERN: Regex pattern for valid slug format. + + Raises: + ValueError: If slug format is invalid. + + Example: + >>> slug = Slug.from_title("My First Post!") + >>> print(slug.value) + 'my-first-post' + """ MAX_LENGTH: int = 200 SLUG_PATTERN: str = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" def _validate(self) -> None: + """Validate slug format. + + Ensures slug contains only lowercase letters, numbers, and hyphens. + + Raises: + ValueError: If slug format is invalid. + """ if not isinstance(self.value, str): raise ValueError("Slug must be a string") if len(self.value) > self.MAX_LENGTH: @@ -23,17 +52,22 @@ class Slug(ValueObject[str]): @classmethod def from_title(cls, title: str) -> "Slug": - """Generate slug from title.""" - # Convert to lowercase, replace spaces with hyphens + """Generate slug from title. + + Converts title to URL-friendly format by lowercasing, removing + special characters, and replacing spaces with hyphens. + + Args: + title: Source title string. + + Returns: + New Slug instance with generated value. + """ slug = title.lower().strip() - # Keep only alphanumeric, spaces, and hyphens slug = re.sub(r"[^a-z0-9\s-]", "", slug) - # Replace spaces and multiple hyphens with single hyphen slug = re.sub(r"[-\s]+", "-", slug) - # Limit length and strip hyphens - max_len = 200 # Same as MAX_LENGTH + max_len = 200 slug = slug[:max_len].strip("-") - # Ensure we have at least one character if not slug: slug = "post" return cls(value=slug) diff --git a/app/domain/value_objects/title.py b/app/domain/value_objects/title.py index 730d1c1..a1891bb 100644 --- a/app/domain/value_objects/title.py +++ b/app/domain/value_objects/title.py @@ -1,4 +1,8 @@ -"""Title value object.""" +"""Title value object. + +This module defines the Title value object for blog post titles +with validation for minimum and maximum length constraints. +""" from dataclasses import dataclass @@ -7,12 +11,35 @@ from app.domain.value_objects.base import ValueObject @dataclass(frozen=True, slots=True) class Title(ValueObject[str]): - """Blog post title value object.""" + """Blog post title value object. + + Wraps and validates blog post titles ensuring they meet length + requirements and are not empty. + + Attributes: + value: The title string value. + MIN_LENGTH: Minimum allowed title length (3 characters). + MAX_LENGTH: Maximum allowed title length (200 characters). + + Raises: + ValueError: If title is empty, too short, or too long. + + Example: + >>> title = Title("My Blog Post") + >>> print(title.value) + """ MIN_LENGTH: int = 3 MAX_LENGTH: int = 200 def _validate(self) -> None: + """Validate title string. + + Checks that title is a non-empty string within length bounds. + + Raises: + ValueError: If title fails validation criteria. + """ if not isinstance(self.value, str): raise ValueError("Title must be a string") if len(self.value) < self.MIN_LENGTH: diff --git a/app/infrastructure/__init__.py b/app/infrastructure/__init__.py index 8de9699..7a51baf 100644 --- a/app/infrastructure/__init__.py +++ b/app/infrastructure/__init__.py @@ -1,4 +1,8 @@ -"""Infrastructure layer exports.""" +"""Infrastructure layer exports. + +This module re-exports all infrastructure components including +config, database, repositories, DI, and middleware. +""" from app.infrastructure.config import Settings, settings from app.infrastructure.database import ( @@ -15,10 +19,8 @@ from app.infrastructure.middleware import register_exception_handlers from app.infrastructure.repositories import SQLAlchemyPostRepository __all__ = [ - # Config "Settings", "settings", - # Database "Base", "PostORM", "engine", @@ -26,10 +28,7 @@ __all__ = [ "get_session", "init_db", "close_db", - # Repositories "SQLAlchemyPostRepository", - # DI "create_container", - # Middleware "register_exception_handlers", ] diff --git a/app/infrastructure/auth/__init__.py b/app/infrastructure/auth/__init__.py index cdd6fcd..cf077c9 100644 --- a/app/infrastructure/auth/__init__.py +++ b/app/infrastructure/auth/__init__.py @@ -1,4 +1,8 @@ -"""Authentication infrastructure package.""" +"""Authentication infrastructure package. + +This module provides Keycloak authentication client and models +for token validation and user info retrieval. +""" from app.infrastructure.auth.client import KeycloakAuthClient from app.infrastructure.auth.models import KeycloakUser, TokenInfo diff --git a/app/infrastructure/auth/client.py b/app/infrastructure/auth/client.py index 93857cb..a62f4aa 100644 --- a/app/infrastructure/auth/client.py +++ b/app/infrastructure/auth/client.py @@ -1,4 +1,8 @@ -"""Keycloak authentication client.""" +"""Keycloak authentication client. + +This module provides a client for Keycloak authentication operations +including token introspection and user info retrieval. +""" import time @@ -9,10 +13,30 @@ from app.infrastructure.config.settings import Settings class KeycloakAuthClient: - """Client for Keycloak authentication operations.""" + """Client for Keycloak authentication operations. + + Handles token validation via introspection and user info retrieval. + Implements token caching to reduce Keycloak server load. + + Attributes: + _settings: Application settings with Keycloak config. + _base_url: Keycloak realm base URL. + _client_id: OAuth client identifier. + _client_secret: OAuth client secret. + _cache: Token info cache for performance. + _cache_ttl: Cache time-to-live in seconds. + + Example: + >>> client = KeycloakAuthClient(settings) + >>> token_info = await client.introspect_token(token) + """ def __init__(self, settings: Settings) -> None: - """Initialize Keycloak client with settings.""" + """Initialize Keycloak client with settings. + + Args: + settings: Application settings with Keycloak configuration. + """ self._settings = settings self._base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}" self._client_id = settings.kc.client_id @@ -21,15 +45,30 @@ class KeycloakAuthClient: self._cache_ttl = settings.kc.token_cache_ttl def _get_introspection_url(self) -> str: - """Get token introspection endpoint URL.""" + """Get token introspection endpoint URL. + + Returns: + Full URL for token introspection endpoint. + """ return f"{self._base_url}/protocol/openid-connect/token/introspection" def _get_userinfo_url(self) -> str: - """Get userinfo endpoint URL.""" + """Get userinfo endpoint URL. + + Returns: + Full URL for userinfo endpoint. + """ return f"{self._base_url}/protocol/openid-connect/userinfo" def _get_cached_token(self, token: str) -> TokenInfo | None: - """Get cached token info if valid.""" + """Get cached token info if valid. + + Args: + token: Access token string. + + Returns: + Cached TokenInfo if valid and not expired, None otherwise. + """ if token not in self._cache: return None @@ -41,9 +80,13 @@ class KeycloakAuthClient: return token_info def _cache_token(self, token: str, token_info: TokenInfo) -> None: - """Cache token info.""" + """Cache token info. + + Args: + token: Access token string as cache key. + token_info: TokenInfo to cache. + """ self._cache[token] = (token_info, time.time()) - # Simple cleanup of old entries current_time = time.time() expired_keys = [ k for k, (_, t) in self._cache.items() if current_time - t > self._cache_ttl @@ -52,13 +95,21 @@ class KeycloakAuthClient: del self._cache[k] async def introspect_token(self, token: str) -> TokenInfo: - """Introspect access token using Keycloak.""" - # Check cache first + """Introspect access token using Keycloak. + + Validates token with Keycloak server and extracts user information. + Uses cache to reduce server requests for recently validated tokens. + + Args: + token: Access token to validate. + + Returns: + TokenInfo with validation result and user claims. + """ cached = self._get_cached_token(token) if cached: return cached - # Prepare introspection request data = { "token": token, "client_id": self._client_id, @@ -81,7 +132,6 @@ class KeycloakAuthClient: if not result.get("active", False): return TokenInfo(active=False, raw_claims=result) - # Extract roles from realm_access or resource_access roles: list[str] = [] realm_access = result.get("realm_access", {}) if isinstance(realm_access, dict): @@ -96,13 +146,21 @@ class KeycloakAuthClient: raw_claims=result, ) - # Cache valid token self._cache_token(token, token_info) return token_info async def get_userinfo(self, token: str) -> KeycloakUser | None: - """Get user information from Keycloak using access token.""" + """Get user information from Keycloak using access token. + + Fetches detailed user profile from Keycloak userinfo endpoint. + + Args: + token: Valid access token. + + Returns: + KeycloakUser with profile data, or None on error. + """ try: async with httpx.AsyncClient() as client: response = await client.get( diff --git a/app/infrastructure/auth/models.py b/app/infrastructure/auth/models.py index ccde351..156e53b 100644 --- a/app/infrastructure/auth/models.py +++ b/app/infrastructure/auth/models.py @@ -1,4 +1,8 @@ -"""Keycloak authentication models.""" +"""Keycloak authentication models. + +This module defines data models for Keycloak authentication data +including token info and user profiles. +""" from dataclasses import dataclass, field from typing import Any @@ -6,7 +10,24 @@ from typing import Any @dataclass(frozen=True) class TokenInfo: - """Information about validated token from Keycloak.""" + """Information about validated token from Keycloak. + + Contains the result of token introspection including user claims + and role information. + + Attributes: + active: Whether the token is active and valid. + user_id: Subject identifier from token. + username: Username from token claims. + email: Email from token claims. + roles: List of roles from token. + raw_claims: Complete raw claims from token. + + Example: + >>> token_info = TokenInfo(active=True, user_id="123", roles=["user"]) + >>> if token_info.is_valid: + ... grant_access() + """ active: bool user_id: str = "" @@ -17,13 +38,32 @@ class TokenInfo: @property def is_valid(self) -> bool: - """Check if token is valid and active.""" + """Check if token is valid and active. + + Returns: + True if token is active and has user_id. + """ return self.active and bool(self.user_id) @dataclass(frozen=True) class KeycloakUser: - """User information from Keycloak.""" + """User information from Keycloak. + + Contains user profile data from Keycloak userinfo endpoint. + + Attributes: + id: User subject identifier. + username: Username. + email: Email address. + first_name: First name. + last_name: Last name. + roles: List of user roles. + is_active: Whether user account is active. + + Example: + >>> user = KeycloakUser(id="123", username="john", email="john@example.com") + """ id: str username: str diff --git a/app/infrastructure/config/__init__.py b/app/infrastructure/config/__init__.py index bc7a6e2..e9d9ca7 100644 --- a/app/infrastructure/config/__init__.py +++ b/app/infrastructure/config/__init__.py @@ -1,4 +1,8 @@ -"""Infrastructure configuration.""" +"""Infrastructure configuration. + +This module re-exports all configuration classes and the global +settings instance for application configuration. +""" from app.infrastructure.config.settings import ( AppConfig, diff --git a/app/infrastructure/config/settings.py b/app/infrastructure/config/settings.py index f7c8e97..766e8a4 100644 --- a/app/infrastructure/config/settings.py +++ b/app/infrastructure/config/settings.py @@ -1,4 +1,8 @@ -"""Application settings with composition pattern.""" +"""Application settings with composition pattern. + +This module defines the application configuration using pydantic-settings. +Provides typed configuration for database, Keycloak, security, and app settings. +""" from enum import Enum from functools import cached_property @@ -8,14 +12,38 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Environment(str, Enum): - """Application environment modes.""" + """Application environment modes. + + Defines the available deployment environments. + Each environment may have different configuration defaults. + + Attributes: + DEV: Development environment with debug features. + PROD: Production environment with strict security. + + Example: + >>> if settings.environment == Environment.PROD: + ... enable_strict_security() + """ DEV = "dev" PROD = "prod" class AppConfig(BaseSettings): - """Application configuration.""" + """Application configuration. + + Contains general application settings like name, host, and port. + + Attributes: + name: Application display name. + debug: Debug mode flag. + host: Server bind host. + port: Server bind port. + + Example: + >>> config = AppConfig(name="My API", port=8000) + """ name: str = "Blog API" debug: bool = False @@ -30,14 +58,27 @@ class AppConfig(BaseSettings): class DBConfig(BaseSettings): - """Database configuration.""" + """Database configuration. + + Contains database connection settings. Supports both SQLite for + development and PostgreSQL for production. + + Attributes: + url: Full database URL (optional, can build from components). + echo: Enable SQL query logging. + host: Database server host. + port: Database server port. + user: Database username. + password: Database password. + name: Database name. + + Example: + >>> db_config = DBConfig(host="localhost", name="blog") + """ - # For dev: sqlite+aiosqlite:///./blog.db - # For prod: postgresql+asyncpg://user:pass@host:port/db url: str | None = None echo: bool = False - # PostgreSQL-specific settings (used in prod) host: str = "localhost" port: int = 5432 user: str = "postgres" @@ -53,7 +94,17 @@ class DBConfig(BaseSettings): @field_validator("url") @classmethod def validate_url(cls, v: str | None) -> str | None: - """Validate database URL if provided.""" + """Validate database URL if provided. + + Args: + v: Database URL string to validate. + + Returns: + Validated URL string. + + Raises: + ValueError: If URL does not start with supported prefix. + """ if v is None: return v if not any(v.startswith(prefix) for prefix in ("sqlite+", "postgresql+")): @@ -62,7 +113,20 @@ class DBConfig(BaseSettings): class KCConfig(BaseSettings): - """Keycloak configuration.""" + """Keycloak configuration. + + Contains Keycloak authentication server settings. + + Attributes: + server_url: Keycloak server base URL. + realm: Keycloak realm name. + client_id: OAuth client identifier. + client_secret: OAuth client secret. + token_cache_ttl: Token cache time-to-live in seconds. + + Example: + >>> kc = KCConfig(server_url="http://localhost:8080", realm="blog") + """ server_url: str = "http://localhost:8080" realm: str = "blog" @@ -71,7 +135,7 @@ class KCConfig(BaseSettings): default="", description="Keycloak client secret - must be set via env in production", ) - token_cache_ttl: int = 60 # seconds + token_cache_ttl: int = 60 model_config = SettingsConfigDict( env_prefix="KC_", @@ -81,12 +145,26 @@ class KCConfig(BaseSettings): @property def is_configured(self) -> bool: - """Check if Keycloak is properly configured.""" + """Check if Keycloak is properly configured. + + Returns: + True if client_secret is set. + """ return bool(self.client_secret) class SecurityConfig(BaseSettings): - """Security configuration.""" + """Security configuration. + + Contains security-related settings for JWT and authentication. + + Attributes: + secret_key: Secret key for JWT signing. + access_token_expire_minutes: Token expiration time in minutes. + + Example: + >>> security = SecurityConfig(secret_key="super-secret-key") + """ secret_key: str = Field( default="", description="Secret key for JWT - must be set via env in production" @@ -101,17 +179,37 @@ class SecurityConfig(BaseSettings): @property def is_configured(self) -> bool: - """Check if security is properly configured.""" + """Check if security is properly configured. + + Returns: + True if secret_key is set. + """ return bool(self.secret_key) class Settings(BaseSettings): - """Application configuration settings with composition.""" + """Application configuration settings with composition. + + Main settings class that composes all sub-configurations. + Validates production settings and provides computed properties. + + Attributes: + environment: Current deployment environment. + app: Application configuration. + db: Database configuration. + kc: Keycloak configuration. + security: Security configuration. + + Raises: + ValueError: If required production settings are missing. + + Example: + >>> settings = Settings() + >>> print(settings.database_url) + """ - # Environment mode environment: Environment = Environment.DEV - # Sub-configurations app: AppConfig = Field(default_factory=AppConfig) db: DBConfig = Field(default_factory=DBConfig) kc: KCConfig = Field(default_factory=KCConfig) @@ -125,7 +223,13 @@ class Settings(BaseSettings): ) def model_post_init(self, __context: object) -> None: - """Validate settings after initialization.""" + """Validate settings after initialization. + + Checks that required settings are configured for production mode. + + Raises: + ValueError: If required production settings are missing. + """ if self.is_prod: if not self.security.is_configured: raise ValueError("SECURITY_SECRET_KEY must be set in production mode") @@ -136,14 +240,16 @@ class Settings(BaseSettings): def database_url(self) -> str: """Get database URL based on environment. - - In dev: uses SQLite if no URL provided - - In prod: uses PostgreSQL if no URL provided + Returns configured URL or builds one from components. + Uses SQLite for development, PostgreSQL for production. + + Returns: + Complete database URL string. """ if self.db.url: return self.db.url if self.environment == Environment.PROD: - # Build PostgreSQL URL from components return str( PostgresDsn.build( scheme="postgresql+asyncpg", @@ -155,19 +261,25 @@ class Settings(BaseSettings): ) ) - # Default dev SQLite URL return "sqlite+aiosqlite:///./blog.db" @property def is_dev(self) -> bool: - """Check if running in development mode.""" + """Check if running in development mode. + + Returns: + True if environment is DEV. + """ return self.environment == Environment.DEV @property def is_prod(self) -> bool: - """Check if running in production mode.""" + """Check if running in production mode. + + Returns: + True if environment is PROD. + """ return self.environment == Environment.PROD -# Global settings instance settings = Settings() diff --git a/app/infrastructure/database/__init__.py b/app/infrastructure/database/__init__.py index ab9829a..6d5ac4a 100644 --- a/app/infrastructure/database/__init__.py +++ b/app/infrastructure/database/__init__.py @@ -1,4 +1,8 @@ -"""Database infrastructure.""" +"""Database infrastructure. + +This module re-exports database connection utilities and ORM models +for data persistence. +""" from app.infrastructure.database.connection import ( AsyncSessionLocal, diff --git a/app/infrastructure/database/connection.py b/app/infrastructure/database/connection.py index c9c2a7b..ea2ce92 100644 --- a/app/infrastructure/database/connection.py +++ b/app/infrastructure/database/connection.py @@ -1,4 +1,8 @@ -"""Database connection and session management.""" +"""Database connection and session management. + +This module handles database engine creation, session management, +and connection lifecycle for the application. +""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -13,22 +17,26 @@ from sqlalchemy.ext.asyncio import ( from app.infrastructure.config import settings -# Convert SQLite URL to async format if needed def _get_database_url() -> str: + """Get database URL with SQLite async compatibility. + + Converts SQLite URL to async format if needed. + + Returns: + Database URL string ready for async engine. + """ url = settings.database_url if url.startswith("sqlite:///") and not url.startswith("sqlite+aiosqlite:///"): return url.replace("sqlite:///", "sqlite+aiosqlite:///") return url -# Create async engine engine: AsyncEngine = create_async_engine( _get_database_url(), echo=settings.db.echo, future=True, ) -# Create session factory AsyncSessionLocal = async_sessionmaker( engine, class_=AsyncSession, @@ -39,7 +47,11 @@ AsyncSessionLocal = async_sessionmaker( async def get_session() -> AsyncGenerator[AsyncSession]: - """Get database session.""" + """Get database session. + + Yields: + AsyncSession instance for database operations. + """ async with AsyncSessionLocal() as session: try: yield session @@ -49,7 +61,11 @@ async def get_session() -> AsyncGenerator[AsyncSession]: @asynccontextmanager async def get_session_context() -> AsyncGenerator[AsyncSession]: - """Get database session as context manager.""" + """Get database session as context manager. + + Yields: + AsyncSession instance for database operations. + """ async with AsyncSessionLocal() as session: try: yield session @@ -58,7 +74,11 @@ async def get_session_context() -> AsyncGenerator[AsyncSession]: async def init_db() -> None: - """Initialize database tables.""" + """Initialize database tables. + + Creates all tables defined in the metadata. + Should be called on application startup. + """ from app.infrastructure.database.models import Base async with engine.begin() as conn: @@ -66,5 +86,9 @@ async def init_db() -> None: async def close_db() -> None: - """Close database connections.""" + """Close database connections. + + Disposes of the engine and all connections. + Should be called on application shutdown. + """ await engine.dispose() diff --git a/app/infrastructure/database/models.py b/app/infrastructure/database/models.py index fbbcb0d..8022df5 100644 --- a/app/infrastructure/database/models.py +++ b/app/infrastructure/database/models.py @@ -1,4 +1,8 @@ -"""SQLAlchemy ORM models.""" +"""SQLAlchemy ORM models. + +This module defines the database ORM models that map to database tables. +Models are used by repositories for data persistence. +""" from datetime import UTC, datetime from uuid import uuid4 @@ -10,7 +14,25 @@ Base = declarative_base() class PostORM(Base): # type: ignore[valid-type,misc] - """SQLAlchemy model for Blog Post.""" + """SQLAlchemy model for Blog Post. + + Database table representation of blog posts. + Maps to the 'posts' table with all post attributes. + + Attributes: + id: Primary key as UUID string. + title: Post title (max 200 chars). + content: Post content (text). + slug: URL-friendly unique identifier. + author_id: Author reference. + published: Publication status flag. + tags: JSON array of tags. + created_at: Creation timestamp. + updated_at: Last update timestamp. + + Example: + >>> post = PostORM(title="Post", content="...", slug="post", author_id="user-1") + """ __tablename__ = "posts" diff --git a/app/infrastructure/di/__init__.py b/app/infrastructure/di/__init__.py index 66b150f..b2df068 100644 --- a/app/infrastructure/di/__init__.py +++ b/app/infrastructure/di/__init__.py @@ -1,4 +1,8 @@ -"""Dependency Injection using Dishka.""" +"""Dependency Injection using Dishka. + +This module provides DI container setup and configuration +for the application using Dishka library. +""" from app.infrastructure.di.container import create_container diff --git a/app/infrastructure/di/providers.py b/app/infrastructure/di/providers.py index bf5739c..0844e51 100644 --- a/app/infrastructure/di/providers.py +++ b/app/infrastructure/di/providers.py @@ -1,4 +1,8 @@ -"""Dishka providers for dependency injection.""" +"""Dishka providers for dependency injection. + +This module defines Dishka providers for all application dependencies. +Providers configure how dependencies are created and scoped. +""" from collections.abc import AsyncGenerator @@ -22,16 +26,31 @@ from app.infrastructure.repositories.post import SQLAlchemyPostRepository class DatabaseProvider(Provider): - """Provider for database-related dependencies.""" + """Provider for database-related dependencies. + + Provides database engine and session scoped appropriately. + Engine is application-scoped, sessions are request-scoped. + + Example: + >>> provider = DatabaseProvider() + """ @provide(scope=Scope.APP) def get_engine(self) -> AsyncEngine: - """Provide SQLAlchemy engine.""" + """Provide SQLAlchemy engine. + + Returns: + AsyncEngine instance for database operations. + """ return engine @provide(scope=Scope.REQUEST) async def get_session(self) -> AsyncGenerator[AsyncSession]: - """Provide database session per request.""" + """Provide database session per request. + + Yields: + AsyncSession instance for the request lifetime. + """ async with AsyncSessionLocal() as session: try: yield session @@ -40,27 +59,62 @@ class DatabaseProvider(Provider): class RepositoryProvider(Provider): - """Provider for repository implementations.""" + """Provider for repository implementations. + + Provides concrete repository implementations for interfaces. + All repositories are request-scoped. + + Example: + >>> provider = RepositoryProvider() + """ @provide(scope=Scope.REQUEST) def get_post_repository(self, session: AsyncSession) -> PostRepository: - """Provide PostRepository implementation.""" + """Provide PostRepository implementation. + + Args: + session: Database session from DI container. + + Returns: + SQLAlchemyPostRepository instance. + """ return SQLAlchemyPostRepository(session) class TransactionManagerProvider(Provider): - """Provider for transaction manager.""" + """Provider for transaction manager. + + Provides transaction manager implementation for use cases. + Scoped per request for transaction isolation. + + Example: + >>> provider = TransactionManagerProvider() + """ @provide(scope=Scope.REQUEST) def get_transaction_manager(self, session: AsyncSession) -> TransactionManager: - """Provide TransactionManager implementation.""" + """Provide TransactionManager implementation. + + Args: + session: Database session from DI container. + + Returns: + SessionTransactionManager instance. + """ from app.infrastructure.di.transaction_manager import SessionTransactionManager return SessionTransactionManager(session) class UseCaseProvider(Provider): - """Provider for use cases.""" + """Provider for use cases. + + Provides all application use cases with their dependencies. + All use cases are request-scoped for transaction isolation. + + Example: + >>> provider = UseCaseProvider() + """ @provide(scope=Scope.REQUEST) def get_create_post_use_case( @@ -68,7 +122,15 @@ class UseCaseProvider(Provider): post_repo: PostRepository, tx_manager: TransactionManager, ) -> CreatePostUseCase: - """Provide CreatePostUseCase.""" + """Provide CreatePostUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured CreatePostUseCase instance. + """ return CreatePostUseCase( post_repo=post_repo, tx_manager=tx_manager, @@ -80,7 +142,15 @@ class UseCaseProvider(Provider): post_repo: PostRepository, tx_manager: TransactionManager, ) -> GetPostUseCase: - """Provide GetPostUseCase.""" + """Provide GetPostUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured GetPostUseCase instance. + """ return GetPostUseCase( post_repo=post_repo, tx_manager=tx_manager, @@ -92,7 +162,15 @@ class UseCaseProvider(Provider): post_repo: PostRepository, tx_manager: TransactionManager, ) -> UpdatePostUseCase: - """Provide UpdatePostUseCase.""" + """Provide UpdatePostUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured UpdatePostUseCase instance. + """ return UpdatePostUseCase( post_repo=post_repo, tx_manager=tx_manager, @@ -104,7 +182,15 @@ class UseCaseProvider(Provider): post_repo: PostRepository, tx_manager: TransactionManager, ) -> DeletePostUseCase: - """Provide DeletePostUseCase.""" + """Provide DeletePostUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured DeletePostUseCase instance. + """ return DeletePostUseCase( post_repo=post_repo, tx_manager=tx_manager, @@ -116,7 +202,15 @@ class UseCaseProvider(Provider): post_repo: PostRepository, tx_manager: TransactionManager, ) -> ListPostsUseCase: - """Provide ListPostsUseCase.""" + """Provide ListPostsUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured ListPostsUseCase instance. + """ return ListPostsUseCase( post_repo=post_repo, tx_manager=tx_manager, @@ -128,7 +222,15 @@ class UseCaseProvider(Provider): post_repo: PostRepository, tx_manager: TransactionManager, ) -> PublishPostUseCase: - """Provide PublishPostUseCase.""" + """Provide PublishPostUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured PublishPostUseCase instance. + """ return PublishPostUseCase( post_repo=post_repo, tx_manager=tx_manager, @@ -136,9 +238,20 @@ class UseCaseProvider(Provider): class KeycloakProvider(Provider): - """Provider for Keycloak authentication client.""" + """Provider for Keycloak authentication client. + + Provides Keycloak client as application-scoped singleton. + Client is stateless and can be shared across requests. + + Example: + >>> provider = KeycloakProvider() + """ @provide(scope=Scope.APP) def get_keycloak_client(self) -> KeycloakAuthClient: - """Provide KeycloakAuthClient singleton.""" + """Provide KeycloakAuthClient singleton. + + Returns: + KeycloakAuthClient instance. + """ return KeycloakAuthClient(settings) diff --git a/app/infrastructure/di/transaction_manager.py b/app/infrastructure/di/transaction_manager.py index e6c5a66..e437c18 100644 --- a/app/infrastructure/di/transaction_manager.py +++ b/app/infrastructure/di/transaction_manager.py @@ -1,4 +1,8 @@ -"""SQLAlchemy implementation of Transaction Manager.""" +"""SQLAlchemy implementation of Transaction Manager. + +This module provides the concrete implementation of TransactionManager +using SQLAlchemy async sessions for transaction control. +""" from sqlalchemy.ext.asyncio import AsyncSession @@ -6,19 +10,44 @@ from app.application.interfaces import TransactionManager class SessionTransactionManager(TransactionManager): - """SQLAlchemy Session-based Transaction Manager.""" + """SQLAlchemy Session-based Transaction Manager. + + Implements transaction control using SQLAlchemy async session. + Tracks commit state to prevent duplicate commits. + + Attributes: + _session: SQLAlchemy async session for transactions. + _committed: Flag indicating if transaction was committed. + + Example: + >>> tx_manager = SessionTransactionManager(session) + >>> await tx_manager.commit() + """ def __init__(self, session: AsyncSession) -> None: + """Initialize transaction manager. + + Args: + session: SQLAlchemy async session instance. + """ self._session = session self._committed: bool = False async def commit(self) -> None: - """Commit the current transaction.""" + """Commit the current transaction. + + Persists all pending changes to the database. + Only commits once - subsequent calls are no-ops. + """ if not self._committed: await self._session.commit() self._committed = True async def rollback(self) -> None: - """Rollback the current transaction.""" + """Rollback the current transaction. + + Discards all pending changes. + Only rolls back if not already committed. + """ if not self._committed: await self._session.rollback() diff --git a/app/infrastructure/middleware/__init__.py b/app/infrastructure/middleware/__init__.py index c46dc01..f7fc721 100644 --- a/app/infrastructure/middleware/__init__.py +++ b/app/infrastructure/middleware/__init__.py @@ -1,4 +1,8 @@ -"""Infrastructure middleware.""" +"""Infrastructure middleware. + +This module re-exports exception handling middleware for +centralized error management in the application. +""" from app.infrastructure.middleware.error_handler import ( domain_exception_handler, diff --git a/app/infrastructure/middleware/error_handler.py b/app/infrastructure/middleware/error_handler.py index c039389..247b24a 100644 --- a/app/infrastructure/middleware/error_handler.py +++ b/app/infrastructure/middleware/error_handler.py @@ -1,4 +1,8 @@ -"""Exception handling middleware.""" +"""Exception handling middleware. + +This module provides exception handlers for FastAPI application. +Maps domain exceptions to appropriate HTTP status codes. +""" from datetime import UTC, datetime @@ -17,7 +21,14 @@ from app.domain.exceptions import ( def get_status_code(exc: DomainException) -> int: - """Map domain exceptions to HTTP status codes.""" + """Map domain exceptions to HTTP status codes. + + Args: + exc: Domain exception instance. + + Returns: + HTTP status code for the exception type. + """ match exc: case ValidationException(): return 400 @@ -34,7 +45,17 @@ def get_status_code(exc: DomainException) -> int: async def domain_exception_handler(request: Request, exc: DomainException) -> JSONResponse: - """Handle domain exceptions.""" + """Handle domain exceptions. + + Converts domain exceptions to JSON error responses. + + Args: + request: FastAPI request object. + exc: Domain exception instance. + + Returns: + JSONResponse with error details. + """ status_code = get_status_code(exc) return JSONResponse( status_code=status_code, @@ -48,7 +69,17 @@ async def domain_exception_handler(request: Request, exc: DomainException) -> JS async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: - """Handle HTTP exceptions.""" + """Handle HTTP exceptions. + + Converts Starlette HTTP exceptions to JSON error responses. + + Args: + request: FastAPI request object. + exc: Starlette HTTP exception instance. + + Returns: + JSONResponse with error details. + """ return JSONResponse( status_code=exc.status_code, content={ @@ -61,7 +92,18 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException) async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: - """Handle generic exceptions.""" + """Handle generic exceptions. + + Converts unhandled exceptions to generic error responses. + Hides internal details for security. + + Args: + request: FastAPI request object. + exc: Generic exception instance. + + Returns: + JSONResponse with generic error message. + """ return JSONResponse( status_code=500, content={ @@ -74,16 +116,17 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes def register_exception_handlers(app: FastAPI) -> None: - """Register all exception handlers with FastAPI app.""" + """Register all exception handlers with FastAPI app. + + Args: + app: FastAPI application instance. + + Raises: + TypeError: If app is not a FastAPI instance. + """ if not isinstance(app, FastAPI): raise TypeError("app must be a FastAPI instance") - # Domain exceptions app.add_exception_handler(DomainException, domain_exception_handler) # type: ignore[arg-type] - - # HTTP exceptions app.add_exception_handler(StarletteHTTPException, http_exception_handler) # type: ignore[arg-type] - - # Generic exceptions (only in production) - # In development, let FastAPI show detailed traceback - # app.add_exception_handler(Exception, generic_exception_handler) + app.add_exception_handler(Exception, generic_exception_handler) diff --git a/app/infrastructure/repositories/__init__.py b/app/infrastructure/repositories/__init__.py index cad32e9..2bde8ab 100644 --- a/app/infrastructure/repositories/__init__.py +++ b/app/infrastructure/repositories/__init__.py @@ -1,4 +1,8 @@ -"""Repository implementations.""" +"""Repository implementations. + +This module re-exports concrete repository implementations +for data access using SQLAlchemy ORM. +""" from app.infrastructure.repositories.post import SQLAlchemyPostRepository diff --git a/app/infrastructure/repositories/post.py b/app/infrastructure/repositories/post.py index d120994..8e86ef6 100644 --- a/app/infrastructure/repositories/post.py +++ b/app/infrastructure/repositories/post.py @@ -1,4 +1,8 @@ -"""SQLAlchemy implementation of PostRepository.""" +"""SQLAlchemy implementation of PostRepository. + +This module provides the concrete implementation of PostRepository +using SQLAlchemy ORM for data persistence. +""" from uuid import UUID @@ -12,13 +16,36 @@ from app.infrastructure.database.models import PostORM class SQLAlchemyPostRepository(PostRepository): - """SQLAlchemy implementation of Post repository.""" + """SQLAlchemy implementation of Post repository. + + Provides data access methods for Post entities using SQLAlchemy ORM. + Handles conversion between domain entities and ORM models. + + Attributes: + _session: SQLAlchemy async session for database operations. + + Example: + >>> repo = SQLAlchemyPostRepository(session) + >>> post = await repo.get_by_id(post_id) + """ def __init__(self, session: AsyncSession) -> None: + """Initialize repository with session. + + Args: + session: SQLAlchemy async session instance. + """ self._session = session def _to_domain(self, orm: PostORM) -> Post: - """Convert ORM model to domain entity.""" + """Convert ORM model to domain entity. + + Args: + orm: SQLAlchemy ORM model instance. + + Returns: + Domain Post entity with validated value objects. + """ return Post( id=UUID(orm.id), title=Title(orm.title), @@ -32,7 +59,14 @@ class SQLAlchemyPostRepository(PostRepository): ) def _to_orm(self, post: Post) -> PostORM: - """Convert domain entity to ORM model.""" + """Convert domain entity to ORM model. + + Args: + post: Domain Post entity. + + Returns: + SQLAlchemy ORM model instance. + """ return PostORM( id=str(post.id), title=post.title.value, @@ -46,25 +80,43 @@ class SQLAlchemyPostRepository(PostRepository): ) async def get_by_id(self, entity_id: UUID) -> Post | None: - """Get post by ID.""" + """Get post by ID. + + Args: + entity_id: Unique identifier of the post. + + Returns: + Post entity if found, None otherwise. + """ result = await self._session.execute(select(PostORM).where(PostORM.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[Post]: - """Get all posts.""" + """Get all posts. + + Returns: + List of all Post entities. + """ result = await self._session.execute(select(PostORM)) orms = result.scalars().all() return [self._to_domain(orm) for orm in orms] async def add(self, entity: Post) -> None: - """Add new post.""" + """Add new post. + + Args: + entity: Post entity to add. + """ orm = self._to_orm(entity) self._session.add(orm) - # Commit делает TransactionManager async def update(self, entity: Post) -> None: - """Update existing post.""" + """Update existing post. + + Args: + entity: Post entity with updated data. + """ result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity.id))) orm = result.scalar_one() @@ -75,22 +127,38 @@ class SQLAlchemyPostRepository(PostRepository): orm.tags = entity.tags orm.updated_at = entity.updated_at - # Commit делает TransactionManager - async def delete(self, entity_id: UUID) -> None: - """Delete post by ID.""" + """Delete post by ID. + + Args: + entity_id: Unique identifier of the post to delete. + """ result = await self._session.execute(select(PostORM).where(PostORM.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 post exists.""" + """Check if post exists. + + Args: + entity_id: Unique identifier of the post. + + Returns: + True if post exists, False otherwise. + """ result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id))) return result.scalar_one_or_none() is not None async def get_by_slug(self, slug: str) -> Post | None: - """Get post by slug.""" + """Get post by slug. + + Args: + slug: URL-friendly slug identifier. + + Returns: + Post entity if found, None otherwise. + """ result = await self._session.execute(select(PostORM).where(PostORM.slug == slug)) orm = result.scalar_one_or_none() return self._to_domain(orm) if orm else None @@ -101,7 +169,16 @@ class SQLAlchemyPostRepository(PostRepository): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Get posts by author.""" + """Get posts by author. + + Args: + author_id: Identifier of the author. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of Post entities by the author. + """ query = select(PostORM).where(PostORM.author_id == author_id) if limit is not None: query = query.limit(limit) @@ -116,7 +193,15 @@ class SQLAlchemyPostRepository(PostRepository): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Get published posts.""" + """Get published posts. + + Args: + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of published Post entities. + """ query = select(PostORM).where(PostORM.published.is_(True)) if limit is not None: query = query.limit(limit) @@ -132,7 +217,16 @@ class SQLAlchemyPostRepository(PostRepository): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Get posts by tag.""" + """Get posts by tag. + + Args: + tag: Tag to filter by. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of Post entities with the tag. + """ query = select(PostORM).where(PostORM.tags.contains([tag])) if limit is not None: query = query.limit(limit) @@ -143,7 +237,14 @@ class SQLAlchemyPostRepository(PostRepository): return [self._to_domain(orm) for orm in orms] async def slug_exists(self, slug: str) -> bool: - """Check if slug exists.""" + """Check if slug exists. + + Args: + slug: Slug to check for existence. + + Returns: + True if slug exists, False otherwise. + """ result = await self._session.execute(select(PostORM).where(PostORM.slug == slug)) return result.scalar_one_or_none() is not None @@ -153,7 +254,16 @@ class SQLAlchemyPostRepository(PostRepository): limit: int | None = None, offset: int | None = None, ) -> list[Post]: - """Search posts.""" + """Search posts. + + Args: + query: Search query string. + limit: Maximum number of posts to return. + offset: Number of posts to skip. + + Returns: + List of Post entities matching the query. + """ search_pattern = f"%{query}%" stmt = select(PostORM).where( or_( diff --git a/app/main.py b/app/main.py index a18a178..be370d7 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,19 @@ -"""Application entry point with DDD architecture.""" +"""Application entry point with DDD architecture. -from collections.abc import AsyncGenerator +This module is the main entry point for the FastAPI application. +Configures DI container, middleware, and routes following DDD principles. +""" + +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import uvicorn from dishka import make_async_container from dishka.integrations.fastapi import setup_dishka -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles from app.infrastructure import close_db, init_db, register_exception_handlers, settings from app.infrastructure.di.providers import ( @@ -18,20 +24,37 @@ from app.infrastructure.di.providers import ( UseCaseProvider, ) from app.presentation import router +from app.presentation.web import auth_router +from app.presentation.web import router as web_router +from app.presentation.web.error_handlers import register_error_handlers +from app.presentation.web.flash import setup_flash_manager @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: - """Application lifespan manager.""" - # Startup + """Application lifespan manager. + + Handles startup and shutdown tasks for the application. + + Args: + app: FastAPI application instance. + + Yields: + None during application runtime. + """ await init_db() yield - # Shutdown await close_db() def app_factory() -> FastAPI: - """Create and configure FastAPI application.""" + """Create and configure FastAPI application. + + Sets up DI container, exception handlers, middleware, and routes. + + Returns: + Configured FastAPI application instance. + """ app = FastAPI( title=settings.app.name, debug=settings.app.debug, @@ -40,7 +63,6 @@ def app_factory() -> FastAPI: redoc_url="/redoc" if settings.is_dev else None, ) - # Setup Dishka DI container container = make_async_container( DatabaseProvider(), RepositoryProvider(), @@ -50,10 +72,18 @@ def app_factory() -> FastAPI: ) setup_dishka(container, app) - # Register exception handlers register_exception_handlers(app) + register_error_handlers(app) + + @app.middleware("http") + async def flash_middleware( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + """Middleware to setup flash manager for each request.""" + await setup_flash_manager(request) + response = await call_next(request) + return response - # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -62,12 +92,29 @@ def app_factory() -> FastAPI: allow_headers=["*"], ) - # Include API routes app.include_router(router, prefix="/api") + app.include_router(web_router) + app.include_router(auth_router) + app.mount("/static", StaticFiles(directory="static"), name="static") + + @app.get("/", response_class=HTMLResponse) + async def root_redirect() -> HTMLResponse: + """Redirect root URL to web UI. + + Returns: + HTMLResponse with redirect to web interface. + """ + return HTMLResponse( + content='', status_code=200 + ) - # Health check endpoint @app.get("/health", tags=["health"]) async def health_check() -> dict[str, str]: + """Health check endpoint. + + Returns: + Status information dictionary. + """ return { "status": "ok", "app": settings.app.name, @@ -78,7 +125,10 @@ def app_factory() -> FastAPI: def main() -> None: - """Run the application.""" + """Run the application. + + Starts uvicorn server with application factory. + """ uvicorn.run( app_factory, factory=True, diff --git a/app/presentation/__init__.py b/app/presentation/__init__.py index d18c9cf..1854978 100644 --- a/app/presentation/__init__.py +++ b/app/presentation/__init__.py @@ -1,4 +1,8 @@ -"""Presentation layer exports.""" +"""Presentation layer exports. + +This module re-exports presentation layer components including +API router and Pydantic schemas. +""" from app.presentation.api import router from app.presentation.schemas import ( diff --git a/app/presentation/api/__init__.py b/app/presentation/api/__init__.py index d3109b0..7f2c7e7 100644 --- a/app/presentation/api/__init__.py +++ b/app/presentation/api/__init__.py @@ -1,4 +1,8 @@ -"""API router configuration.""" +"""API router configuration. + +This module sets up the main API router and includes versioned +sub-routers for API organization. +""" from fastapi import APIRouter diff --git a/app/presentation/api/deps.py b/app/presentation/api/deps.py index 71f55d1..12bad06 100644 --- a/app/presentation/api/deps.py +++ b/app/presentation/api/deps.py @@ -1,4 +1,8 @@ -"""API dependencies using Dishka.""" +"""API dependencies using Dishka. + +This module defines FastAPI dependencies for authentication, authorization, +and use case injection using Dishka DI container. +""" from typing import Annotated, Any @@ -18,7 +22,6 @@ from app.domain.exceptions import ForbiddenException, UnauthorizedException from app.domain.roles import Role, get_effective_role from app.infrastructure.auth import KeycloakAuthClient, TokenInfo -# Use case dependencies - injected via Dishka CreatePostDep = FromDishka[CreatePostUseCase] GetPostDep = FromDishka[GetPostUseCase] UpdatePostDep = FromDishka[UpdatePostUseCase] @@ -26,12 +29,18 @@ DeletePostDep = FromDishka[DeletePostUseCase] ListPostsDep = FromDishka[ListPostsUseCase] PublishPostDep = FromDishka[PublishPostUseCase] -# Security scheme security = HTTPBearer(auto_error=False) def get_keycloak_client(request: Request) -> KeycloakAuthClient: - """Get Keycloak client from DI container via request state.""" + """Get Keycloak client from DI container via request state. + + Args: + request: FastAPI request object. + + Returns: + KeycloakAuthClient instance from container. + """ client: KeycloakAuthClient = request.state.dishka_container.get(KeycloakAuthClient) return client @@ -40,7 +49,18 @@ async def get_current_token_info( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], request: Request, ) -> TokenInfo: - """Validate token and return token info from Keycloak.""" + """Validate token and return token info from Keycloak. + + Args: + credentials: HTTP authorization credentials. + request: FastAPI request object. + + Returns: + Validated TokenInfo instance. + + Raises: + UnauthorizedException: If no credentials or invalid token. + """ if not credentials: raise UnauthorizedException("Authentication required") @@ -57,7 +77,14 @@ async def get_current_token_info( async def get_current_user_id( token_info: Annotated[TokenInfo, Depends(get_current_token_info)], ) -> str: - """Get current user ID from validated token.""" + """Get current user ID from validated token. + + Args: + token_info: Validated token info. + + Returns: + User ID string from token. + """ return token_info.user_id @@ -65,12 +92,21 @@ CurrentUserDep = Annotated[str, Depends(get_current_user_id)] TokenInfoDep = Annotated[TokenInfo, Depends(get_current_token_info)] -# Optional auth - doesn't require authentication but provides user info if available async def get_optional_token_info( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], request: Request, ) -> TokenInfo | None: - """Get token info if valid token provided, otherwise None (guest).""" + """Get token info if valid token provided, otherwise None. + + For endpoints that support both authenticated and guest access. + + Args: + credentials: HTTP authorization credentials. + request: FastAPI request object. + + Returns: + TokenInfo if valid, None otherwise. + """ if not credentials: return None @@ -90,7 +126,14 @@ OptionalTokenInfoDep = Annotated[TokenInfo | None, Depends(get_optional_token_in async def get_optional_user_id( token_info: OptionalTokenInfoDep, ) -> str | None: - """Get current user ID if token is valid, otherwise None.""" + """Get current user ID if token is valid, otherwise None. + + Args: + token_info: Optional token info. + + Returns: + User ID if authenticated, None for guests. + """ if token_info: return token_info.user_id return None @@ -103,6 +146,12 @@ def get_current_role(token_info: OptionalTokenInfoDep) -> Role: """Get effective role from token info. Returns GUEST if no valid token provided. + + Args: + token_info: Optional token info. + + Returns: + Effective Role enum value. """ if token_info and token_info.roles: return get_effective_role(token_info.roles) @@ -113,7 +162,17 @@ CurrentRoleDep = Annotated[Role, Depends(get_current_role)] def require_roles(allowed_roles: list[Role]) -> Any: - """Create dependency that checks if user has one of the allowed roles.""" + """Create dependency that checks if user has one of the allowed roles. + + Args: + allowed_roles: List of roles allowed to access. + + Returns: + FastAPI Depends for role checking. + + Raises: + ForbiddenException: If user role is not in allowed list. + """ async def check_role(role: CurrentRoleDep) -> Role: if role not in allowed_roles: @@ -125,7 +184,6 @@ def require_roles(allowed_roles: list[Role]) -> Any: return Depends(check_role) -# Predefined role requirements RequireAdmin = require_roles([Role.ADMIN]) RequireUser = require_roles([Role.USER, Role.ADMIN]) RequireAny = require_roles([Role.GUEST, Role.USER, Role.ADMIN]) diff --git a/app/presentation/api/v1/__init__.py b/app/presentation/api/v1/__init__.py index 10c5384..87ffa60 100644 --- a/app/presentation/api/v1/__init__.py +++ b/app/presentation/api/v1/__init__.py @@ -1,4 +1,8 @@ -"""API v1 router.""" +"""API v1 router. + +This module sets up the version 1 API router and includes +all v1 endpoint routers. +""" from fastapi import APIRouter diff --git a/app/presentation/api/v1/posts.py b/app/presentation/api/v1/posts.py index 5bf5aca..01dd63c 100644 --- a/app/presentation/api/v1/posts.py +++ b/app/presentation/api/v1/posts.py @@ -1,4 +1,8 @@ -"""Posts API routes.""" +"""Posts API routes. + +This module defines FastAPI routes for blog post operations. +Implements CRUD endpoints with authentication and authorization. +""" from uuid import UUID @@ -39,7 +43,16 @@ async def create_post( use_case: CreatePostDep, current_user_id: CurrentUserDep, ) -> PostResponseSchema: - """Create a new blog post.""" + """Create a new blog post. + + Args: + schema: Post creation data. + use_case: CreatePostUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + PostResponseSchema with created post data. + """ dto = CreatePostDTO( title=schema.title, content=schema.content, @@ -65,19 +78,22 @@ async def list_posts( """Get blog posts with optional filtering and pagination. Args: + use_case: ListPostsUseCase dependency. + role: Current user role. include_unpublished: If True, returns all posts including drafts. - Only admins can use this parameter. + Only admins can use this parameter. limit: Maximum number of posts to return (default: 10, max: 100). offset: Number of posts to skip (default: 0). + Returns: + PostListResponseSchema with paginated posts. + Raises: ForbiddenException: If non-admin tries to include unpublished posts. """ - # Clamp limit to reasonable range limit = max(1, min(limit, 100)) offset = max(0, offset) - # Check permissions for unpublished posts if include_unpublished: if not has_permission(role, Permission.POST_READ_UNPUBLISHED): raise ForbiddenException("Only admins can view unpublished posts") @@ -97,7 +113,14 @@ async def list_posts( async def list_published_posts( use_case: ListPostsDep, ) -> PostListResponseSchema: - """Get all published blog posts.""" + """Get all published blog posts. + + Args: + use_case: ListPostsUseCase dependency. + + Returns: + PostListResponseSchema with published posts. + """ results = await use_case.published_posts() items = [PostResponseSchema(**r.__dict__) for r in results] return PostListResponseSchema(items=items, total=len(items)) @@ -112,7 +135,15 @@ async def search_posts( query: str, use_case: ListPostsDep, ) -> PostListResponseSchema: - """Search posts by query.""" + """Search posts by query. + + Args: + query: Search query string. + use_case: ListPostsUseCase dependency. + + Returns: + PostListResponseSchema with matching posts. + """ results = await use_case.search(query) items = [PostResponseSchema(**r.__dict__) for r in results] return PostListResponseSchema(items=items, total=len(items)) @@ -127,7 +158,15 @@ async def get_posts_by_tag( tag: str, use_case: ListPostsDep, ) -> PostListResponseSchema: - """Get posts by tag.""" + """Get posts by tag. + + Args: + tag: Tag to filter by. + use_case: ListPostsUseCase dependency. + + Returns: + PostListResponseSchema with tagged posts. + """ results = await use_case.by_tag(tag) items = [PostResponseSchema(**r.__dict__) for r in results] return PostListResponseSchema(items=items, total=len(items)) @@ -142,7 +181,15 @@ async def get_posts_by_author( author_id: str, use_case: ListPostsDep, ) -> PostListResponseSchema: - """Get posts by author.""" + """Get posts by author. + + Args: + author_id: Author identifier. + use_case: ListPostsUseCase dependency. + + Returns: + PostListResponseSchema with author's posts. + """ results = await use_case.by_author(author_id) items = [PostResponseSchema(**r.__dict__) for r in results] return PostListResponseSchema(items=items, total=len(items)) @@ -157,7 +204,15 @@ async def get_post( post_id: UUID, use_case: GetPostDep, ) -> PostResponseSchema: - """Get a post by its ID.""" + """Get a post by its ID. + + Args: + post_id: Unique post identifier. + use_case: GetPostUseCase dependency. + + Returns: + PostResponseSchema with post data. + """ result = await use_case.by_id(post_id) return PostResponseSchema(**result.__dict__) @@ -171,7 +226,15 @@ async def get_post_by_slug( slug: str, use_case: GetPostDep, ) -> PostResponseSchema: - """Get a post by its slug.""" + """Get a post by its slug. + + Args: + slug: URL-friendly slug identifier. + use_case: GetPostUseCase dependency. + + Returns: + PostResponseSchema with post data. + """ result = await use_case.by_slug(slug) return PostResponseSchema(**result.__dict__) @@ -187,7 +250,17 @@ async def update_post( use_case: UpdatePostDep, current_user_id: CurrentUserDep, ) -> PostResponseSchema: - """Update a post.""" + """Update a post. + + Args: + post_id: Unique post identifier. + schema: Update data. + use_case: UpdatePostUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + PostResponseSchema with updated post data. + """ dto = UpdatePostDTO( title=schema.title, content=schema.content, @@ -207,7 +280,13 @@ async def delete_post( use_case: DeletePostDep, current_user_id: CurrentUserDep, ) -> None: - """Delete a post.""" + """Delete a post. + + Args: + post_id: Unique post identifier. + use_case: DeletePostUseCase dependency. + current_user_id: Authenticated user ID. + """ await use_case.execute(post_id, current_user_id) @@ -221,7 +300,16 @@ async def publish_post( use_case: PublishPostDep, current_user_id: CurrentUserDep, ) -> PostResponseSchema: - """Publish a post.""" + """Publish a post. + + Args: + post_id: Unique post identifier. + use_case: PublishPostUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + PostResponseSchema with published post data. + """ result = await use_case.publish(post_id, current_user_id) return PostResponseSchema(**result.__dict__) @@ -236,6 +324,15 @@ async def unpublish_post( use_case: PublishPostDep, current_user_id: CurrentUserDep, ) -> PostResponseSchema: - """Unpublish a post.""" + """Unpublish a post. + + Args: + post_id: Unique post identifier. + use_case: PublishPostUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + PostResponseSchema with unpublished post data. + """ result = await use_case.unpublish(post_id, current_user_id) return PostResponseSchema(**result.__dict__) diff --git a/app/presentation/schemas/__init__.py b/app/presentation/schemas/__init__.py index 25f7a27..90fa5ec 100644 --- a/app/presentation/schemas/__init__.py +++ b/app/presentation/schemas/__init__.py @@ -1,4 +1,8 @@ -"""Presentation schemas.""" +"""Presentation schemas. + +This module re-exports all Pydantic schemas used for +request/response validation in the API layer. +""" from app.presentation.schemas.post import ( PostBaseSchema, diff --git a/app/presentation/schemas/post.py b/app/presentation/schemas/post.py index 8038e56..9966062 100644 --- a/app/presentation/schemas/post.py +++ b/app/presentation/schemas/post.py @@ -1,4 +1,8 @@ -"""API schemas for posts.""" +"""API schemas for posts. + +This module defines Pydantic schemas for request/response validation +in the posts API endpoints. +""" from datetime import datetime from uuid import UUID @@ -7,7 +11,14 @@ from pydantic import BaseModel, ConfigDict, Field class PostBaseSchema(BaseModel): - """Base schema for posts.""" + """Base schema for posts. + + Contains common fields shared across post schemas. + + Attributes: + title: Post title (3-200 characters). + content: Post content (10-50000 characters). + """ model_config = ConfigDict(from_attributes=True) @@ -16,13 +27,27 @@ class PostBaseSchema(BaseModel): class PostCreateSchema(PostBaseSchema): - """Schema for creating a post.""" + """Schema for creating a post. + + Extends base schema with creation-specific fields. + + Attributes: + tags: List of tags for categorization. + """ tags: list[str] = Field(default_factory=list) class PostUpdateSchema(BaseModel): - """Schema for updating a post.""" + """Schema for updating a post. + + All fields are optional for partial updates. + + Attributes: + title: Optional new title. + content: Optional new content. + tags: Optional new tags list. + """ model_config = ConfigDict(from_attributes=True) @@ -32,7 +57,21 @@ class PostUpdateSchema(BaseModel): class PostResponseSchema(BaseModel): - """Schema for post response.""" + """Schema for post response. + + Complete post data for API responses. + + Attributes: + id: Unique post identifier. + title: Post title. + content: Post content. + slug: URL-friendly slug. + author_id: Author identifier. + published: Publication status. + tags: List of tags. + created_at: Creation timestamp. + updated_at: Last update timestamp. + """ model_config = ConfigDict(from_attributes=True) @@ -48,19 +87,38 @@ class PostResponseSchema(BaseModel): class PostListResponseSchema(BaseModel): - """Schema for list of posts response.""" + """Schema for list of posts response. + + Paginated response for list endpoints. + + Attributes: + items: List of post items. + total: Total number of items. + """ items: list[PostResponseSchema] total: int class PostSearchSchema(BaseModel): - """Schema for searching posts.""" + """Schema for searching posts. + + Search query parameters. + + Attributes: + query: Search query string (1-100 characters). + """ query: str = Field(..., min_length=1, max_length=100) class PostPublishSchema(BaseModel): - """Schema for publishing/unpublishing a post.""" + """Schema for publishing/unpublishing a post. + + Publication status toggle. + + Attributes: + published: Desired publication status. + """ published: bool diff --git a/app/presentation/templates/base.html b/app/presentation/templates/base.html new file mode 100644 index 0000000..ce9aaa9 --- /dev/null +++ b/app/presentation/templates/base.html @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% block title %}Blog{% endblock %} + + + + + + + + {% block extra_css %}{% endblock %} + + + {% include "partials/header.html" %} + + + {% if flash_messages %} +
+ {% for msg in flash_messages %} + + {% endfor %} +
+ {% endif %} + +
+
+ {% block content %}{% endblock %} +
+
+ + {% include "partials/footer.html" %} + + + + {% block extra_js %}{% endblock %} + + + + diff --git a/app/presentation/templates/pages/error.html b/app/presentation/templates/pages/error.html new file mode 100644 index 0000000..5454145 --- /dev/null +++ b/app/presentation/templates/pages/error.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} + +{% block title %}{{ error_code }} - {{ error_title }}{% endblock %} +{% block meta_description %}{{ error_message }}{% endblock %} + +{% block content %} +
+
+
+ {% if error_code == 404 %} + 🔍 + {% elif error_code == 403 %} + 🚫 + {% elif error_code == 500 %} + ⚠️ + {% else %} + ❌ + {% endif %} +
+ +

{{ error_code }}

+

{{ error_title }}

+

{{ error_message }}

+ +
+ + + + + Go Home + + + {% if error_code == 403 %} + + Sign In + + {% endif %} + + +
+
+
+ + +{% endblock %} diff --git a/app/presentation/templates/pages/index.html b/app/presentation/templates/pages/index.html new file mode 100644 index 0000000..cbe67cd --- /dev/null +++ b/app/presentation/templates/pages/index.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} + +{% block title %}Blog - Home{% endblock %} +{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %} +{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %} + +{% block og_type %}website{% endblock %} +{% block og_title %}Blog - Home{% endblock %} +{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %} + +{% block twitter_title %}Blog - Home{% endblock %} +{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %} + +{% block content %} + + +{% if posts %} +
+ {% for post in posts %} +
+
+

+ {{ post.title }} +

+ {% if post.published %} + Published + {% else %} + Draft + {% endif %} +
+ +
+ + {{ post.author_id[0]|upper }} + {{ post.author_id }} + + + {{ post.created_at.strftime('%B %d, %Y') }} + +
+ +
+ {{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %} +
+ +
+
+ {% for tag in post.tags %} + {{ tag }} + {% endfor %} +
+ + Read more + + + + +
+
+ {% endfor %} +
+ + + +{% else %} +
+
📝
+

No posts yet

+

Be the first to write a post!

+ Create your first post +
+{% endif %} +{% endblock %} diff --git a/app/presentation/templates/pages/post_detail.html b/app/presentation/templates/pages/post_detail.html new file mode 100644 index 0000000..1b32b7e --- /dev/null +++ b/app/presentation/templates/pages/post_detail.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} + +{% block title %}{{ post.title }} - Blog{% endblock %} +{% block meta_description %}{{ post.content.value[:160] }}{% endblock %} +{% block meta_keywords %}{{ post.tags|join(', ') }}{% endblock %} +{% block meta_author %}{{ post.author_id }}{% endblock %} + +{% block canonical_url %}{{ request.base_url }}web/posts/{{ post.slug.value }}{% endblock %} + +{% block og_type %}article{% endblock %} +{% block og_url %}{{ request.base_url }}web/posts/{{ post.slug.value }}{% endblock %} +{% block og_title %}{{ post.title }}{% endblock %} +{% block og_description %}{{ post.content.value[:160] }}{% endblock %} + +{% block twitter_title %}{{ post.title }}{% endblock %} +{% block twitter_description %}{{ post.content.value[:160] }}{% endblock %} + +{% block content %} +
+
+

{{ post.title }}

+ +
+ + {{ post.author_id[0]|upper }} + {{ post.author_id }} + + + {{ post.created_at.strftime('%B %d, %Y') }} + + {% if post.published %} + Published + {% else %} + Draft + {% endif %} +
+
+ +
+ {{ post.content.value|nl2br }} +
+ +
+
+ {% for tag in post.tags %} + {{ tag }} + {% endfor %} +
+ +
+ +
+ + + + + Back to posts + + + {% if can_edit or can_delete %} +
+ {% if can_edit %} + + + + + Edit + + {% endif %} + {% if can_delete %} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/presentation/templates/pages/post_form.html b/app/presentation/templates/pages/post_form.html new file mode 100644 index 0000000..61d2477 --- /dev/null +++ b/app/presentation/templates/pages/post_form.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %} + +{% block content %} + + +
+
+
+ + + A catchy title for your post +
+ +
+ + + The main content of your post. Markdown is supported. +
+ +
+ + + Comma-separated list of tags +
+ +
+ +
+
+ + +
+{% endblock %} diff --git a/app/presentation/templates/pages/profile.html b/app/presentation/templates/pages/profile.html new file mode 100644 index 0000000..44882ed --- /dev/null +++ b/app/presentation/templates/pages/profile.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block title %}Profile - {{ user.username }}{% endblock %} + +{% block content %} + + +
+
+
+
+ {{ user.username[0]|upper }} +
+
+

{{ user.username }}

+ + {{ user_role|upper }} + +
+
+ +
+ +
+
+ Email: + {{ user.email or 'Not provided' }} +
+ +
+ User ID: + {{ user.user_id }} +
+ + {% if user.first_name or user.last_name %} +
+ Name: + + {{ user.first_name or '' }} {{ user.last_name or '' }} + +
+ {% endif %} +
+
+ + +
+ + +{% endblock %} diff --git a/app/presentation/templates/partials/footer.html b/app/presentation/templates/partials/footer.html new file mode 100644 index 0000000..d62fb3e --- /dev/null +++ b/app/presentation/templates/partials/footer.html @@ -0,0 +1,14 @@ + diff --git a/app/presentation/templates/partials/header.html b/app/presentation/templates/partials/header.html new file mode 100644 index 0000000..a97d0da --- /dev/null +++ b/app/presentation/templates/partials/header.html @@ -0,0 +1,337 @@ + + + + + + + + diff --git a/app/presentation/templates/partials/nav.html b/app/presentation/templates/partials/nav.html new file mode 100644 index 0000000..c060f53 --- /dev/null +++ b/app/presentation/templates/partials/nav.html @@ -0,0 +1,11 @@ + diff --git a/app/presentation/web/__init__.py b/app/presentation/web/__init__.py new file mode 100644 index 0000000..1911931 --- /dev/null +++ b/app/presentation/web/__init__.py @@ -0,0 +1,15 @@ +"""Web UI layer for blog application. + +This package provides HTML endpoints and templates for the blog web interface, +separate from the JSON API layer. Uses Jinja2 templates with Gitea-inspired +theme support and comprehensive data-testid attributes for testing. + +The web layer follows the same DDD principles as the API layer and will +be integrated with use cases in future iterations. +""" + +from app.presentation.web.auth import router as auth_router +from app.presentation.web.error_handlers import register_error_handlers +from app.presentation.web.routes import router + +__all__ = ["router", "auth_router", "register_error_handlers"] diff --git a/app/presentation/web/auth.py b/app/presentation/web/auth.py new file mode 100644 index 0000000..9b0fc91 --- /dev/null +++ b/app/presentation/web/auth.py @@ -0,0 +1,157 @@ +"""Web authentication routes for blog application. + +This module provides OAuth2/OIDC authentication flow with Keycloak +for the web UI. Uses HTTP-only cookies for token storage. +""" + +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import RedirectResponse + +from app.infrastructure.config.settings import settings + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def get_keycloak_login_url(redirect_uri: str) -> str: + """Build Keycloak authorization URL. + + Args: + redirect_uri: Callback URL after Keycloak authentication. + + Returns: + Full Keycloak authorization endpoint URL. + """ + base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}" + return ( + f"{base_url}/protocol/openid-connect/auth" + f"?client_id={settings.kc.client_id}" + f"&response_type=code" + f"&redirect_uri={redirect_uri}" + f"&scope=openid" + ) + + +def get_keycloak_logout_url(redirect_uri: str) -> str: + """Build Keycloak logout URL. + + Args: + redirect_uri: URL to redirect after logout. + + Returns: + Full Keycloak logout endpoint URL. + """ + base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}" + return ( + f"{base_url}/protocol/openid-connect/logout" + f"?client_id={settings.kc.client_id}" + f"&post_logout_redirect_uri={redirect_uri}" + ) + + +async def exchange_code_for_token(code: str, redirect_uri: str) -> dict[str, Any]: + """Exchange authorization code for access token. + + Args: + code: Authorization code from Keycloak. + redirect_uri: Callback URL used during login. + + Returns: + Token response containing access_token, refresh_token, etc. + + Raises: + HTTPException: If token exchange fails. + """ + token_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}/protocol/openid-connect/token" + + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": settings.kc.client_id, + "client_secret": settings.kc.client_secret, + "redirect_uri": redirect_uri, + } + + async with httpx.AsyncClient() as client: + response = await client.post(token_url, data=data) + + if response.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to exchange code for token") + + result: dict[str, Any] = response.json() + return result + + +@router.get("/login") +async def login(request: Request) -> RedirectResponse: + """Redirect to Keycloak login page. + + Args: + request: HTTP request object. + + Returns: + RedirectResponse to Keycloak authorization endpoint. + """ + callback_url = str(request.base_url).rstrip("/") + "/auth/callback" + login_url = get_keycloak_login_url(callback_url) + return RedirectResponse(url=login_url) + + +@router.get("/callback") +async def callback(request: Request, code: str | None = None) -> Response: + """Handle OAuth callback from Keycloak. + + Exchanges authorization code for tokens and sets HTTP-only cookie. + + Args: + request: HTTP request object. + code: Authorization code from Keycloak. + + Returns: + RedirectResponse to home page with token cookie set. + + Raises: + HTTPException: If code is missing or token exchange fails. + """ + if not code: + raise HTTPException(status_code=400, detail="Authorization code not provided") + + callback_url = str(request.base_url).rstrip("/") + "/auth/callback" + token_data = await exchange_code_for_token(code, callback_url) + access_token = token_data.get("access_token") + + if not access_token: + raise HTTPException(status_code=400, detail="No access token received") + + response = RedirectResponse(url="/web/", status_code=302) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=not settings.is_dev, # Secure in production + samesite="lax", + max_age=token_data.get("expires_in", 3600), + ) + + return response + + +@router.get("/logout") +async def logout(request: Request) -> Response: + """Logout user and clear token cookie. + + Args: + request: HTTP request object. + + Returns: + RedirectResponse to Keycloak logout with cookie cleared. + """ + home_url = str(request.base_url).rstrip("/") + "/web/" + logout_url = get_keycloak_logout_url(home_url) + + response = RedirectResponse(url=logout_url) + response.delete_cookie(key="access_token") + + return response diff --git a/app/presentation/web/deps.py b/app/presentation/web/deps.py new file mode 100644 index 0000000..b0dbfef --- /dev/null +++ b/app/presentation/web/deps.py @@ -0,0 +1,213 @@ +"""Web dependencies for authentication and authorization. + +This module provides FastAPI dependencies for web UI authentication +including user extraction from cookies and role checking. +""" + +from typing import Annotated + +from fastapi import Cookie, Depends, HTTPException, Request + +from app.domain.roles import Role, get_effective_role +from app.infrastructure.auth import KeycloakAuthClient, TokenInfo + + +def get_keycloak_client(request: Request) -> KeycloakAuthClient: + """Get Keycloak client from DI container via request state. + + Args: + request: FastAPI request object. + + Returns: + KeycloakAuthClient instance from container. + """ + client: KeycloakAuthClient = request.state.dishka_container.get(KeycloakAuthClient) + return client + + +async def get_optional_user( + request: Request, + access_token: Annotated[str | None, Cookie()] = None, +) -> TokenInfo | None: + """Get current user from cookie if authenticated. + + Args: + request: FastAPI request object. + access_token: Access token from HTTP-only cookie. + + Returns: + TokenInfo if user is authenticated, None otherwise. + """ + if not access_token: + return None + + try: + keycloak_client = get_keycloak_client(request) + token_info = await keycloak_client.introspect_token(access_token) + + if not token_info.is_valid: + return None + + return token_info + except Exception: + return None + + +async def get_current_user( + request: Request, + access_token: Annotated[str | None, Cookie()] = None, +) -> TokenInfo: + """Get current user or raise HTTPException. + + Args: + request: HTTP request object. + access_token: Access token from HTTP-only cookie. + + Returns: + Validated TokenInfo for current user. + + Raises: + HTTPException: If user is not authenticated. + """ + user = await get_optional_user(request, access_token) + + if not user: + raise HTTPException( + status_code=307, + headers={"Location": "/auth/login"}, + ) + + return user + + +OptionalUserDep = Annotated[TokenInfo | None, Depends(get_optional_user)] +CurrentUserDep = Annotated[TokenInfo, Depends(get_current_user)] + + +def get_user_role(user: TokenInfo | None) -> Role: + """Get effective role from user token. + + Args: + user: User token info or None for guest. + + Returns: + Effective role for the user. + """ + if not user: + return Role.GUEST + + return get_effective_role(user.roles) + + +def require_role(required_role: Role): # type: ignore[no-untyped-def] + """Create dependency that requires specific role or higher. + + Args: + required_role: Minimum required role. + + Returns: + Dependency function for role checking. + """ + + async def role_checker(user: OptionalUserDep) -> TokenInfo: + """Check if user has required role. + + Args: + user: Current user from dependency. + + Returns: + User token info if authorized. + + Raises: + HTTPException: If user lacks required role. + """ + if not user: + raise HTTPException( + status_code=307, + headers={"Location": "/auth/login"}, + ) + + user_role = get_user_role(user) + role_hierarchy = [Role.GUEST, Role.USER, Role.ADMIN] + + user_level = role_hierarchy.index(user_role) + required_level = role_hierarchy.index(required_role) + + if user_level < required_level: + raise HTTPException( + status_code=403, + detail=f"Role '{required_role.value}' or higher required", + ) + + return user + + return role_checker + + +RequireUserDep = Annotated[TokenInfo, Depends(require_role(Role.USER))] +RequireAdminDep = Annotated[TokenInfo, Depends(require_role(Role.ADMIN))] + + +def can_edit_post(user: TokenInfo | None, post_author_id: str) -> bool: + """Check if user can edit a post. + + Args: + user: Current user or None. + post_author_id: ID of the post author. + + Returns: + True if user can edit the post. + """ + if not user: + return False + + user_role = get_user_role(user) + + return user_role == Role.ADMIN or (user_role == Role.USER and user.user_id == post_author_id) + + +def can_delete_post(user: TokenInfo | None, post_author_id: str) -> bool: + """Check if user can delete a post. + + Args: + user: Current user or None. + post_author_id: ID of the post author. + + Returns: + True if user can delete the post. + """ + return can_edit_post(user, post_author_id) + + +def can_see_draft(user: TokenInfo | None, post_author_id: str) -> bool: + """Check if user can see a draft post. + + Args: + user: Current user or None. + post_author_id: ID of the post author. + + Returns: + True if user can see the draft. + """ + if not user: + return False + + user_role = get_user_role(user) + + return user_role == Role.ADMIN or (user_role == Role.USER and user.user_id == post_author_id) + + +def can_create_post(user: TokenInfo | None) -> bool: + """Check if user can create a post. + + Args: + user: Current user or None. + + Returns: + True if user can create posts. + """ + if not user: + return False + + user_role = get_user_role(user) + return user_role in (Role.USER, Role.ADMIN) diff --git a/app/presentation/web/error_handlers.py b/app/presentation/web/error_handlers.py new file mode 100644 index 0000000..61b9ef7 --- /dev/null +++ b/app/presentation/web/error_handlers.py @@ -0,0 +1,194 @@ +"""Error handlers and middleware for web UI. + +This module provides custom error pages and flash message middleware +for the web interface. +""" + +from typing import Any + +from fastapi import HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.presentation.web.flash import FlashManager, get_flash_messages + +templates = Jinja2Templates(directory="app/presentation/templates") + + +async def setup_flash_manager(request: Request) -> None: + """Setup flash manager on request state. + + Args: + request: FastAPI request object. + """ + request.state.flash_manager = FlashManager(request) + + +async def add_flash_to_response(request: Request, response: HTMLResponse) -> None: + """Add flash cookie to response if needed. + + Args: + request: FastAPI request object. + response: FastAPI response object. + """ + if hasattr(request.state, "flash_manager"): + request.state.flash_manager.set_cookie(response) + + +def get_template_context(request: Request) -> dict[str, Any]: + """Get base template context with flash messages. + + Args: + request: FastAPI request object. + + Returns: + Template context dictionary. + """ + from app.presentation.web.deps import can_create_post, get_user_role + + user = getattr(request.state, "user", None) + user_role = get_user_role(user) + + return { + "request": request, + "user": user, + "user_role": user_role.value if user_role else None, + "can_create": can_create_post(user), + "flash_messages": get_flash_messages(request), + } + + +async def http_exception_handler(request: Request, exc: HTTPException) -> HTMLResponse: + """Handle HTTP exceptions with custom error pages. + + Args: + request: FastAPI request object. + exc: HTTPException instance. + + Returns: + HTMLResponse with error page. + """ + # Handle redirects (307, 308) + if exc.status_code in (307, 308): + location = exc.headers.get("Location") if exc.headers else None + if location: + return RedirectResponse(url=location, status_code=exc.status_code) # type: ignore[return-value] + + error_pages = { + 403: ("Access Denied", "You don't have permission to access this page."), + 404: ("Page Not Found", "The page you're looking for doesn't exist."), + 500: ("Server Error", "Something went wrong on our end. Please try again later."), + } + + error_title, error_message = error_pages.get( + exc.status_code, ("Error", exc.detail or "An unexpected error occurred.") + ) + + context = get_template_context(request) + context.update( + { + "error_code": exc.status_code, + "error_title": error_title, + "error_message": error_message, + } + ) + + return templates.TemplateResponse( + request, + "pages/error.html", + context, + status_code=exc.status_code, + ) + + +async def not_found_handler(request: Request, exc: HTTPException) -> HTMLResponse: + """Handle 404 Not Found errors. + + Args: + request: FastAPI request object. + exc: HTTPException instance. + + Returns: + HTMLResponse with 404 page. + """ + context = get_template_context(request) + context.update( + { + "error_code": 404, + "error_title": "Page Not Found", + "error_message": "The page you're looking for doesn't exist or has been moved.", + } + ) + + return templates.TemplateResponse( + request, + "pages/error.html", + context, + status_code=404, + ) + + +async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse: + """Handle 403 Forbidden errors. + + Args: + request: FastAPI request object. + exc: HTTPException instance. + + Returns: + HTMLResponse with 403 page. + """ + context = get_template_context(request) + context.update( + { + "error_code": 403, + "error_title": "Access Denied", + "error_message": "You don't have permission to access this resource. Please sign in or contact an administrator.", + } + ) + + return templates.TemplateResponse( + request, + "pages/error.html", + context, + status_code=403, + ) + + +async def server_error_handler(request: Request, exc: Exception) -> HTMLResponse: + """Handle 500 Internal Server Error. + + Args: + request: FastAPI request object. + exc: Exception instance. + + Returns: + HTMLResponse with 500 page. + """ + context = get_template_context(request) + context.update( + { + "error_code": 500, + "error_title": "Server Error", + "error_message": "Something went wrong on our end. Please try again later or contact support if the problem persists.", + } + ) + + return templates.TemplateResponse( + request, + "pages/error.html", + context, + status_code=500, + ) + + +def register_error_handlers(app: Any) -> None: + """Register error handlers with FastAPI app. + + Args: + app: FastAPI application instance. + """ + app.add_exception_handler(404, not_found_handler) + app.add_exception_handler(403, forbidden_handler) + app.add_exception_handler(500, server_error_handler) + app.add_exception_handler(HTTPException, http_exception_handler) diff --git a/app/presentation/web/flash.py b/app/presentation/web/flash.py new file mode 100644 index 0000000..2ae3a25 --- /dev/null +++ b/app/presentation/web/flash.py @@ -0,0 +1,175 @@ +"""Flash messages middleware for web UI. + +This module provides flash message functionality for the web interface, +allowing temporary messages to be passed between requests (e.g., after redirect). +Uses signed cookies for security. +""" + +from fastapi import Request, Response +from itsdangerous import URLSafeSerializer + +from app.infrastructure.config.settings import settings + +FLASH_COOKIE_NAME = "flash_messages" +_SECRET_KEY = ( + settings.security.secret_key.get_secret_value() + if hasattr(settings.security.secret_key, "get_secret_value") + else settings.security.secret_key +) +SERIALIZER = URLSafeSerializer(_SECRET_KEY) + + +class FlashMessage: + """Flash message model. + + Represents a single flash message with type and content. + + Attributes: + message: The message text. + category: Message category (success, error, warning, info). + """ + + def __init__(self, message: str, category: str = "info") -> None: + """Initialize flash message. + + Args: + message: The message text. + category: Message category (success, error, warning, info). + """ + self.message = message + self.category = category + + def to_dict(self) -> dict[str, str]: + """Convert to dictionary. + + Returns: + Dictionary with message and category. + """ + return {"message": self.message, "category": self.category} + + +class FlashManager: + """Manager for flash messages. + + Handles storing and retrieving flash messages from cookies. + Messages are cleared after being read. + + Attributes: + request: FastAPI request object. + messages: List of current messages. + """ + + CATEGORIES = { + "success": "success", + "error": "error", + "warning": "warning", + "info": "info", + } + + def __init__(self, request: Request) -> None: + """Initialize flash manager. + + Args: + request: FastAPI request object. + """ + self.request = request + self.messages: list[FlashMessage] = [] + self._load_messages() + + def _load_messages(self) -> None: + """Load messages from cookie.""" + cookie_value = self.request.cookies.get(FLASH_COOKIE_NAME) + if cookie_value: + try: + data = SERIALIZER.loads(cookie_value) + if isinstance(data, list): + self.messages = [FlashMessage(msg["message"], msg["category"]) for msg in data] + except Exception: + self.messages = [] + + def add(self, message: str, category: str = "info") -> None: + """Add a flash message. + + Args: + message: The message text. + category: Message category (success, error, warning, info). + """ + self.messages.append(FlashMessage(message, category)) + + def get_messages(self) -> list[dict[str, str]]: + """Get all messages and clear them. + + Returns: + List of message dictionaries. + """ + result = [msg.to_dict() for msg in self.messages] + self.messages = [] + return result + + def has_messages(self) -> bool: + """Check if there are any messages. + + Returns: + True if there are messages. + """ + return len(self.messages) > 0 + + def set_cookie(self, response: Response) -> None: + """Set flash cookie on response. + + Args: + response: FastAPI response object. + """ + if self.messages: + data = [msg.to_dict() for msg in self.messages] + cookie_value = SERIALIZER.dumps(data) + response.set_cookie( + key=FLASH_COOKIE_NAME, + value=cookie_value, + httponly=True, + secure=not settings.is_dev, + samesite="lax", + max_age=300, # 5 minutes + ) + else: + response.delete_cookie(key=FLASH_COOKIE_NAME) + + +def flash(request: Request, message: str, category: str = "info") -> None: + """Add flash message to request state. + + Convenience function to add flash message. + Must be called before response is created. + + Args: + request: FastAPI request object. + message: The message text. + category: Message category. + """ + if not hasattr(request.state, "flash_manager"): + request.state.flash_manager = FlashManager(request) + request.state.flash_manager.add(message, category) + + +def get_flash_messages(request: Request) -> list[dict[str, str]]: + """Get flash messages from request. + + Args: + request: FastAPI request object. + + Returns: + List of flash message dictionaries. + """ + if hasattr(request.state, "flash_manager"): + return request.state.flash_manager.get_messages() # type: ignore[no-any-return] + return [] + + +async def setup_flash_manager(request: Request) -> None: + """Setup flash manager on request state. + + Args: + request: FastAPI request object. + """ + if not hasattr(request.state, "flash_manager"): + request.state.flash_manager = FlashManager(request) diff --git a/app/presentation/web/routes.py b/app/presentation/web/routes.py new file mode 100644 index 0000000..b370a37 --- /dev/null +++ b/app/presentation/web/routes.py @@ -0,0 +1,519 @@ +"""Web UI routes for blog application with authentication. + +This module provides HTML endpoints for the blog web interface +with role-based access control and user authentication. +""" + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.infrastructure.auth import TokenInfo +from app.presentation.web.deps import ( + OptionalUserDep, + RequireUserDep, + can_create_post, + can_delete_post, + can_edit_post, + can_see_draft, + get_user_role, +) +from app.presentation.web.flash import flash + +router = APIRouter(prefix="/web", tags=["web"]) +templates = Jinja2Templates(directory="app/presentation/templates") + + +def nl2br(value: str) -> str: + """Convert newlines to HTML line breaks. + + Args: + value: String with newlines. + + Returns: + String with
tags instead of newlines. + """ + return value.replace("\n", "
\n") + + +templates.env.filters["nl2br"] = nl2br + + +class MockPost: + """Mock post object for UI demonstration. + + This class simulates a Post entity for template rendering + before integration with actual use cases. + + Attributes: + id: Unique identifier for the post. + title: Post title value object. + content: Post content value object. + slug: URL-friendly slug. + author_id: Identifier of the post author. + published: Publication status flag. + tags: List of tags associated with the post. + created_at: Timestamp when the post was created. + updated_at: Timestamp when the post was last updated. + """ + + def __init__( + self, + id: str, + title: str, + content: str, + slug: str, + author_id: str, + published: bool, + tags: list[str], + created_at: datetime | None = None, + ) -> None: + """Initialize mock post with provided attributes. + + Args: + id: Unique identifier for the post. + title: Post title string. + content: Post content string. + slug: URL-friendly slug string. + author_id: Author identifier string. + published: Whether the post is published. + tags: List of tag strings. + created_at: Optional creation timestamp, defaults to now. + """ + self.id = id + self.title = MockValueObject(title) + self.content = MockValueObject(content) + self.slug = MockValueObject(slug) + self.author_id = author_id + self.published = published + self.tags = tags + self.created_at = created_at or datetime.now() + self.updated_at = self.created_at + + +class MockValueObject: + """Mock value object for simulating domain value objects. + + Wraps a raw value to simulate the interface of domain + value objects like Title, Content, and Slug. + + Attributes: + value: The wrapped string value. + """ + + def __init__(self, value: str) -> None: + """Initialize with a string value. + + Args: + value: The string value to wrap. + """ + self.value = value + + +MOCK_POSTS = [ + MockPost( + id=str(uuid4()), + title="Getting Started with FastAPI", + content="FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.7+ based on standard Python type hints. It is designed to be easy to use while providing high performance.", + slug="getting-started-with-fastapi", + author_id="john_doe", + published=True, + tags=["python", "fastapi", "tutorial"], + created_at=datetime(2026, 1, 15, 10, 30), + ), + MockPost( + id=str(uuid4()), + title="Understanding DDD Architecture", + content="Domain-Driven Design (DDD) is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The term was coined by Eric Evans in his book of the same title.", + slug="understanding-ddd-architecture", + author_id="jane_smith", + published=True, + tags=["ddd", "architecture", "software-design"], + created_at=datetime(2026, 1, 14, 14, 45), + ), + MockPost( + id=str(uuid4()), + title="Draft Post Example", + content="This is a draft post that hasn't been published yet. It demonstrates how unpublished posts appear in the UI.", + slug="draft-post-example", + author_id="john_doe", + published=False, + tags=["draft"], + created_at=datetime(2026, 1, 13, 9, 0), + ), +] + + +def get_base_context(user: TokenInfo | None) -> dict[str, Any]: + """Get base template context with user info and permissions. + + Args: + user: Current user or None for guest. + + Returns: + Dictionary with user, user_role, and can_create flags. + """ + user_role = get_user_role(user) + + return { + "user": user, + "user_role": user_role.value if user_role else None, + "can_create": can_create_post(user), + } + + +def filter_visible_posts(posts: list[MockPost], user: TokenInfo | None) -> list[MockPost]: + """Filter posts based on user permissions. + + Args: + posts: List of all posts. + user: Current user or None for guest. + + Returns: + Filtered list of posts visible to the user. + """ + visible_posts = [] + + for post in posts: + if post.published or can_see_draft(user, post.author_id): + visible_posts.append(post) + + return visible_posts + + +@router.get("/", response_class=HTMLResponse) +async def home( + request: Request, + user: OptionalUserDep, +) -> HTMLResponse: + """Render the home page with list of posts. + + Args: + request: The HTTP request object for template context. + user: Current user from dependency. + + Returns: + HTMLResponse with rendered posts list template. + """ + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + + return templates.TemplateResponse( + request, + "pages/index.html", + { + **context, + "posts": visible_posts, + "active_page": "home", + "current_page": 1, + "has_prev": False, + "has_next": False, + }, + ) + + +@router.get("/posts", response_class=HTMLResponse) +async def list_posts( + request: Request, + user: OptionalUserDep, +) -> HTMLResponse: + """Render the posts listing page. + + Args: + request: The HTTP request object for template context. + user: Current user from dependency. + + Returns: + HTMLResponse with rendered posts list template. + """ + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + + return templates.TemplateResponse( + request, + "pages/index.html", + { + **context, + "posts": visible_posts, + "active_page": "posts", + "current_page": 1, + "has_prev": False, + "has_next": True, + }, + ) + + +@router.get("/posts/new", response_class=HTMLResponse) +async def new_post_form( + request: Request, + user: RequireUserDep, +) -> HTMLResponse: + """Render the new post creation form. + + Args: + request: The HTTP request object for template context. + user: Current user (required). + + Returns: + HTMLResponse with rendered post form template. + """ + context = get_base_context(user) + + return templates.TemplateResponse( + request, + "pages/post_form.html", + { + **context, + "is_edit": False, + "post": None, + "active_page": "posts", + }, + ) + + +@router.post("/posts/new") +async def create_post( + request: Request, + user: RequireUserDep, +) -> RedirectResponse: + """Handle new post creation form submission. + + Args: + request: The HTTP request object containing form data. + user: Current user (required). + + Returns: + RedirectResponse to the new post or home page. + """ + flash(request, "Post created successfully!", "success") + response = RedirectResponse(url="/web/", status_code=303) + return response + + +@router.get("/posts/{post_slug}", response_class=HTMLResponse) +async def post_detail( + request: Request, + post_slug: str, + user: OptionalUserDep, +) -> HTMLResponse: + """Render a single post detail page. + + Args: + request: The HTTP request object for template context. + post_id: The unique identifier of the post to display. + user: Current user from dependency. + + Returns: + HTMLResponse with rendered post detail template. + + Raises: + HTTPException: If post not found or not visible to user. + """ + post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not post.published and not can_see_draft(user, post.author_id): + raise HTTPException(status_code=404, detail="Post not found") + + context = get_base_context(user) + + return templates.TemplateResponse( + request, + "pages/post_detail.html", + { + **context, + "post": post, + "active_page": "posts", + "can_edit": can_edit_post(user, post.author_id), + "can_delete": can_delete_post(user, post.author_id), + }, + ) + + +@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse) +async def edit_post_form( + request: Request, + post_slug: str, + user: RequireUserDep, +) -> HTMLResponse: + """Render the post edit form. + + Args: + request: The HTTP request object for template context. + post_id: The unique identifier of the post to edit. + user: Current user (required). + + Returns: + HTMLResponse with rendered post form template. + + Raises: + HTTPException: If post not found or user cannot edit it. + """ + post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not can_edit_post(user, post.author_id): + raise HTTPException(status_code=403, detail="Not authorized to edit this post") + + context = get_base_context(user) + + return templates.TemplateResponse( + request, + "pages/post_form.html", + { + **context, + "is_edit": True, + "post": post, + "active_page": "posts", + }, + ) + + +@router.post("/posts/{post_slug}/edit", response_class=HTMLResponse) +async def update_post( + request: Request, + post_slug: str, + user: RequireUserDep, +) -> HTMLResponse: + """Handle post update form submission. + + Args: + request: The HTTP request object containing form data. + post_id: The unique identifier of the post to update. + user: Current user (required). + + Returns: + HTMLResponse with rendered post detail template. + + Raises: + HTTPException: If post not found or user cannot edit it. + """ + post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not can_edit_post(user, post.author_id): + raise HTTPException(status_code=403, detail="Not authorized to edit this post") + + context = get_base_context(user) + + return templates.TemplateResponse( + request, + "pages/post_detail.html", + { + **context, + "post": post, + "active_page": "posts", + "can_edit": True, + "can_delete": can_delete_post(user, post.author_id), + }, + ) + + +@router.post("/posts/{post_slug}/delete", response_class=HTMLResponse) +async def delete_post( + request: Request, + post_slug: str, + user: RequireUserDep, +) -> HTMLResponse: + """Handle post deletion. + + Args: + request: The HTTP request object. + post_id: The unique identifier of the post to delete. + user: Current user (required). + + Returns: + HTMLResponse redirecting to the home page. + + Raises: + HTTPException: If post not found or user cannot delete it. + """ + post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not can_delete_post(user, post.author_id): + raise HTTPException(status_code=403, detail="Not authorized to delete this post") + + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + + return templates.TemplateResponse( + request, + "pages/index.html", + { + **context, + "posts": visible_posts, + "active_page": "home", + "current_page": 1, + "has_prev": False, + "has_next": False, + }, + ) + + +@router.get("/profile", response_class=HTMLResponse) +async def profile( + request: Request, + user: RequireUserDep, +) -> HTMLResponse: + """Render user profile page. + + Args: + request: The HTTP request object for template context. + user: Current user (required). + + Returns: + HTMLResponse with rendered profile template. + """ + context = get_base_context(user) + + return templates.TemplateResponse( + request, + "pages/profile.html", + { + **context, + "active_page": "profile", + }, + ) + + +@router.get("/about", response_class=HTMLResponse) +async def about( + request: Request, + user: OptionalUserDep, +) -> HTMLResponse: + """Render the about page. + + Args: + request: The HTTP request object for template context. + user: Current user from dependency. + + Returns: + HTMLResponse with rendered about page template. + """ + return HTMLResponse( + content=f""" + + + About - Blog + +

About

+

A modern blog built with FastAPI and DDD architecture.

+

User: {user.username if user else "Guest"}

+ Back to home + + + """ + ) diff --git a/pyproject.toml b/pyproject.toml index edf9ece..7c83f5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "asyncpg>=0.30.0", "dishka>=1.5.0", "httpx>=0.28.0", + "jinja2>=3.1.6", + "itsdangerous>=2.2.0", ] [build-system] diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..a333116 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,158 @@ +/* Base styles for blog application + * + * This file provides reset, typography, and base styles + * using CSS variables from theme files. + */ + +/* Reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: var(--color-body); + color: var(--color-text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + color: var(--color-text-dark); + font-weight: 600; + line-height: 1.25; + margin-bottom: 1rem; +} + +h1 { font-size: 2rem; } +h2 { font-size: 1.75rem; } +h3 { font-size: 1.5rem; } +h4 { font-size: 1.25rem; } +h5 { font-size: 1.125rem; } +h6 { font-size: 1rem; } + +p { + margin-bottom: 1rem; + color: var(--color-text); +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +/* Lists */ +ul, ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +li { + margin-bottom: 0.25rem; +} + +/* Code */ +code { + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace; + font-size: 0.875em; + background-color: var(--color-code-bg); + color: var(--color-text); + padding: 0.125rem 0.375rem; + border-radius: 3px; +} + +pre { + background-color: var(--color-code-bg); + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 1rem; +} + +pre code { + background: none; + padding: 0; +} + +/* Selection */ +::selection { + background-color: var(--color-primary-alpha-30); + color: var(--color-text-dark); +} + +/* Focus styles */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} + +/* Enhanced link focus for accessibility */ +a:focus-visible, +.btn:focus-visible, +.nav-link:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 4px; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--color-secondary-light-4); +} + +::-webkit-scrollbar-thumb { + background: var(--color-secondary-dark-4); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-secondary-dark-5); +} + +/* Utility classes */ +.text-light { + color: var(--color-text-light); +} + +.text-muted { + color: var(--color-text-light-3); +} + +.text-center { + text-align: center; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..d921970 --- /dev/null +++ b/static/css/components.css @@ -0,0 +1,364 @@ +/* Component styles for blog application + * + * This file provides reusable UI components like buttons, + * cards, forms, inputs, and other interactive elements. + * All components use CSS variables from theme files. + */ + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5; + border: 1px solid var(--color-secondary-dark-1); + border-radius: 4px; + background-color: var(--color-button); + color: var(--color-text); + cursor: pointer; + text-decoration: none; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + white-space: nowrap; +} + +.btn:hover { + background-color: var(--color-hover); + border-color: var(--color-secondary-dark-2); + text-decoration: none; +} + +.btn:active { + background-color: var(--color-active); +} + +.btn:focus { + outline: none; + box-shadow: 0 0 0 3px var(--color-primary-alpha-30); +} + +.btn:disabled, +.btn.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--color-primary); + border-color: var(--color-primary-dark-1); + color: var(--color-primary-contrast); +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-dark-2); +} + +.btn-primary:active { + background-color: var(--color-primary-active); +} + +.btn-danger { + background-color: var(--color-red); + border-color: var(--color-red-dark-1); + color: #ffffff; +} + +.btn-danger:hover { + background-color: var(--color-red-dark-1); +} + +.btn-success { + background-color: var(--color-green); + border-color: var(--color-green-dark-1); + color: #ffffff; +} + +.btn-success:hover { + background-color: var(--color-green-dark-1); +} + +.btn-ghost { + background-color: transparent; + border-color: transparent; +} + +.btn-ghost:hover { + background-color: var(--color-hover); +} + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.8125rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Cards */ +.card { + background-color: var(--color-box-body); + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px var(--color-shadow); + transition: all 0.2s ease; +} + +.card:hover { + box-shadow: 0 4px 12px var(--color-shadow); +} + +.card-header { + background-color: var(--color-box-header); + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border); + font-weight: 600; +} + +.card-body { + padding: 1.5rem 2rem; +} + +.card-footer { + background-color: var(--color-box-header); + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--color-border); +} + +/* Forms */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-text); +} + +.form-label-required::after { + content: " *"; + color: var(--color-red); +} + +.form-hint { + display: block; + margin-top: 0.25rem; + font-size: 0.8125rem; + color: var(--color-text-light-3); +} + +/* Inputs */ +.input, +.textarea, +.select { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-input-text); + background-color: var(--color-input-background); + border: 1px solid var(--color-input-border); + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.input:focus, +.textarea:focus, +.select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-alpha-20); +} + +.input:disabled, +.textarea:disabled, +.select:disabled { + background-color: var(--color-secondary-light-2); + cursor: not-allowed; +} + +.input::placeholder, +.textarea::placeholder { + color: var(--color-placeholder-text); +} + +.textarea { + min-height: 100px; + resize: vertical; +} + +.select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.5rem; +} + +/* Input sizes */ +.input-sm, +.textarea-sm, +.select-sm { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; +} + +.input-lg, +.textarea-lg, +.select-lg { + padding: 0.75rem 1rem; + font-size: 1rem; +} + +/* Alerts */ +.alert { + padding: 1rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 4px; +} + +.alert-error { + background-color: var(--color-error-bg); + border-color: var(--color-error-border); + color: var(--color-error-text); +} + +.alert-success { + background-color: var(--color-success-bg); + border-color: var(--color-success-border); + color: var(--color-success-text); +} + +.alert-warning { + background-color: var(--color-warning-bg); + border-color: var(--color-warning-border); + color: var(--color-warning-text); +} + +.alert-info { + background-color: var(--color-info-bg); + border-color: var(--color-info-border); + color: var(--color-info-text); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.5; + border-radius: 9999px; + background-color: var(--color-label-bg); + color: var(--color-label-text); + white-space: nowrap; +} + +.badge-primary { + background-color: var(--color-primary-alpha-20); + color: var(--color-primary); +} + +.badge-success { + background-color: var(--color-green-badge-bg); + color: var(--color-green-badge); +} + +.badge-danger { + background-color: var(--color-red-badge-bg); + color: var(--color-red-badge); +} + +.badge-warning { + background-color: var(--color-yellow-badge-bg); + color: var(--color-yellow-badge); +} + +/* Tags */ +.tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + background-color: var(--color-secondary-light-3); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text-light); + cursor: pointer; + transition: all 0.2s ease; +} + +.tag:hover { + background-color: var(--color-primary-alpha-10); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* Checkbox styling */ +input[type="checkbox"] { + width: 1.25rem; + height: 1.25rem; + margin-right: 0.5rem; + accent-color: var(--color-primary); + cursor: pointer; + vertical-align: middle; +} + +/* Avatar */ +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background-color: var(--color-primary); + color: var(--color-primary-contrast); + font-weight: 500; + font-size: 0.875rem; +} + +.avatar-sm { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; +} + +.avatar-lg { + width: 2.5rem; + height: 2.5rem; + font-size: 1rem; +} + +/* Dividers */ +.divider { + height: 1px; + background-color: var(--color-border); + margin: 1.5rem 0; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-light-3); +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} diff --git a/static/css/layout.css b/static/css/layout.css new file mode 100644 index 0000000..1cb59e6 --- /dev/null +++ b/static/css/layout.css @@ -0,0 +1,505 @@ +/* Layout styles for blog application + * + * This file provides layout-related styles including + * grid system, navigation, containers, and page structure. + */ + +/* Container */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.container-narrow { + max-width: 800px; +} + +.container-wide { + max-width: 1400px; +} + +/* Main layout */ +.main-wrapper { + flex: 1; + padding: 2rem 0; +} + +/* Grid system */ +.grid { + display: grid; + gap: 1.5rem; +} + +.grid-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 1024px) { + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + } +} + +/* Flex utilities */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.justify-center { + justify-content: center; +} + +.gap-1 { gap: 0.25rem; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } + +/* Header */ +.site-header { + background-color: var(--color-nav-bg); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 100; +} + +.site-header .container { + display: flex; + align-items: center; + justify-content: space-between; + height: 4rem; +} + +.site-logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-dark); + text-decoration: none; +} + +.site-logo:hover { + color: var(--color-primary); + text-decoration: none; +} + +/* Navigation */ +.main-nav { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.nav-link { + color: var(--color-nav-text); + font-weight: 500; + padding: 0.5rem 0; + border-bottom: 2px solid transparent; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.nav-link:hover { + color: var(--color-primary); + text-decoration: none; + border-bottom-color: var(--color-primary-alpha-50); +} + +.nav-link.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* Header actions */ +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Theme toggle button */ +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 4px; + background: transparent; + border: 1px solid transparent; + color: var(--color-nav-text); + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.theme-toggle:hover { + background-color: var(--color-nav-hover-bg); + color: var(--color-primary); +} + +/* Footer */ +.site-footer { + background-color: var(--color-footer); + border-top: 1px solid var(--color-border); + padding: 2rem 0; + margin-top: auto; +} + +.site-footer .container { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.footer-links { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.footer-link { + color: var(--color-text-light); + font-size: 0.875rem; +} + +.footer-link:hover { + color: var(--color-primary); +} + +/* Page header */ +.page-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.page-header-flex { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.page-title { + margin-bottom: 0; +} + +.page-subtitle { + color: var(--color-text-light); + margin-top: 0.25rem; +} + +/* Post list */ +.post-list { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Post card specific */ +.post-card { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + transition: all 0.2s ease; +} + +.post-card:hover { + transform: translateY(-2px); +} + +.post-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.post-card-title { + margin-bottom: 0; + font-size: 1.5rem; + line-height: 1.3; +} + +.post-card-title a { + color: var(--color-text-dark); +} + +.post-card-title a:hover { + color: var(--color-primary); +} + +.post-card-meta { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.875rem; + color: var(--color-text-light-1); + margin-bottom: 0.75rem; +} + +.post-card-meta-item { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.post-card-content { + color: var(--color-text-light-1); + line-height: 1.7; + font-size: 1rem; +} + +.post-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.post-card-tags { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Post detail */ +.post-detail { + max-width: 800px; + margin: 0 auto; +} + +.post-detail-header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.post-detail-title { + font-size: 2rem; + margin-bottom: 1rem; +} + +.post-detail-meta { + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; + color: var(--color-text-light-2); +} + +.post-detail-content { + font-size: 1.125rem; + line-height: 1.8; + color: var(--color-text); +} + +.post-detail-content p { + margin-bottom: 1.5rem; +} + +.post-detail-footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +.post-detail-tags { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Sidebar */ +.sidebar { + position: sticky; + top: 6rem; +} + +.sidebar-section { + background-color: var(--color-box-body); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.sidebar-title { + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + color: var(--color-text-light); + margin-bottom: 1rem; +} + +/* Two column layout */ +.two-column { + display: grid; + grid-template-columns: 1fr 300px; + gap: 2rem; +} + +@media (max-width: 1024px) { + .two-column { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } +} + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + margin-top: 2rem; +} + +.pagination-item { + display: flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + color: var(--color-text); + text-decoration: none; + transition: background-color 0.2s ease; +} + +.pagination-item:hover { + background-color: var(--color-hover); + text-decoration: none; +} + +.pagination-item.active { + background-color: var(--color-primary); + color: var(--color-primary-contrast); +} + +.pagination-item.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Mobile menu */ +@media (max-width: 768px) { + .site-header .container { + height: 3.5rem; + padding: 0 1.25rem; + } + + .main-nav { + display: none; + } + + .mobile-menu-btn { + display: flex; + } + + /* Form actions mobile */ + .form-actions { + flex-direction: column-reverse; + gap: 1rem; + } + + .form-actions .btn { + width: 100%; + } + + /* Footer mobile */ + .site-footer .container { + flex-direction: column; + text-align: center; + gap: 1.5rem; + padding: 2rem 1rem; + } + + .footer-links { + flex-wrap: wrap; + justify-content: center; + } + + /* Post card mobile */ + .post-card { + padding: 1.25rem; + } + + .post-card-title { + font-size: 1.25rem; + } + + .post-card-header { + flex-direction: column; + gap: 0.75rem; + } + + .post-card-footer { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + /* Page header mobile */ + .page-header-flex { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .page-title { + font-size: 1.75rem; + } +} + +@media (min-width: 769px) { + .mobile-menu-btn { + display: none; + } +} diff --git a/static/css/themes/theme-dark.css b/static/css/themes/theme-dark.css new file mode 100644 index 0000000..9907c60 --- /dev/null +++ b/static/css/themes/theme-dark.css @@ -0,0 +1,199 @@ +gitea-theme-meta-info { + --theme-display-name: "Dark"; + --theme-color-scheme: "dark"; +} + +[data-theme="dark"] { + --is-dark-theme: true; + + /* Primary colors */ + --color-primary: #4183c4; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #548fca; + --color-primary-dark-2: #679cd0; + --color-primary-dark-3: #7aa8d6; + --color-primary-dark-4: #8db5dc; + --color-primary-dark-5: #b3cde7; + --color-primary-dark-6: #d9e6f3; + --color-primary-dark-7: #f4f8fb; + --color-primary-light-1: #3876b3; + --color-primary-light-2: #31699f; + --color-primary-light-3: #2b5c8b; + --color-primary-light-4: #254f77; + --color-primary-light-5: #193450; + --color-primary-light-6: #0c1a28; + --color-primary-light-7: #04080c; + --color-primary-alpha-10: #4183c419; + --color-primary-alpha-20: #4183c433; + --color-primary-alpha-30: #4183c44b; + --color-primary-alpha-40: #4183c466; + --color-primary-alpha-50: #4183c480; + --color-primary-alpha-60: #4183c499; + --color-primary-alpha-70: #4183c4b3; + --color-primary-alpha-80: #4183c4cc; + --color-primary-alpha-90: #4183c4e1; + --color-primary-hover: var(--color-primary-light-1); + --color-primary-active: var(--color-primary-light-2); + + /* Secondary colors */ + --color-secondary: #3f4248; + --color-secondary-dark-1: #46494f; + --color-secondary-dark-2: #4f5259; + --color-secondary-dark-3: #5e626a; + --color-secondary-dark-4: #6f747d; + --color-secondary-dark-5: #7d828c; + --color-secondary-dark-6: #8b8f98; + --color-secondary-dark-7: #999da4; + --color-secondary-dark-8: #a8abb1; + --color-secondary-dark-9: #aeb1b8; + --color-secondary-dark-10: #bbbec3; + --color-secondary-dark-11: #c8cacf; + --color-secondary-dark-12: #d2d4d7; + --color-secondary-dark-13: #d5d6d9; + --color-secondary-light-1: #35373c; + --color-secondary-light-2: #2c2e32; + --color-secondary-light-3: #1f2124; + --color-secondary-light-4: #191a1c; + --color-secondary-alpha-10: #3f424819; + --color-secondary-alpha-20: #3f424833; + --color-secondary-alpha-30: #3f42484b; + --color-secondary-alpha-40: #3f424866; + --color-secondary-alpha-50: #3f424880; + --color-secondary-alpha-60: #3f424899; + --color-secondary-alpha-70: #3f4248b3; + --color-secondary-alpha-80: #3f4248cc; + --color-secondary-alpha-90: #3f4248e1; + --color-secondary-button: var(--color-secondary-dark-4); + --color-secondary-hover: var(--color-secondary-dark-3); + --color-secondary-active: var(--color-secondary-dark-2); + + /* Semantic colors */ + --color-red: #cc4848; + --color-orange: #cc580c; + --color-yellow: #cc9903; + --color-olive: #91a313; + --color-green: #87ab63; + --color-teal: #00918a; + --color-blue: #3a8ac6; + --color-violet: #906ae1; + --color-purple: #b259d0; + --color-pink: #d22e8b; + --color-brown: #a47252; + --color-black: #202225; + + /* Light variants */ + --color-red-light: #d15a5a; + --color-orange-light: #f6a066; + --color-yellow-light: #eaaf03; + --color-olive-light: #abc016; + --color-green-light: #93b373; + --color-teal-light: #00b6ad; + --color-blue-light: #4e96cc; + --color-violet-light: #9b79e4; + --color-purple-light: #ba6ad5; + --color-pink-light: #d74397; + --color-brown-light: #b08061; + --color-black-light: #45484e; + + /* Dark variants */ + --color-red-dark-1: #c23636; + --color-orange-dark-1: #f38236; + --color-yellow-dark-1: #b88a03; + --color-olive-dark-1: #839311; + --color-green-dark-1: #7a9e55; + --color-teal-dark-1: #00837c; + --color-blue-dark-1: #347cb3; + --color-violet-dark-1: #7b4edb; + --color-purple-dark-1: #a742c9; + --color-pink-dark-1: #be297d; + --color-brown-dark-1: #94674a; + --color-black-dark-1: #2e3033; + + /* Status colors */ + --color-error-border: #763232; + --color-error-bg: #322226; + --color-error-bg-active: #49262a; + --color-error-bg-hover: #3c2427; + --color-error-text: #f85149; + --color-success-border: #225633; + --color-success-bg: #1c3329; + --color-success-text: #3fb950; + --color-warning-border: #5f481a; + --color-warning-bg: #342e1f; + --color-warning-text: #d29922; + --color-info-border: #254a7e; + --color-info-bg: #1b283a; + --color-info-text: #2f81f7; + + /* Target-based colors */ + --color-body: #1e1f20; + --color-box-header: #1b1c1e; + --color-box-body: #161718; + --color-box-body-highlight: #202124; + --color-text-dark: #f8f8f8; + --color-text: #d2d4d8; + --color-text-light: #c0c2c7; + --color-text-light-1: #aaadb4; + --color-text-light-2: #969aa1; + --color-text-light-3: #80858f; + --color-footer: var(--color-nav-bg); + --color-timeline: #383b40; + --color-input-text: var(--color-text-dark); + --color-input-background: #191a1c; + --color-input-toggle-background: #323438; + --color-input-border: var(--color-secondary-dark-1); + --color-light: #0b0b0c28; + --color-light-border: #f3f3f428; + --color-hover: #f3f3f419; + --color-hover-opaque: #232528; + --color-active: #f3f3f424; + --color-menu: #191a1c; + --color-card: #191a1c; + --color-button: #191a1c; + --color-code-bg: #161718; + --color-shadow: #0b0b0c58; + --color-shadow-opaque: #0b0b0c; + --color-secondary-bg: #2e3033; + --color-expand-button: #333539; + --color-placeholder-text: var(--color-text-light-3); + --color-tooltip-text: #fafafa; + --color-tooltip-bg: #0b0b0cf0; + --color-nav-bg: #18191b; + --color-nav-hover-bg: var(--color-secondary-light-1); + --color-nav-text: var(--color-text); + --color-secondary-nav-bg: #1a1b1e; + --color-label-text: var(--color-text); + --color-label-bg: #7a7f8a4b; + --color-label-hover-bg: #7a7f8aa0; + --color-label-active-bg: #7a7f8aff; + --color-accent: var(--color-primary-light-1); + --color-small-accent: var(--color-primary-light-5); + --color-border: #3f4248; + + accent-color: var(--color-accent); + color-scheme: dark; +} + +/* invert emojis that are hard to read otherwise */ +.emoji[aria-label="check mark"], +.emoji[aria-label="currency exchange"], +.emoji[aria-label="TOP arrow"], +.emoji[aria-label="END arrow"], +.emoji[aria-label="ON! arrow"], +.emoji[aria-label="SOON arrow"], +.emoji[aria-label="heavy dollar sign"], +.emoji[aria-label="copyright"], +.emoji[aria-label="registered"], +.emoji[aria-label="trade mark"], +.emoji[aria-label="multiply"], +.emoji[aria-label="plus"], +.emoji[aria-label="minus"], +.emoji[aria-label="divide"], +.emoji[aria-label="curly loop"], +.emoji[aria-label="double curly loop"], +.emoji[aria-label="wavy dash"], +.emoji[aria-label="paw prints"], +.emoji[aria-label="musical note"], +.emoji[aria-label="musical notes"] { + filter: invert(100%) hue-rotate(180deg); +} diff --git a/static/css/themes/theme-light.css b/static/css/themes/theme-light.css new file mode 100644 index 0000000..c9eb875 --- /dev/null +++ b/static/css/themes/theme-light.css @@ -0,0 +1,175 @@ +gitea-theme-meta-info { + --theme-display-name: "Light"; + --theme-color-scheme: "light"; +} + +:root { + --is-dark-theme: false; + + /* Primary colors */ + --color-primary: #4183c4; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #3876b3; + --color-primary-dark-2: #31699f; + --color-primary-dark-3: #2b5c8b; + --color-primary-dark-4: #254f77; + --color-primary-dark-5: #193450; + --color-primary-dark-6: #0c1a28; + --color-primary-dark-7: #04080c; + --color-primary-light-1: #548fca; + --color-primary-light-2: #679cd0; + --color-primary-light-3: #7aa8d6; + --color-primary-light-4: #8db5dc; + --color-primary-light-5: #b3cde7; + --color-primary-light-6: #d9e6f3; + --color-primary-light-7: #f4f8fb; + --color-primary-alpha-10: #4183c419; + --color-primary-alpha-20: #4183c433; + --color-primary-alpha-30: #4183c44b; + --color-primary-alpha-40: #4183c466; + --color-primary-alpha-50: #4183c480; + --color-primary-alpha-60: #4183c499; + --color-primary-alpha-70: #4183c4b3; + --color-primary-alpha-80: #4183c4cc; + --color-primary-alpha-90: #4183c4e1; + --color-primary-hover: var(--color-primary-dark-1); + --color-primary-active: var(--color-primary-dark-2); + + /* Secondary colors */ + --color-secondary: #d0d7de; + --color-secondary-dark-1: #c7ced5; + --color-secondary-dark-2: #b9c0c7; + --color-secondary-dark-3: #99a0a7; + --color-secondary-dark-4: #899097; + --color-secondary-dark-5: #7a8188; + --color-secondary-dark-6: #6a7178; + --color-secondary-dark-7: #5b6269; + --color-secondary-dark-8: #4b5259; + --color-secondary-dark-9: #3c434a; + --color-secondary-dark-10: #2c333a; + --color-secondary-dark-11: #1d242b; + --color-secondary-dark-12: #0d141b; + --color-secondary-dark-13: #00040b; + --color-secondary-light-1: #dee5ec; + --color-secondary-light-2: #e4ebf2; + --color-secondary-light-3: #ebf2f9; + --color-secondary-light-4: #f1f8ff; + --color-secondary-alpha-10: #d0d7de19; + --color-secondary-alpha-20: #d0d7de33; + --color-secondary-alpha-30: #d0d7de4b; + --color-secondary-alpha-40: #d0d7de66; + --color-secondary-alpha-50: #d0d7de80; + --color-secondary-alpha-60: #d0d7de99; + --color-secondary-alpha-70: #d0d7deb3; + --color-secondary-alpha-80: #d0d7decc; + --color-secondary-alpha-90: #d0d7dee1; + --color-secondary-button: var(--color-secondary-dark-4); + --color-secondary-hover: var(--color-secondary-dark-5); + --color-secondary-active: var(--color-secondary-dark-6); + + /* Semantic colors */ + --color-red: #db2828; + --color-orange: #f2711c; + --color-yellow: #fbbd08; + --color-olive: #b5cc18; + --color-green: #21ba45; + --color-teal: #00b5ad; + --color-blue: #2185d0; + --color-violet: #6435c9; + --color-purple: #a333c8; + --color-pink: #e03997; + --color-brown: #a5673f; + --color-black: #1d2328; + + /* Light variants */ + --color-red-light: #e45e5e; + --color-orange-light: #f59555; + --color-yellow-light: #fcce46; + --color-olive-light: #d3e942; + --color-green-light: #46de6a; + --color-teal-light: #08fff4; + --color-blue-light: #51a5e3; + --color-violet-light: #8b67d7; + --color-purple-light: #bb64d8; + --color-pink-light: #e86bb1; + --color-brown-light: #c58b66; + --color-black-light: #4b5b68; + + /* Dark variants */ + --color-red-dark-1: #c82121; + --color-orange-dark-1: #e6630d; + --color-yellow-dark-1: #e5ac04; + --color-olive-dark-1: #a3b816; + --color-green-dark-1: #1ea73e; + --color-teal-dark-1: #00a39c; + --color-blue-dark-1: #1e78bb; + --color-violet-dark-1: #5a30b5; + --color-purple-dark-1: #932eb4; + --color-pink-dark-1: #db228a; + --color-brown-dark-1: #955d39; + --color-black-dark-1: #2c3339; + + /* Status colors */ + --color-error-border: #ff818266; + --color-error-bg: #ffebe9; + --color-error-bg-active: #ffcecb; + --color-error-bg-hover: #ffdcd7; + --color-error-text: #d1242f; + --color-success-border: #4ac26b66; + --color-success-bg: #dafbe1; + --color-success-text: #1a7f37; + --color-warning-border: #d4a72c66; + --color-warning-bg: #fff8c5; + --color-warning-text: #9a6700; + --color-info-border: #54aeff66; + --color-info-bg: #ddf4ff; + --color-info-text: #0969da; + + /* Target-based colors */ + --color-body: #ffffff; + --color-box-header: #f1f3f5; + --color-box-body: #ffffff; + --color-box-body-highlight: #ecf5fd; + --color-text-dark: #01050a; + --color-text: #181c21; + --color-text-light: #30363b; + --color-text-light-1: #40474d; + --color-text-light-2: #5b6167; + --color-text-light-3: #747c84; + --color-footer: var(--color-nav-bg); + --color-timeline: #d0d7de; + --color-input-text: var(--color-text-dark); + --color-input-background: #fff; + --color-input-toggle-background: #d0d7de; + --color-input-border: var(--color-secondary-dark-1); + --color-light: #00001706; + --color-light-border: #0000171d; + --color-hover: #00001708; + --color-hover-opaque: #f1f3f5; + --color-active: #00001714; + --color-menu: #f8f9fb; + --color-card: #f8f9fb; + --color-button: #f8f9fb; + --color-code-bg: #fafdff; + --color-shadow: #00001726; + --color-shadow-opaque: #c7ced5; + --color-secondary-bg: #f2f5f8; + --color-expand-button: #cfe8fa; + --color-placeholder-text: var(--color-text-light-3); + --color-tooltip-text: #fbfdff; + --color-tooltip-bg: #000017f0; + --color-nav-bg: #f6f7fa; + --color-nav-hover-bg: var(--color-secondary-light-1); + --color-nav-text: var(--color-text); + --color-secondary-nav-bg: #f9fafb; + --color-label-text: var(--color-text); + --color-label-bg: #949da64b; + --color-label-hover-bg: #949da6a0; + --color-label-active-bg: #949da6ff; + --color-accent: var(--color-primary-light-1); + --color-small-accent: var(--color-primary-light-6); + --color-border: #d0d7de; + + accent-color: var(--color-accent); + color-scheme: light; +} diff --git a/static/images/favicon.svg b/static/images/favicon.svg new file mode 100644 index 0000000..10dcc9a --- /dev/null +++ b/static/images/favicon.svg @@ -0,0 +1,4 @@ + + + B + diff --git a/static/js/flash.js b/static/js/flash.js new file mode 100644 index 0000000..3cdece2 --- /dev/null +++ b/static/js/flash.js @@ -0,0 +1,69 @@ +/** + * Flash messages functionality for blog application. + * + * Handles auto-dismissal and manual closing of flash messages. + */ + +(function() { + 'use strict'; + + const AUTO_DISMISS_DELAY = 5000; // 5 seconds + + function initFlashMessages() { + const flashMessages = document.querySelectorAll('[data-testid^="flash-message-"]'); + + flashMessages.forEach(function(message) { + const closeBtn = message.querySelector('[data-testid="flash-close"]'); + + // Manual close + if (closeBtn) { + closeBtn.addEventListener('click', function() { + dismissMessage(message); + }); + } + + // Auto dismiss after delay + setTimeout(function() { + dismissMessage(message); + }, AUTO_DISMISS_DELAY); + + // Pause auto-dismiss on hover + message.addEventListener('mouseenter', function() { + message.classList.add('paused'); + }); + + message.addEventListener('mouseleave', function() { + message.classList.remove('paused'); + }); + }); + } + + function dismissMessage(message) { + if (message.classList.contains('paused')) { + // Retry after a short delay if paused + setTimeout(function() { + dismissMessage(message); + }, 1000); + return; + } + + message.classList.add('fade-out'); + + setTimeout(function() { + message.remove(); + + // Remove container if empty + const container = document.querySelector('[data-testid="flash-container"]'); + if (container && container.children.length === 0) { + container.remove(); + } + }, 300); + } + + // Initialize on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initFlashMessages); + } else { + initFlashMessages(); + } +})(); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..bd7cd8b --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,163 @@ +/** + * Theme switching functionality for blog application. + * + * Handles theme persistence in localStorage and applies + * the selected theme to the document root element. + * Supports system preference detection and manual theme switching. + */ + +(function() { + 'use strict'; + + const STORAGE_KEY = 'blog-theme'; + const THEME_ATTRIBUTE = 'data-theme'; + const THEME_LIGHT = 'light'; + const THEME_DARK = 'dark'; + + /** + * Get the currently stored theme preference. + * Falls back to system preference if no stored value. + * + * @returns {string} The theme name ('light' or 'dark') + */ + function getStoredTheme() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === THEME_LIGHT || stored === THEME_DARK) { + return stored; + } + } catch (e) { + console.warn('Failed to access localStorage:', e); + } + + return getSystemPreference(); + } + + /** + * Detect system color scheme preference. + * + * @returns {string} 'dark' if system prefers dark mode, 'light' otherwise + */ + function getSystemPreference() { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return THEME_DARK; + } + return THEME_LIGHT; + } + + /** + * Apply the specified theme to the document. + * Updates the data-theme attribute on the html element. + * + * @param {string} theme - The theme to apply ('light' or 'dark') + */ + function applyTheme(theme) { + const html = document.documentElement; + if (html) { + html.setAttribute(THEME_ATTRIBUTE, theme); + } + } + + /** + * Save the theme preference to localStorage. + * + * @param {string} theme - The theme to save ('light' or 'dark') + */ + function saveTheme(theme) { + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch (e) { + console.warn('Failed to save theme to localStorage:', e); + } + } + + /** + * Set and apply the specified theme. + * Updates both the DOM and localStorage. + * + * @param {string} theme - The theme to set ('light' or 'dark') + */ + function setTheme(theme) { + if (theme !== THEME_LIGHT && theme !== THEME_DARK) { + console.warn('Invalid theme:', theme); + return; + } + applyTheme(theme); + saveTheme(theme); + updateThemeIcons(theme); + } + + /** + * Toggle between light and dark themes. + */ + function toggleTheme() { + const currentTheme = document.documentElement.getAttribute(THEME_ATTRIBUTE); + const newTheme = currentTheme === THEME_DARK ? THEME_LIGHT : THEME_DARK; + setTheme(newTheme); + } + + /** + * Update theme toggle icons based on current theme. + * Shows/hides sun/moon icons appropriately. + * + * @param {string} theme - The current theme + */ + function updateThemeIcons(theme) { + const lightIcons = document.querySelectorAll('[data-testid="theme-light-icon"]'); + const darkIcons = document.querySelectorAll('[data-testid="theme-dark-icon"]'); + + lightIcons.forEach(icon => { + icon.style.display = theme === THEME_LIGHT ? 'none' : 'block'; + }); + + darkIcons.forEach(icon => { + icon.style.display = theme === THEME_DARK ? 'none' : 'block'; + }); + } + + /** + * Initialize theme on page load. + * Applies stored theme and sets up event listeners. + */ + function init() { + const theme = getStoredTheme(); + applyTheme(theme); + + document.addEventListener('DOMContentLoaded', function() { + updateThemeIcons(theme); + + const toggleBtn = document.querySelector('[data-testid="theme-toggle"]'); + if (toggleBtn) { + toggleBtn.addEventListener('click', toggleTheme); + } + }); + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) { + try { + const hasUserPreference = localStorage.getItem(STORAGE_KEY); + if (!hasUserPreference) { + const newTheme = e.matches ? THEME_DARK : THEME_LIGHT; + applyTheme(newTheme); + updateThemeIcons(newTheme); + } + } catch (err) { + console.warn('Failed to handle system theme change:', err); + } + }); + } + + const BlogTheme = { + setTheme: setTheme, + toggleTheme: toggleTheme, + getStoredTheme: getStoredTheme, + getSystemPreference: getSystemPreference, + THEME_LIGHT: THEME_LIGHT, + THEME_DARK: THEME_DARK + }; + + if (typeof window !== 'undefined') { + window.BlogTheme = BlogTheme; + } + + init(); +})(); diff --git a/tests/api/test_error_handlers.py b/tests/api/test_error_handlers.py new file mode 100644 index 0000000..7156223 --- /dev/null +++ b/tests/api/test_error_handlers.py @@ -0,0 +1,207 @@ +"""Tests for error handler middleware. + +Tests exception handling and error responses. +""" + +from unittest.mock import patch + +from httpx import ASGITransport, AsyncClient + +from app.domain.exceptions import ( + AlreadyExistsException, + DomainException, + ForbiddenException, + NotFoundException, + ValidationException, +) +from app.main import app_factory + + +class TestDomainExceptionHandlers: + """Test suite for domain exception handlers.""" + + async def test_validation_exception(self) -> None: + """Test ValidationException returns 400.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=ValidationException("Invalid input"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "ValidationException" + assert data["message"] == "Invalid input" + assert "timestamp" in data + assert "path" in data + + async def test_forbidden_exception(self) -> None: + """Test ForbiddenException returns 403.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=ForbiddenException("Access denied"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 403 + data = response.json() + assert data["error"] == "ForbiddenException" + assert data["message"] == "Access denied" + + async def test_not_found_exception(self) -> None: + """Test NotFoundException returns 404.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=NotFoundException("Post not found"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 404 + data = response.json() + assert data["error"] == "NotFoundException" + assert data["message"] == "Post not found" + + async def test_already_exists_exception(self) -> None: + """Test AlreadyExistsException returns 409.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=AlreadyExistsException("Post already exists"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 409 + data = response.json() + assert data["error"] == "AlreadyExistsException" + assert data["message"] == "Post already exists" + + async def test_generic_domain_exception(self) -> None: + """Test generic DomainException returns 500.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=DomainException("Generic error"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 500 + data = response.json() + assert data["error"] == "DomainException" + assert data["message"] == "Generic error" + + +class TestHTTPExceptionHandler: + """Test suite for HTTP exception handling.""" + + async def test_http_exception_structure(self) -> None: + """Test HTTP exception response structure.""" + # Test that exception handler is registered and produces correct format + import json + from dataclasses import dataclass, field + + from starlette.exceptions import HTTPException + + from app.infrastructure.middleware.error_handler import http_exception_handler + + # Create mock request + @dataclass + class MockURL: + path: str = "/test" + + @dataclass + class MockRequest: + url: MockURL = field(default_factory=MockURL) + + exc = HTTPException(status_code=404, detail="Not found") + response = await http_exception_handler(MockRequest(), exc) # type: ignore[arg-type] + + assert response.status_code == 404 + body_bytes: bytes = response.body # type: ignore[assignment] + data: dict[str, object] = json.loads(body_bytes.decode("utf-8")) + assert data["error"] == "HTTPException" + assert "message" in data + + +class TestGenericExceptionHandler: + """Test suite for generic exception handling.""" + + async def test_generic_exception_handler_function(self) -> None: + """Test generic exception handler function directly.""" + import json + from dataclasses import dataclass, field + + from app.infrastructure.middleware.error_handler import ( + generic_exception_handler, + ) + + # Create mock request + @dataclass + class MockURL: + path: str = "/test" + + @dataclass + class MockRequest: + url: MockURL = field(default_factory=MockURL) + + exc = RuntimeError("Internal error") + response = await generic_exception_handler(MockRequest(), exc) # type: ignore[arg-type] + + assert response.status_code == 500 + body_bytes: bytes = response.body # type: ignore[assignment] + data: dict[str, object] = json.loads(body_bytes.decode("utf-8")) + assert data["error"] == "InternalServerError" + assert data["message"] == "An unexpected error occurred" + assert "timestamp" in data + assert "path" in data + + +class TestGetStatusCode: + """Test suite for get_status_code function.""" + + def test_validation_exception_status(self) -> None: + """Test ValidationException maps to 400.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = ValidationException("Invalid") + assert get_status_code(exc) == 400 + + def test_forbidden_exception_status(self) -> None: + """Test ForbiddenException maps to 403.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = ForbiddenException("Forbidden") + assert get_status_code(exc) == 403 + + def test_not_found_exception_status(self) -> None: + """Test NotFoundException maps to 404.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = NotFoundException("Not found") + assert get_status_code(exc) == 404 + + def test_already_exists_exception_status(self) -> None: + """Test AlreadyExistsException maps to 409.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = AlreadyExistsException("Already exists") + assert get_status_code(exc) == 409 + + def test_generic_exception_status(self) -> None: + """Test generic DomainException maps to 500.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = DomainException("Generic") + assert get_status_code(exc) == 500 diff --git a/tests/api/test_posts.py b/tests/api/test_posts.py new file mode 100644 index 0000000..da4c13e --- /dev/null +++ b/tests/api/test_posts.py @@ -0,0 +1,318 @@ +"""API tests for posts endpoints. + +Tests REST API endpoints - focusing on endpoints that don't require +complex Dishka dependency mocking. +""" + +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.application.dtos import PostResponseDTO +from app.domain.exceptions import NotFoundException +from app.main import app_factory + + +@pytest.fixture +def sample_post_dto() -> PostResponseDTO: + """Create a sample post DTO for testing.""" + return PostResponseDTO( + id=uuid4(), + title="Test Post", + content="This is test content for the blog post", + slug="test-post", + author_id="test-user-id", + published=True, + tags=["python", "testing"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +class TestListPublishedPosts: + """Test suite for GET /api/v1/posts/published endpoint.""" + + async def test_list_published_posts( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test listing published posts without authentication.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.published_posts", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/published") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + +class TestSearchPosts: + """Test suite for GET /api/v1/posts/search endpoint.""" + + async def test_search_posts( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test searching posts by query.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.search", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/search?query=test") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + async def test_search_posts_empty_query(self) -> None: + """Test search with empty query returns empty results.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.search", + return_value=[], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/search?query=") + + # Empty query returns 200 with empty results (not 422) + # as query param accepts empty strings + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + +class TestGetPostsByTag: + """Test suite for GET /api/v1/posts/by-tag/{tag} endpoint.""" + + async def test_get_posts_by_tag( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting posts by tag.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.by_tag", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/by-tag/python") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + +class TestGetPostsByAuthor: + """Test suite for GET /api/v1/posts/by-author/{author_id} endpoint.""" + + async def test_get_posts_by_author( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting posts by author.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.by_author", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/by-author/test-user-id") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + +class TestGetPostById: + """Test suite for GET /api/v1/posts/{post_id} endpoint.""" + + async def test_get_post_by_id_success( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting a post by ID.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + return_value=sample_post_dto, + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get(f"/api/v1/posts/{sample_post_dto.id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(sample_post_dto.id) + assert data["title"] == sample_post_dto.title + + async def test_get_post_by_id_not_found(self) -> None: + """Test getting a non-existing post returns 404.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=NotFoundException("Post not found"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get(f"/api/v1/posts/{uuid4()}") + + assert response.status_code == 404 + + +class TestGetPostBySlug: + """Test suite for GET /api/v1/posts/slug/{slug} endpoint.""" + + async def test_get_post_by_slug_success( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting a post by slug.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_slug", + return_value=sample_post_dto, + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/slug/test-post") + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == "test-post" + + async def test_get_post_by_slug_not_found(self) -> None: + """Test getting a non-existing post by slug returns 404.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_slug", + side_effect=NotFoundException("Post not found"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/slug/non-existing-slug") + + assert response.status_code == 404 + + +class TestCreatePostAuth: + """Test suite for POST /api/v1/posts authentication.""" + + async def test_create_post_unauthorized(self) -> None: + """Test post creation without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/posts", + json={ + "title": "Test Post", + "content": "This is test content for the blog post", + }, + ) + + assert response.status_code == 401 + + +class TestUpdatePostAuth: + """Test suite for PATCH /api/v1/posts/{post_id} authentication.""" + + async def test_update_post_unauthorized(self) -> None: + """Test updating post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + f"/api/v1/posts/{uuid4()}", + json={"title": "Updated Title"}, + ) + + assert response.status_code == 401 + + +class TestDeletePostAuth: + """Test suite for DELETE /api/v1/posts/{post_id} authentication.""" + + async def test_delete_post_unauthorized(self) -> None: + """Test deleting post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.delete(f"/api/v1/posts/{uuid4()}") + + assert response.status_code == 401 + + +class TestPublishPostAuth: + """Test suite for POST /api/v1/posts/{post_id}/publish authentication.""" + + async def test_publish_post_unauthorized(self) -> None: + """Test publishing post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post(f"/api/v1/posts/{uuid4()}/publish") + + assert response.status_code == 401 + + +class TestUnpublishPostAuth: + """Test suite for POST /api/v1/posts/{post_id}/unpublish authentication.""" + + async def test_unpublish_post_unauthorized(self) -> None: + """Test unpublishing post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post(f"/api/v1/posts/{uuid4()}/unpublish") + + assert response.status_code == 401 + + +class TestHealthEndpoint: + """Test suite for health check endpoint.""" + + async def test_health_check(self) -> None: + """Test health check endpoint returns ok status.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "app" in data + assert "env" in data + + +class TestRootRedirect: + """Test suite for root redirect.""" + + async def test_root_redirect(self) -> None: + """Test root URL redirects to web UI.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/") + + assert response.status_code == 200 + assert "web/" in response.text diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py new file mode 100644 index 0000000..1fbc49b --- /dev/null +++ b/tests/integration/test_repositories.py @@ -0,0 +1,479 @@ +"""Integration tests for SQLAlchemyPostRepository. + +Tests repository implementation with real in-memory SQLite database. +Note: Some tests involving JSON array operations are skipped for SQLite +as it has limited support compared to PostgreSQL. +""" + +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.entities import Post +from app.domain.repositories import PostRepository +from app.domain.value_objects import Content, Slug, Title +from app.infrastructure.repositories.post import SQLAlchemyPostRepository + + +@pytest.fixture +def repository(db_session: AsyncSession) -> PostRepository: + """Create repository instance for testing.""" + return SQLAlchemyPostRepository(db_session) + + +@pytest.fixture +def sample_post() -> Post: + """Create a sample post for testing.""" + return Post( + id=uuid4(), + title=Title("Test Post Title"), + content=Content("Test content for the blog post"), + slug=Slug("test-post-title"), + author_id="test-author-123", + published=False, + tags=["python", "testing"], + ) + + +@pytest.fixture +def published_post() -> Post: + """Create a published post for testing.""" + post = Post( + id=uuid4(), + title=Title("Published Post"), + content=Content("This is a published post content"), + slug=Slug("published-post"), + author_id="test-author-456", + published=True, + tags=["published", "blog"], + ) + return post + + +class TestPostRepositoryCreate: + """Test suite for post creation operations.""" + + async def test_add_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test adding a new post to the database.""" + await repository.add(sample_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert retrieved.id == sample_post.id + assert retrieved.title.value == sample_post.title.value + assert retrieved.content.value == sample_post.content.value + assert retrieved.slug.value == sample_post.slug.value + assert retrieved.author_id == sample_post.author_id + assert retrieved.published == sample_post.published + assert retrieved.tags == sample_post.tags + + async def test_get_by_id_existing( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving an existing post by ID.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.get_by_id(sample_post.id) + + assert result is not None + assert result.id == sample_post.id + + async def test_get_by_id_non_existing(self, repository: PostRepository) -> None: + """Test retrieving a non-existing post returns None.""" + non_existing_id = uuid4() + + result = await repository.get_by_id(non_existing_id) + + assert result is None + + +class TestPostRepositoryGetAll: + """Test suite for retrieving all posts.""" + + async def test_get_all_empty(self, repository: PostRepository) -> None: + """Test retrieving all posts when database is empty.""" + results = await repository.get_all() + + assert results == [] + + async def test_get_all_multiple_posts( + self, + repository: PostRepository, + sample_post: Post, + published_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving all posts returns all entries.""" + await repository.add(sample_post) + await repository.add(published_post) + await db_session.commit() + + results = await repository.get_all() + + assert len(results) == 2 + ids = {post.id for post in results} + assert sample_post.id in ids + assert published_post.id in ids + + +class TestPostRepositoryUpdate: + """Test suite for post update operations.""" + + async def test_update_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test updating an existing post.""" + await repository.add(sample_post) + await db_session.commit() + + # Refresh to get latest state + await db_session.flush() + + # Create a new post instance with updated values + updated_post = Post( + id=sample_post.id, + title=Title("Updated Title"), + content=Content("Updated content for the post"), + slug=sample_post.slug, + author_id=sample_post.author_id, + published=sample_post.published, + tags=["updated", "tags"], + created_at=sample_post.created_at, + updated_at=sample_post.updated_at, + ) + + await repository.update(updated_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert retrieved.title.value == "Updated Title" + assert retrieved.content.value == "Updated content for the post" + assert retrieved.tags == ["updated", "tags"] + + async def test_update_publishes_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test that update reflects published status change.""" + await repository.add(sample_post) + await db_session.commit() + await db_session.flush() + + # Create updated post with published=True + updated_post = Post( + id=sample_post.id, + title=sample_post.title, + content=sample_post.content, + slug=sample_post.slug, + author_id=sample_post.author_id, + published=True, + tags=sample_post.tags, + created_at=sample_post.created_at, + updated_at=sample_post.updated_at, + ) + + await repository.update(updated_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert retrieved.published is True + + +class TestPostRepositoryDelete: + """Test suite for post deletion operations.""" + + async def test_delete_existing_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test deleting an existing post.""" + await repository.add(sample_post) + await db_session.commit() + + await repository.delete(sample_post.id) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + assert retrieved is None + + async def test_delete_non_existing_post(self, repository: PostRepository) -> None: + """Test deleting a non-existing post does not raise error.""" + non_existing_id = uuid4() + + await repository.delete(non_existing_id) + + +class TestPostRepositoryExists: + """Test suite for post existence checks.""" + + async def test_exists_true( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test exists returns True for existing post.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.exists(sample_post.id) + + assert result is True + + async def test_exists_false(self, repository: PostRepository) -> None: + """Test exists returns False for non-existing post.""" + non_existing_id = uuid4() + + result = await repository.exists(non_existing_id) + + assert result is False + + +class TestPostRepositoryGetBySlug: + """Test suite for slug-based retrieval.""" + + async def test_get_by_slug_existing( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving post by existing slug.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.get_by_slug(sample_post.slug.value) + + assert result is not None + assert result.id == sample_post.id + assert result.slug.value == sample_post.slug.value + + async def test_get_by_slug_non_existing(self, repository: PostRepository) -> None: + """Test retrieving by non-existing slug returns None.""" + result = await repository.get_by_slug("non-existing-slug") + + assert result is None + + +class TestPostRepositoryGetByAuthor: + """Test suite for author-based retrieval.""" + + async def test_get_by_author( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving posts by author ID.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.get_by_author(sample_post.author_id) + + assert len(results) == 1 + assert results[0].id == sample_post.id + + async def test_get_by_author_empty(self, repository: PostRepository) -> None: + """Test retrieving posts by author with no posts.""" + results = await repository.get_by_author("non-existing-author") + + assert results == [] + + +class TestPostRepositoryGetPublished: + """Test suite for published posts retrieval.""" + + async def test_get_published_only( + self, + repository: PostRepository, + sample_post: Post, + published_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving only published posts.""" + await repository.add(sample_post) + await repository.add(published_post) + await db_session.commit() + + results = await repository.get_published() + + assert len(results) == 1 + assert results[0].id == published_post.id + + +class TestPostRepositoryGetByTag: + """Test suite for tag-based retrieval. + + Note: These tests are skipped for SQLite as it has limited JSON support. + """ + + @pytest.mark.skip(reason="SQLite has limited JSON array support") + async def test_get_by_tag(self, repository: PostRepository, sample_post: Post) -> None: + """Test retrieving posts by tag.""" + pass + + @pytest.mark.skip(reason="SQLite has limited JSON array support") + async def test_get_by_tag_multiple_posts(self, repository: PostRepository) -> None: + """Test retrieving multiple posts with same tag.""" + pass + + @pytest.mark.skip(reason="SQLite has limited JSON array support") + async def test_get_by_tag_not_found( + self, + repository: PostRepository, + sample_post: Post, + ) -> None: + """Test retrieving by non-existing tag returns empty list.""" + pass + + +class TestPostRepositorySlugExists: + """Test suite for slug existence checks.""" + + async def test_slug_exists_true( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test slug_exists returns True for existing slug.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.slug_exists(sample_post.slug.value) + + assert result is True + + async def test_slug_exists_false(self, repository: PostRepository) -> None: + """Test slug_exists returns False for non-existing slug.""" + result = await repository.slug_exists("non-existing-slug") + + assert result is False + + +class TestPostRepositorySearch: + """Test suite for post search functionality.""" + + async def test_search_by_title( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test searching posts by title.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("Test Post") + + assert len(results) == 1 + assert results[0].id == sample_post.id + + async def test_search_by_content( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test searching posts by content.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("blog post") + + assert len(results) == 1 + assert results[0].id == sample_post.id + + async def test_search_case_insensitive( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test search is case insensitive.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("TEST POST") + + assert len(results) == 1 + + async def test_search_no_results( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test search with no matching results.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("xyz123nonexistent") + + assert results == [] + + @pytest.mark.skip(reason="SQLite behavior without ORDER BY is non-deterministic") + async def test_search_with_limit( + self, + repository: PostRepository, + db_session: AsyncSession, + ) -> None: + """Test search with limit - skipped for SQLite.""" + pass + + @pytest.mark.skip(reason="SQLite order non-deterministic without ORDER BY") + async def test_search_with_offset( + self, + repository: PostRepository, + db_session: AsyncSession, + ) -> None: + """Test search with offset.""" + pass + + +class TestPostRepositoryConversion: + """Test suite for domain/ORM conversion.""" + + async def test_to_domain_preserves_all_fields( + self, + repository: SQLAlchemyPostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test that domain conversion preserves all post fields.""" + await repository.add(sample_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert isinstance(retrieved.id, UUID) + assert retrieved.title.value == sample_post.title.value + assert retrieved.content.value == sample_post.content.value + assert retrieved.slug.value == sample_post.slug.value + assert retrieved.author_id == sample_post.author_id + assert retrieved.published == sample_post.published + assert retrieved.tags == sample_post.tags diff --git a/tests/unit/application/test_list_posts.py b/tests/unit/application/test_list_posts.py new file mode 100644 index 0000000..7bb1fc1 --- /dev/null +++ b/tests/unit/application/test_list_posts.py @@ -0,0 +1,225 @@ +"""Tests for ListPostsUseCase. + +Tests listing posts with various filters. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from app.application.dtos import PostResponseDTO +from app.application.interfaces import TransactionManager +from app.application.use_cases.list_posts import ListPostsUseCase +from app.domain.entities import Post +from app.domain.repositories import PostRepository +from app.domain.value_objects import Content, Slug, Title + + +@pytest.fixture +def mock_post_repository() -> MagicMock: + """Create mock post repository.""" + return MagicMock(spec=PostRepository) + + +@pytest.fixture +def mock_transaction_manager() -> MagicMock: + """Create mock transaction manager.""" + return MagicMock(spec=TransactionManager) + + +@pytest.fixture +def list_use_case( + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, +) -> ListPostsUseCase: + """Create list use case with mocked dependencies.""" + return ListPostsUseCase(mock_post_repository, mock_transaction_manager) + + +@pytest.fixture +def sample_posts() -> list[Post]: + """Create sample posts for testing.""" + return [ + Post( + id=uuid4(), + title=Title(f"Post {i}"), + content=Content(f"Content for post number {i}"), + slug=Slug(f"post-{i}"), + author_id="author-123", + published=i % 2 == 0, + tags=["python"] if i == 0 else [], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + for i in range(3) + ] + + +class TestAllPosts: + """Test suite for all_posts method.""" + + async def test_all_posts( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting all posts.""" + mock_post_repository.get_all = AsyncMock(return_value=sample_posts) + + result = await list_use_case.all_posts() + + assert len(result) == 3 + assert all(isinstance(dto, PostResponseDTO) for dto in result) + mock_post_repository.get_all.assert_called_once() + + async def test_all_posts_empty( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting all posts when empty.""" + mock_post_repository.get_all = AsyncMock(return_value=[]) + + result = await list_use_case.all_posts() + + assert result == [] + + +class TestPublishedPosts: + """Test suite for published_posts method.""" + + async def test_published_posts( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting published posts.""" + published = [p for p in sample_posts if p.published] + mock_post_repository.get_published = AsyncMock(return_value=published) + + result = await list_use_case.published_posts() + + assert len(result) == 2 # posts 0 and 2 are published + assert all(dto.published for dto in result) + + async def test_published_posts_with_limit_offset( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting published posts with pagination.""" + mock_post_repository.get_published = AsyncMock(return_value=[]) + + result = await list_use_case.published_posts(limit=5, offset=10) + + mock_post_repository.get_published.assert_called_once_with(limit=5, offset=10) + assert result == [] + + +class TestByAuthor: + """Test suite for by_author method.""" + + async def test_by_author( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting posts by author.""" + mock_post_repository.get_by_author = AsyncMock(return_value=sample_posts) + + result = await list_use_case.by_author("author-123") + + assert len(result) == 3 + mock_post_repository.get_by_author.assert_called_once_with( + "author-123", limit=None, offset=None + ) + + async def test_by_author_with_pagination( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting posts by author with pagination.""" + mock_post_repository.get_by_author = AsyncMock(return_value=[]) + + await list_use_case.by_author("author-123", limit=5, offset=0) + + mock_post_repository.get_by_author.assert_called_once_with("author-123", limit=5, offset=0) + + +class TestByTag: + """Test suite for by_tag method.""" + + async def test_by_tag( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting posts by tag.""" + tagged_posts = [sample_posts[0]] + mock_post_repository.get_by_tag = AsyncMock(return_value=tagged_posts) + + result = await list_use_case.by_tag("python") + + assert len(result) == 1 + assert "python" in result[0].tags + + async def test_by_tag_empty( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting posts by non-existent tag.""" + mock_post_repository.get_by_tag = AsyncMock(return_value=[]) + + result = await list_use_case.by_tag("nonexistent") + + assert result == [] + + +class TestSearch: + """Test suite for search method.""" + + async def test_search( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test searching posts.""" + mock_post_repository.search = AsyncMock(return_value=sample_posts) + + result = await list_use_case.search("test query") + + assert len(result) == 3 + mock_post_repository.search.assert_called_once_with("test query", limit=None, offset=None) + + async def test_search_with_pagination( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test searching posts with pagination.""" + mock_post_repository.search = AsyncMock(return_value=[]) + + await list_use_case.search("query", limit=10, offset=5) + + mock_post_repository.search.assert_called_once_with("query", limit=10, offset=5) + + async def test_search_no_results( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test searching with no matches.""" + mock_post_repository.search = AsyncMock(return_value=[]) + + result = await list_use_case.search("xyz123") + + assert result == [] diff --git a/tests/unit/application/test_publish_post.py b/tests/unit/application/test_publish_post.py new file mode 100644 index 0000000..9e05644 --- /dev/null +++ b/tests/unit/application/test_publish_post.py @@ -0,0 +1,174 @@ +"""Tests for PublishPostUseCase. + +Tests publishing and unpublishing posts with authorization. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from app.application.dtos import PostResponseDTO +from app.application.interfaces import TransactionManager +from app.application.use_cases.publish_post import PublishPostUseCase +from app.domain.entities import Post +from app.domain.exceptions import ForbiddenException, NotFoundException +from app.domain.repositories import PostRepository +from app.domain.value_objects import Content, Slug, Title + + +@pytest.fixture +def mock_post_repository() -> MagicMock: + """Create mock post repository.""" + return MagicMock(spec=PostRepository) + + +@pytest.fixture +def mock_transaction_manager() -> MagicMock: + """Create mock transaction manager.""" + tx = MagicMock(spec=TransactionManager) + tx.commit = AsyncMock() + return tx + + +@pytest.fixture +def publish_use_case( + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, +) -> PublishPostUseCase: + """Create publish use case with mocked dependencies.""" + return PublishPostUseCase(mock_post_repository, mock_transaction_manager) + + +@pytest.fixture +def sample_post() -> Post: + """Create a sample unpublished post.""" + return Post( + id=uuid4(), + title=Title("Test Post"), + content=Content("Test content"), + slug=Slug("test-post"), + author_id="author-123", + published=False, + tags=["test"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +@pytest.fixture +def published_post() -> Post: + """Create a sample published post.""" + post = Post( + id=uuid4(), + title=Title("Published Post"), + content=Content("Published content"), + slug=Slug("published-post"), + author_id="author-123", + published=True, + tags=["published"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return post + + +class TestPublishPost: + """Test suite for publish method.""" + + async def test_publish_success( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, + sample_post: Post, + ) -> None: + """Test successful post publishing.""" + mock_post_repository.get_by_id = AsyncMock(return_value=sample_post) + mock_post_repository.update = AsyncMock() + + result = await publish_use_case.publish(sample_post.id, sample_post.author_id) + + assert isinstance(result, PostResponseDTO) + assert result.id == sample_post.id + assert result.published is True + mock_post_repository.update.assert_called_once() + mock_transaction_manager.commit.assert_called_once() + + async def test_publish_not_found( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test publishing non-existent post raises NotFoundException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=None) + + with pytest.raises(NotFoundException) as exc_info: + await publish_use_case.publish(uuid4(), "author-123") + + assert "not found" in str(exc_info.value).lower() + + async def test_publish_forbidden( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + sample_post: Post, + ) -> None: + """Test publishing other user's post raises ForbiddenException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=sample_post) + + with pytest.raises(ForbiddenException) as exc_info: + await publish_use_case.publish(sample_post.id, "different-author") + + assert "own posts" in str(exc_info.value).lower() + + +class TestUnpublishPost: + """Test suite for unpublish method.""" + + async def test_unpublish_success( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, + published_post: Post, + ) -> None: + """Test successful post unpublishing.""" + mock_post_repository.get_by_id = AsyncMock(return_value=published_post) + mock_post_repository.update = AsyncMock() + + result = await publish_use_case.unpublish(published_post.id, published_post.author_id) + + assert isinstance(result, PostResponseDTO) + assert result.id == published_post.id + assert result.published is False + mock_post_repository.update.assert_called_once() + mock_transaction_manager.commit.assert_called_once() + + async def test_unpublish_not_found( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test unpublishing non-existent post raises NotFoundException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=None) + + with pytest.raises(NotFoundException) as exc_info: + await publish_use_case.unpublish(uuid4(), "author-123") + + assert "not found" in str(exc_info.value).lower() + + async def test_unpublish_forbidden( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + published_post: Post, + ) -> None: + """Test unpublishing other user's post raises ForbiddenException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=published_post) + + with pytest.raises(ForbiddenException) as exc_info: + await publish_use_case.unpublish(published_post.id, "different-author") + + assert "own posts" in str(exc_info.value).lower() diff --git a/tests/unit/infrastructure/test_transaction_manager.py b/tests/unit/infrastructure/test_transaction_manager.py new file mode 100644 index 0000000..6b0154b --- /dev/null +++ b/tests/unit/infrastructure/test_transaction_manager.py @@ -0,0 +1,46 @@ +"""Tests for DI transaction manager. + +Tests SessionTransactionManager implementation. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.di.transaction_manager import SessionTransactionManager + + +@pytest.fixture +def mock_session() -> MagicMock: + """Create mock async session.""" + session = MagicMock(spec=AsyncSession) + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +@pytest.fixture +def transaction_manager(mock_session: MagicMock) -> SessionTransactionManager: + """Create transaction manager with mock session.""" + return SessionTransactionManager(mock_session) + + +class TestSessionTransactionManager: + """Test suite for SessionTransactionManager.""" + + async def test_commit( + self, transaction_manager: SessionTransactionManager, mock_session: MagicMock + ) -> None: + """Test commit calls session commit.""" + await transaction_manager.commit() + + mock_session.commit.assert_called_once() + + async def test_rollback( + self, transaction_manager: SessionTransactionManager, mock_session: MagicMock + ) -> None: + """Test rollback calls session rollback.""" + await transaction_manager.rollback() + + mock_session.rollback.assert_called_once()