Compare commits
8 Commits
6a528bcbb9
...
41b6698c55
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b6698c55 | |||
| b37ec1390d | |||
| b1878e470f | |||
| 4eee261107 | |||
| 0cb706e54b | |||
| 2aed9f5c8a | |||
| e2802d83f2 | |||
| ca4e8877a5 |
173
AGENTS.md
173
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
|
||||
<button data-testid="btn-create-post" class="btn btn-primary">
|
||||
Create Post
|
||||
</button>
|
||||
|
||||
<article data-testid="post-card-{{ post.id }}" class="card">
|
||||
<h2 data-testid="post-title">{{ post.title }}</h2>
|
||||
<p data-testid="post-content">{{ post.content }}</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
### 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 `<html>` 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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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"])
|
||||
<Role.ADMIN: 'admin'>
|
||||
"""
|
||||
if Role.ADMIN.value in roles:
|
||||
return Role.ADMIN
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,16 @@ 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_(
|
||||
|
||||
70
app/main.py
70
app/main.py
@@ -1,4 +1,8 @@
|
||||
"""Application entry point with DDD architecture."""
|
||||
"""Application entry point with DDD architecture.
|
||||
|
||||
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
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -8,6 +12,8 @@ from dishka import make_async_container
|
||||
from dishka.integrations.fastapi import setup_dishka
|
||||
from fastapi import FastAPI
|
||||
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,16 @@ 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, call_next):
|
||||
"""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 +90,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='<meta http-equiv="refresh" content="0;url=/web/">', 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 +123,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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
182
app/presentation/templates/base.html
Normal file
182
app/presentation/templates/base.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-testid="html-root">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform built with FastAPI{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}">
|
||||
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}">
|
||||
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="{% block canonical_url %}{{ request.url }}{% endblock %}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:url" content="{% block og_url %}{{ request.url }}{% endblock %}">
|
||||
<meta property="og:title" content="{% block og_title %}{{ self.title() }}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
<meta property="og:image" content="{% block og_image %}{{ request.base_url }}static/images/og-default.png{% endblock %}">
|
||||
<meta property="og:site_name" content="Blog">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="{% block twitter_card %}summary_large_image{% endblock %}">
|
||||
<meta property="twitter:url" content="{% block twitter_url %}{{ request.url }}{% endblock %}">
|
||||
<meta property="twitter:title" content="{% block twitter_title %}{{ self.title() }}{% endblock %}">
|
||||
<meta property="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
<meta property="twitter:image" content="{% block twitter_image %}{{ self.og_image() }}{% endblock %}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
|
||||
<link rel="alternate icon" href="/static/images/favicon.ico">
|
||||
|
||||
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/base.css" data-testid="base-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/components.css" data-testid="components-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/layout.css" data-testid="layout-stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body data-testid="body">
|
||||
{% include "partials/header.html" %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if flash_messages %}
|
||||
<div class="flash-container" data-testid="flash-container">
|
||||
{% for msg in flash_messages %}
|
||||
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
|
||||
<span class="flash-text" data-testid="flash-text">{{ msg.message }}</span>
|
||||
<button type="button" class="flash-close" data-testid="flash-close" aria-label="Close message">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<main class="main-wrapper" data-testid="main-content">
|
||||
<div class="container" data-testid="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% include "partials/footer.html" %}
|
||||
|
||||
<script src="/static/js/theme.js" data-testid="theme-script"></script>
|
||||
<script src="/static/js/flash.js" data-testid="flash-script"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
.flash-container {
|
||||
position: fixed;
|
||||
top: 5rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.flash-message.fade-out {
|
||||
animation: slideOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background-color: var(--color-success-bg);
|
||||
border-color: var(--color-success-border);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background-color: var(--color-error-bg);
|
||||
border-color: var(--color-error-border);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.flash-warning {
|
||||
background-color: var(--color-warning-bg);
|
||||
border-color: var(--color-warning-border);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background-color: var(--color-info-bg);
|
||||
border-color: var(--color-info-border);
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
|
||||
.flash-text {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.flash-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.flash-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.flash-container {
|
||||
top: 4rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
111
app/presentation/templates/pages/error.html
Normal file
111
app/presentation/templates/pages/error.html
Normal file
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ error_code }} - {{ error_title }}{% endblock %}
|
||||
{% block meta_description %}{{ error_message }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-page" data-testid="error-page">
|
||||
<div class="error-content" data-testid="error-content">
|
||||
<div class="error-icon" data-testid="error-icon">
|
||||
{% if error_code == 404 %}
|
||||
🔍
|
||||
{% elif error_code == 403 %}
|
||||
🚫
|
||||
{% elif error_code == 500 %}
|
||||
⚠️
|
||||
{% else %}
|
||||
❌
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="error-code" data-testid="error-code">{{ error_code }}</h1>
|
||||
<h2 class="error-title" data-testid="error-title">{{ error_title }}</h2>
|
||||
<p class="error-message" data-testid="error-message">{{ error_message }}</p>
|
||||
|
||||
<div class="error-actions" data-testid="error-actions">
|
||||
<a href="/" class="btn btn-primary" data-testid="btn-error-home">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M2 8L8 2L14 8M4 6V13H12V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Go Home
|
||||
</a>
|
||||
|
||||
{% if error_code == 403 %}
|
||||
<a href="/auth/login" class="btn" data-testid="btn-error-login">
|
||||
Sign In
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<button onclick="window.history.back()" class="btn btn-ghost" data-testid="btn-error-back">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-dark);
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-text-light);
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
102
app/presentation/templates/pages/index.html
Normal file
102
app/presentation/templates/pages/index.html
Normal file
@@ -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 %}
|
||||
<section class="page-header" data-testid="page-header-home">
|
||||
<div class="page-header-flex">
|
||||
<div data-testid="page-header-content">
|
||||
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
|
||||
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Write a Post
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if posts %}
|
||||
<section class="post-list" data-testid="post-list">
|
||||
{% for post in posts %}
|
||||
<article class="card post-card" data-testid="post-card-{{ post.id }}">
|
||||
<div class="post-card-header" data-testid="post-card-header-{{ post.id }}">
|
||||
<h2 class="post-card-title" data-testid="post-title-{{ post.id }}">
|
||||
<a href="/web/posts/{{ post.slug.value }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
|
||||
</h2>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
|
||||
{% else %}
|
||||
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="post-card-meta" data-testid="post-meta-{{ post.id }}">
|
||||
<span class="post-card-meta-item" data-testid="post-author-{{ post.id }}">
|
||||
<span class="avatar avatar-sm" data-testid="post-author-avatar-{{ post.id }}">{{ post.author_id[0]|upper }}</span>
|
||||
<span data-testid="post-author-name-{{ post.id }}">{{ post.author_id }}</span>
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="post-date-{{ post.id }}">
|
||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
|
||||
{{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="post-card-footer" data-testid="post-card-footer-{{ post.id }}">
|
||||
<div class="post-card-tags" data-testid="post-tags-{{ post.id }}">
|
||||
{% for tag in post.tags %}
|
||||
<span class="tag" data-testid="post-tag-{{ post.id }}-{{ loop.index }}">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/web/posts/{{ post.slug.value }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
|
||||
Read more
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
|
||||
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
|
||||
{% if has_prev %}
|
||||
<a href="/?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
|
||||
{% else %}
|
||||
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
|
||||
|
||||
{% if has_next %}
|
||||
<a href="/?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
|
||||
{% else %}
|
||||
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state" data-testid="empty-state">
|
||||
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
|
||||
<h3 data-testid="empty-state-title">No posts yet</h3>
|
||||
<p data-testid="empty-state-description">Be the first to write a post!</p>
|
||||
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
85
app/presentation/templates/pages/post_detail.html
Normal file
85
app/presentation/templates/pages/post_detail.html
Normal file
@@ -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 %}
|
||||
<article class="post-detail" data-testid="post-detail">
|
||||
<header class="post-detail-header" data-testid="post-detail-header">
|
||||
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
|
||||
|
||||
<div class="post-detail-meta" data-testid="post-detail-meta">
|
||||
<span class="post-card-meta-item" data-testid="post-detail-author">
|
||||
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
|
||||
<span data-testid="post-detail-author-name">{{ post.author_id }}</span>
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="post-detail-date">
|
||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success" data-testid="post-detail-status">Published</span>
|
||||
{% else %}
|
||||
<span class="badge" data-testid="post-detail-status">Draft</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-detail-content" data-testid="post-detail-content">
|
||||
{{ post.content.value|nl2br }}
|
||||
</div>
|
||||
|
||||
<footer class="post-detail-footer" data-testid="post-detail-footer">
|
||||
<div class="post-detail-tags" data-testid="post-detail-tags">
|
||||
{% for tag in post.tags %}
|
||||
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="divider" data-testid="post-detail-divider"></div>
|
||||
|
||||
<div class="flex justify-between items-center" data-testid="post-detail-actions">
|
||||
<a href="/" class="btn" data-testid="btn-back-to-posts">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to posts
|
||||
</a>
|
||||
|
||||
{% if can_edit or can_delete %}
|
||||
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||
{% if can_edit %}
|
||||
<a href="/web/posts/{{ post.slug.value }}/edit" class="btn" data-testid="btn-edit-post">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form action="/web/posts/{{ post.slug.value }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
|
||||
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
{% endblock %}
|
||||
99
app/presentation/templates/pages/post_form.html
Normal file
99
app/presentation/templates/pages/post_form.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header" data-testid="page-header-form">
|
||||
<h1 class="page-title" data-testid="page-title-form">
|
||||
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{% if is_edit %}/web/posts/{{ post.slug.value }}/edit{% else %}/web/posts/new{% endif %}"
|
||||
class="card"
|
||||
data-testid="form-post"
|
||||
>
|
||||
<div class="card-body" data-testid="form-post-body">
|
||||
<div class="form-group" data-testid="form-group-title">
|
||||
<label for="title" class="form-label form-label-required" data-testid="label-title">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
class="input input-lg"
|
||||
value="{% if post %}{{ post.title.value }}{% endif %}"
|
||||
placeholder="Enter post title"
|
||||
required
|
||||
data-testid="input-title"
|
||||
>
|
||||
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-content">
|
||||
<label for="content" class="form-label form-label-required" data-testid="label-content">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
class="textarea"
|
||||
rows="12"
|
||||
placeholder="Write your post content here..."
|
||||
required
|
||||
data-testid="textarea-content"
|
||||
>{% if post %}{{ post.content.value }}{% endif %}</textarea>
|
||||
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-tags">
|
||||
<label for="tags" class="form-label" data-testid="label-tags">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
class="input"
|
||||
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
|
||||
placeholder="python, fastapi, tutorial"
|
||||
data-testid="input-tags"
|
||||
>
|
||||
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-published">
|
||||
<label class="form-label" data-testid="label-published">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="published"
|
||||
value="true"
|
||||
{% if post and post.published %}checked{% endif %}
|
||||
data-testid="checkbox-published"
|
||||
>
|
||||
Publish immediately
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer" data-testid="form-post-footer">
|
||||
<div class="flex justify-between items-center" data-testid="form-actions">
|
||||
<a href="{% if is_edit %}/web/posts/{{ post.slug.value }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2" data-testid="form-submit-actions">
|
||||
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
|
||||
Save as Draft
|
||||
</button>
|
||||
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
|
||||
{% if is_edit %}Update Post{% else %}Publish Post{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
125
app/presentation/templates/pages/profile.html
Normal file
125
app/presentation/templates/pages/profile.html
Normal file
@@ -0,0 +1,125 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - {{ user.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" data-testid="page-header-profile">
|
||||
<h1 class="page-title" data-testid="page-title-profile">User Profile</h1>
|
||||
</div>
|
||||
|
||||
<div class="card" data-testid="profile-card">
|
||||
<div class="card-body" data-testid="profile-card-body">
|
||||
<div class="profile-header" data-testid="profile-header">
|
||||
<div class="avatar avatar-lg" data-testid="profile-avatar">
|
||||
{{ user.username[0]|upper }}
|
||||
</div>
|
||||
<div class="profile-info" data-testid="profile-info">
|
||||
<h2 class="profile-username" data-testid="profile-username">{{ user.username }}</h2>
|
||||
<span class="badge {% if user_role == 'admin' %}badge-primary{% else %}badge-success{% endif %}" data-testid="profile-role">
|
||||
{{ user_role|upper }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" data-testid="profile-divider"></div>
|
||||
|
||||
<div class="profile-details" data-testid="profile-details">
|
||||
<div class="profile-field" data-testid="profile-field-email">
|
||||
<span class="profile-label" data-testid="profile-label-email">Email:</span>
|
||||
<span class="profile-value" data-testid="profile-value-email">{{ user.email or 'Not provided' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="profile-field" data-testid="profile-field-userid">
|
||||
<span class="profile-label" data-testid="profile-label-userid">User ID:</span>
|
||||
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
|
||||
</div>
|
||||
|
||||
{% if user.first_name or user.last_name %}
|
||||
<div class="profile-field" data-testid="profile-field-name">
|
||||
<span class="profile-label" data-testid="profile-label-name">Name:</span>
|
||||
<span class="profile-value" data-testid="profile-value-name">
|
||||
{{ user.first_name or '' }} {{ user.last_name or '' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer" data-testid="profile-card-footer">
|
||||
<div class="flex justify-between items-center" data-testid="profile-actions">
|
||||
<a href="/web/" class="btn" data-testid="btn-back-home">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Home
|
||||
</a>
|
||||
|
||||
{% if can_create %}
|
||||
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-profile">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
New Post
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-light);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.profile-value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
14
app/presentation/templates/partials/footer.html
Normal file
14
app/presentation/templates/partials/footer.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<footer class="site-footer" data-testid="site-footer">
|
||||
<div class="container" data-testid="footer-container">
|
||||
<div class="footer-copyright" data-testid="footer-copyright">
|
||||
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span>
|
||||
</div>
|
||||
|
||||
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
|
||||
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a>
|
||||
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a>
|
||||
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a>
|
||||
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
337
app/presentation/templates/partials/header.html
Normal file
337
app/presentation/templates/partials/header.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<header class="site-header" data-testid="site-header">
|
||||
<div class="container" data-testid="header-container">
|
||||
<a href="/" class="site-logo" data-testid="nav-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="logo-icon">
|
||||
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
|
||||
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-testid="logo-text">Blog</span>
|
||||
</a>
|
||||
|
||||
{% include "partials/nav.html" %}
|
||||
|
||||
<div class="header-actions" data-testid="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-menu-btn"
|
||||
data-testid="mobile-menu-toggle"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-nav"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="menu-icon-open">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="menu-icon-close" style="display: none;">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-testid="theme-toggle"
|
||||
aria-label="Toggle dark mode"
|
||||
title="Toggle dark mode"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
|
||||
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-dark-icon" style="display: none;">
|
||||
<path d="M17.293 13.293A8 8 0 116.707 2.707a8.003 8.003 0 0010.586 10.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{% if user %}
|
||||
<div class="user-menu" data-testid="user-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-toggle"
|
||||
data-testid="user-menu-toggle"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="avatar avatar-sm" data-testid="user-avatar">{{ user.username[0]|upper }}</span>
|
||||
<span class="user-name" data-testid="user-name">{{ user.username }}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
|
||||
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="user-menu-dropdown" data-testid="user-menu-dropdown">
|
||||
<a href="/web/profile" class="user-menu-item" data-testid="user-menu-profile">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
{% if can_create %}
|
||||
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
New Post
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="user-menu-divider" data-testid="user-menu-divider"></div>
|
||||
<a href="/auth/logout" class="user-menu-item user-menu-item-danger" data-testid="user-menu-logout">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
|
||||
Sign In
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-toggle:hover {
|
||||
background-color: var(--color-hover);
|
||||
border-color: var(--color-secondary-dark-2);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.user-menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 180px;
|
||||
background-color: var(--color-box-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.user-menu:hover .user-menu-dropdown,
|
||||
.user-menu-toggle:focus + .user-menu-dropdown,
|
||||
.user-menu-dropdown:hover {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-item:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.user-menu-item:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background-color: var(--color-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-menu-item-danger {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.user-menu-item-danger:hover {
|
||||
background-color: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.user-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.mobile-menu-btn[aria-expanded="true"] {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-contrast);
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 4rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-body);
|
||||
z-index: 99;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-nav.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-nav .nav-link {
|
||||
display: block;
|
||||
padding: 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-nav .nav-link:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
|
||||
<a href="/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
|
||||
Home
|
||||
</a>
|
||||
<a href="/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
|
||||
About
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const menuBtn = document.querySelector('[data-testid="mobile-menu-toggle"]');
|
||||
const mobileNav = document.getElementById('mobile-nav');
|
||||
const menuIconOpen = menuBtn?.querySelector('.menu-icon-open');
|
||||
const menuIconClose = menuBtn?.querySelector('.menu-icon-close');
|
||||
|
||||
function toggleMenu() {
|
||||
if (!mobileNav || !menuBtn) return;
|
||||
|
||||
const isOpen = mobileNav.classList.toggle('is-open');
|
||||
menuBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
|
||||
if (menuIconOpen && menuIconClose) {
|
||||
menuIconOpen.style.display = isOpen ? 'none' : 'block';
|
||||
menuIconClose.style.display = isOpen ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Prevent body scroll when menu is open
|
||||
document.body.style.overflow = isOpen ? 'hidden' : '';
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (!mobileNav || !menuBtn) return;
|
||||
|
||||
mobileNav.classList.remove('is-open');
|
||||
menuBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
if (menuIconOpen && menuIconClose) {
|
||||
menuIconOpen.style.display = 'block';
|
||||
menuIconClose.style.display = 'none';
|
||||
}
|
||||
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (menuBtn) {
|
||||
menuBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// Close menu when clicking on a link
|
||||
if (mobileNav) {
|
||||
mobileNav.querySelectorAll('a').forEach(function(link) {
|
||||
link.addEventListener('click', closeMenu);
|
||||
});
|
||||
}
|
||||
|
||||
// Close menu on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobileNav?.classList.contains('is-open')) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (mobileNav?.classList.contains('is-open') &&
|
||||
!mobileNav.contains(e.target) &&
|
||||
!menuBtn?.contains(e.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
11
app/presentation/templates/partials/nav.html
Normal file
11
app/presentation/templates/partials/nav.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
|
||||
<a href="/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
|
||||
Home
|
||||
</a>
|
||||
<a href="/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
|
||||
About
|
||||
</a>
|
||||
</nav>
|
||||
15
app/presentation/web/__init__.py
Normal file
15
app/presentation/web/__init__.py
Normal file
@@ -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"]
|
||||
157
app/presentation/web/auth.py
Normal file
157
app/presentation/web/auth.py
Normal file
@@ -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
|
||||
213
app/presentation/web/deps.py
Normal file
213
app/presentation/web/deps.py
Normal file
@@ -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)
|
||||
194
app/presentation/web/error_handlers.py
Normal file
194
app/presentation/web/error_handlers.py
Normal file
@@ -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)
|
||||
175
app/presentation/web/flash.py
Normal file
175
app/presentation/web/flash.py
Normal file
@@ -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()
|
||||
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)
|
||||
519
app/presentation/web/routes.py
Normal file
519
app/presentation/web/routes.py
Normal file
@@ -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 <br> tags instead of newlines.
|
||||
"""
|
||||
return value.replace("\n", "<br>\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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>About - Blog</title></head>
|
||||
<body>
|
||||
<h1>About</h1>
|
||||
<p>A modern blog built with FastAPI and DDD architecture.</p>
|
||||
<p>User: {user.username if user else "Guest"}</p>
|
||||
<a href="/web/">Back to home</a>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
158
static/css/base.css
Normal file
158
static/css/base.css
Normal file
@@ -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;
|
||||
}
|
||||
364
static/css/components.css
Normal file
364
static/css/components.css
Normal file
@@ -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;
|
||||
}
|
||||
505
static/css/layout.css
Normal file
505
static/css/layout.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
199
static/css/themes/theme-dark.css
Normal file
199
static/css/themes/theme-dark.css
Normal file
@@ -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);
|
||||
}
|
||||
175
static/css/themes/theme-light.css
Normal file
175
static/css/themes/theme-light.css
Normal file
@@ -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;
|
||||
}
|
||||
4
static/images/favicon.svg
Normal file
4
static/images/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="15" fill="#4183c4"/>
|
||||
<text x="50" y="70" font-family="Arial, sans-serif" font-size="50" font-weight="bold" text-anchor="middle" fill="white">B</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
69
static/js/flash.js
Normal file
69
static/js/flash.js
Normal file
@@ -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();
|
||||
}
|
||||
})();
|
||||
163
static/js/theme.js
Normal file
163
static/js/theme.js
Normal file
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user