Compare commits

..

1 Commits

Author SHA1 Message Date
66ce5ae746 init update (#1)
Co-authored-by: Sergey Vanyushkin <pi3c@yandex.ru>
Co-committed-by: Sergey Vanyushkin <pi3c@yandex.ru>
2026-04-25 17:47:07 +00:00
198 changed files with 1091 additions and 22885 deletions

BIN
.coverage Normal file

Binary file not shown.

View File

@@ -1,33 +0,0 @@
# Environment mode: dev or prod
ENVIRONMENT=dev
# App settings
APP_NAME=Blog API
APP_DEBUG=false
APP_HOST=0.0.0.0
APP_PORT=8000
# Database settings
# For dev (SQLite): DB_URL=sqlite+aiosqlite:///./blog.db
# For prod (PostgreSQL): DB_URL=postgresql+asyncpg://user:pass@host:port/db
# Or use individual DB_* vars for prod (see below)
DB_URL=
DB_ECHO=false
# PostgreSQL-specific settings (used in prod when DB_URL is not set)
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=blog
# Security settings (REQUIRED)
SECURITY_SECRET_KEY=your-secret-key-here-change-in-production
SECURITY_ACCESS_TOKEN_EXPIRE_MINUTES=30
# Keycloak settings (REQUIRED for authentication)
KC_SERVER_URL=http://localhost:8080
KC_REALM=blog
KC_CLIENT_ID=blog-api
KC_CLIENT_SECRET=your-keycloak-client-secret-here
KC_TOKEN_CACHE_TTL=60

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,30 @@
## Description
<!-- Brief description of changes -->
## Type of Change
<!-- Mark with [x] -->
- [ ] 🚀 Feature (`feat`)
- [ ] 🐛 Bug Fix (`fix`)
- [ ] 📝 Documentation (`docs`)
- [ ] ♻️ Refactor (`refactor`)
- [ ] 🎨 Code Style (`style`)
- [ ] ✅ Tests (`test`)
- [ ] 🔧 Chore (`chore`)
## Checklist
- [ ] Code follows project style guidelines (ruff, isort)
- [ ] Tests added/updated (if applicable)
- [ ] Documentation updated (if applicable)
- [ ] Commit message follows convention (`type: description`)
- [ ] Branch rebased to single commit before merge
- [ ] No cache files in commit (`__pycache__`, `*.pyc`)
## Testing
<!-- Describe how changes were tested -->
## Related Issues
<!-- Link to issues if applicable -->
Fixes #
## Screenshots (if applicable)
<!-- Add screenshots for UI changes -->

20
.gitignore vendored
View File

@@ -2,12 +2,24 @@
site/
# Python cache (ignore all)
**/__pycache__/
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
# opencode skills (agent-only)
.opencode/
AGENTS.md
.github/PULL_REQUEST_TEMPLATE.md
# Scripts (except hooks)
scripts/*
!scripts/commit-msg
!scripts/post-commit
!scripts/update_readme.py
!scripts/clean_cache.sh
# IDE
.idea/
.vscode/
@@ -28,9 +40,13 @@ htmlcov/
# Environment
.env
.env.example
.venv/
venv/
# uv cache
.uv/
blog.db
# Scripts cache
scripts/__pycache__/

18
.woodpecker/deploy.yaml Normal file
View File

@@ -0,0 +1,18 @@
when:
event: [push]
branch: main
steps:
deploy:
image: python:3.13
commands:
- echo "🚀 Deploying to production..."
- echo "Branch: main"
- echo "Commit: $(git rev-parse --short HEAD)"
# Add your deployment commands here
# Example:
# - uv sync --frozen
# - uv run python -m app.main &
- echo "✅ Deployment complete"
when:
status: [success]

View File

@@ -0,0 +1,49 @@
when:
event: [push, pull_request]
steps:
check-branch:
image: alpine/git
commands:
- BRANCH=$(git rev-parse --abbrev-ref HEAD)
- |
echo "Branch: $BRANCH"
if [ "$BRANCH" = "main" ]; then
echo "✓ Production branch (protected)"
elif [ "$BRANCH" = "dev" ]; then
echo "✓ Development branch (protected)"
elif echo "$BRANCH" | grep -qE "^feature/"; then
echo "✓ Feature branch"
elif echo "$BRANCH" | grep -qE "^(fix|hotfix|release)/"; then
echo "✓ Special branch"
else
echo "⚠️ Unusual branch name: $BRANCH"
echo " Recommended: feature/<description>, fix/<description>"
fi
check-commit-message:
image: alpine/git
commands:
- MSG=$(git log -1 --pretty=%s)
- |
echo "Last commit: $MSG"
if echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z]"; then
echo "✓ Commit message follows convention"
else
echo "❌ Invalid commit message format"
echo " Expected: <type>: <description>"
echo " Types: feat, fix, docs, style, refactor, test, chore"
exit 1
fi
check-cache-files:
image: python:3.13
commands:
- |
CACHE_FILES=$(git diff --name-only HEAD~1 | grep -E "__pycache__|\.pyc$" || true)
if [ -n "$CACHE_FILES" ]; then
echo "❌ Cache files in commit:"
echo "$CACHE_FILES"
exit 1
fi
- echo "✓ No cache files in commit"

13
.woodpecker/lints.yaml Normal file
View File

@@ -0,0 +1,13 @@
when:
- event: [push, pull_request]
branch: main
steps:
- name: lint
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --only-group lints
- uv run black --check .
- uv run ruff check .
- uv run isort --check-only .

View File

@@ -1,147 +0,0 @@
when:
event: [push, pull_request]
branch: [dev, main, master]
services:
- name: postgres
image: postgres:17-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: blog_test
steps:
- name: deps
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
commands:
- pip install uv
- uv sync --group lints --group tests --group types --group dev
- name: lint
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
depends_on: [deps]
commands:
- pip install uv
- uv run --no-sync ruff check .
- uv run --no-sync ruff format --check .
- uv run --no-sync isort --check-only .
- name: type
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
depends_on: [deps]
commands:
- pip install uv
- uv run --no-sync mypy .
- name: test-unit
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
COVERAGE_FILE: .coverage.unit
depends_on: [deps]
commands:
- pip install uv
- uv run --no-sync pytest tests/unit/ -o "addopts=--cov=app --cov-report=term-missing --cov-fail-under=0"
- name: test-integration
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
DB_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/blog_test
SKIP_INIT_DB: "1"
COVERAGE_FILE: .coverage.integration
depends_on: [deps]
commands:
- pip install uv
- uv run --no-sync pytest tests/integration/ -v -o "addopts=--cov=app --cov-report=term-missing --cov-fail-under=0"
- name: test-e2e
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
DB_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/blog_test
SKIP_INIT_DB: "1"
depends_on: [test-integration]
commands:
- pip install uv
- uv run --no-sync alembic upgrade head
- apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
- uv run --no-sync playwright install chromium
- uv run --no-sync blog &
- sleep 5
- uv run --no-sync pytest tests/e2e/ -v --no-cov
- name: coverage
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
depends_on: [test-unit, test-integration, test-e2e]
commands:
- pip install uv
- uv run --no-sync coverage combine .coverage.unit .coverage.integration
- uv run --no-sync coverage report --fail-under=70 --include=app/*
- uv run --no-sync coverage html
- name: pr-comment
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
GITEA_API_TOKEN:
from_secret: gitea_api_token
depends_on: [coverage, lint, type]
when:
event: [pull_request]
commands:
- pip install uv
- |
SHA7=$(printf '%.7s' "${CI_COMMIT_SHA:-unknown}")
COMMIT_URL="${CI_FORGE_URL}/${CI_REPO_OWNER}/${CI_REPO_NAME}/commit/${CI_COMMIT_SHA}"
SOURCE="${CI_COMMIT_SOURCE:-${CI_COMMIT_SOURCE_BRANCH:-?}}"
TARGET="${CI_COMMIT_TARGET:-${CI_COMMIT_TARGET_BRANCH:-?}}"
PIPELINE_URL="${CI_PIPELINE_URL:-}"
COVER=$(uv run --no-sync coverage report --include='app/*' | tail -1 | awk '{print $NF}')
if [ -z "$GITEA_API_TOKEN" ]; then
echo "pr-comment: GITEA_API_TOKEN not set, skipping"
exit 0
fi
FMT='{"body": "## CI Summary\n\n**Commit:** [`%s`](%s)\n**Branch:** `%s` → `%s`\n**Pipeline:** [View](%s)\n\n### Checks\n\n| Check | Status |\n|-------|--------|\n| Lint (ruff + isort) | ✅ |\n| Type check (mypy) | ✅ |\n| Unit tests | ✅ |\n| Integration tests | ✅ |\n| E2E tests | ✅ |\n| Coverage | **%s** |\n\n---\n*Reported by Woodpecker CI*"}'
BODY=$(printf "$FMT" "$SHA7" "$COMMIT_URL" "$SOURCE" "$TARGET" "$PIPELINE_URL" "$COVER")
curl -s -X POST "${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/issues/${CI_COMMIT_PULL_REQUEST}/comments" -H "Authorization: token $${GITEA_API_TOKEN}" -H "Content-Type: application/json" --data-binary "$BODY"

12
.woodpecker/tests.yaml Normal file
View File

@@ -0,0 +1,12 @@
when:
- event: [push, pull_request]
branch: main
steps:
- name: tests
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --group tests
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing

11
.woodpecker/types.yaml Normal file
View File

@@ -0,0 +1,11 @@
when:
- event: [push, pull_request]
branch: main
steps:
- name: types
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --only-group types
- uv run mypy .

545
AGENTS.md
View File

@@ -1,545 +0,0 @@
# Blog AGENTS.md
**Generated:** 2026-05-03 22:15 UTC
**Commit:** 41f2a3d
**Branch:** feature/tests
## Stack
- Python 3.13+, FastAPI, pydantic, uvicorn
- SQLAlchemy 2.0 (async), aiosqlite
- Package manager: `uv`
- CI: Woodpecker (lint, test, type on push/PR to `dev`)
## Commands
```bash
uv sync --group dev # Install all dev dependencies
uv run pytest # Run tests (coverage >= 70% required)
uv run pytest tests/unit/ # Run single test directory
uv run ruff check . --fix # Lint
uv run ruff format # Format
uv run isort . # Sort imports
uv run mypy . # Type check (strict mode)
uv run blog # Start dev server (port 8000)
```
## Pre-commit order
`ruff check --fix``ruff format``isort``mypy`
## DDD Architecture
### Layer Structure
```
app/
├── domain/ # Domain Layer - business logic, no dependencies
│ ├── entities/ # Domain entities (Post, User, etc.)
│ │ ├── base.py # Base entity class
│ │ └── post.py # Post entity with business logic
│ ├── value_objects/ # Value objects (Title, Content, Slug)
│ │ ├── base.py
│ │ ├── title.py
│ │ ├── content.py
│ │ └── slug.py
│ ├── repositories/ # Repository interfaces (abstract)
│ │ ├── base.py
│ │ └── post.py
│ └── exceptions.py # Domain exceptions
├── application/ # Application Layer - use cases
│ ├── dtos/ # Data Transfer Objects
│ │ └── post.py
│ ├── interfaces/ # Abstract interfaces (UoW)
│ │ └── unit_of_work.py
│ └── use_cases/ # Use cases (CQRS-like)
│ ├── create_post.py
│ ├── get_post.py
│ ├── update_post.py
│ ├── delete_post.py
│ ├── list_posts.py
│ └── publish_post.py
├── infrastructure/ # Infrastructure Layer - external concerns
│ ├── config/ # Configuration
│ │ └── settings.py
│ ├── database/ # Database connection & ORM models
│ │ ├── connection.py
│ │ └── models.py
│ ├── repositories/ # Repository implementations
│ │ ├── post.py # SQLAlchemyPostRepository
│ │ └── unit_of_work.py # SQLAlchemyUnitOfWork
│ ├── di/ # Dependency Injection
│ │ └── container.py
│ └── middleware/ # Exception handlers
│ └── error_handler.py
├── presentation/ # Presentation Layer - API
│ ├── api/ # FastAPI routes
│ │ ├── v1/ # API version 1
│ │ │ ├── __init__.py
│ │ │ └── posts.py # Posts endpoints
│ │ ├── deps.py # FastAPI dependencies
│ │ └── __init__.py
│ └── schemas/ # Pydantic schemas
│ └── post.py
└── main.py # Application entry point
tests/
├── unit/ # Unit tests (domain, use cases)
│ ├── domain/ # Domain layer tests
│ ├── application/ # Application layer tests
│ └── infrastructure/ # Infrastructure tests
├── integration/ # Integration tests (DB, repos)
├── api/ # API endpoint tests
└── e2e/ # End-to-end tests
```
## Where to Look
| Task | Location | Notes |
|------|----------|-------|
| Add a new use case | `app/application/use_cases/` | Follow naming: `{action}_post.py` |
| Add a new API endpoint | `app/presentation/api/v1/posts.py` | Or create new module in `v1/` |
| Add a new web page | `app/presentation/web/routes.py` | Integrate real use cases, not mocks |
| Add a domain entity | `app/domain/entities/` | Inherit from `BaseEntity`, add to `domain/__init__.py` |
| Add a repository method | `app/infrastructure/repositories/post.py` | Mirror in `app/domain/repositories/post.py` |
| Configure DI provider | `app/infrastructure/di/providers.py` | Add to existing provider class or create new one |
| Change database schema | `app/infrastructure/database/models.py` | Mirror changes in domain entity |
| Add/modify tests | `tests/unit/{layer}/` | Mirror `app/` structure exactly |
| Run linting | `uv run ruff check . --fix` | Pre-commit: ruff → ruff format → isort → mypy |
| Run tests | `uv run pytest` | Coverage auto-collected, HTML report at `htmlcov/` |
| Run type check | `uv run mypy .` | Strict mode; excludes `tests/e2e` |
## Code Map
| Symbol | Type | Location | Refs | Role |
|--------|------|----------|------|------|
| `app_factory` | Function | `app/main.py:50` | 3 | FastAPI app factory with DI lifespan |
| `SQLAlchemyPostRepository` | Class | `app/infrastructure/repositories/post.py:18` | 1 | Concrete repository implementation |
| `Post` | Class | `app/domain/entities/post.py:17` | 1 | Core domain entity |
| `PostRepository` | Class | `app/domain/repositories/post.py:13` | 1 | Repository interface |
| `CreatePostUseCase` | Class | `app/application/use_cases/create_post.py:14` | 1 | Use case for creating posts |
| `home` | Function | `app/presentation/web/routes.py:189` | 1 | Web home page route |
| `create_post` | Function | `app/presentation/api/v1/posts.py:35` | 1 | API create post endpoint |
## Key Conventions
### Dependency Rule
- Domain layer has **NO dependencies** on other layers
- Application layer depends only on Domain
- Infrastructure depends on Domain and Application
- Presentation depends on all other layers
### Testing
- **Unit tests**: Test domain logic without DB/external services
### Code Patterns
- Use **dataclasses** for entities and value objects
- Use **frozen dataclasses** for value objects (immutable)
- Use **Unit of Work** pattern for transactions
- Use **Repository** pattern for data access
- Use **Dependency Injection** via FastAPI's Depends()
## Anti-Patterns (This Project)
- **NO inline comments** — Self-documenting code only; Google-style docstrings required
- **NO type suppression** — Never use `typing.Any` casts or `# type: ignore` to bypass mypy strict mode
- **Dead code**: `create_container()` in `app/infrastructure/di/container.py` is defined but never used; `main.py` calls `make_async_container()` directly
- **Empty directories**: `app/domain/exceptions/` and `app/presentation/api/deps/` are empty dirs that co-exist with `.py` files of the same name — import ambiguity risk
- **Missing `__main__.py`**: `python -m app` fails; use `uv run blog` or `python app/main.py`
- **Stale config**: `pyproject.toml` excludes `tests/e2e` but the directory does not exist
- **Unused dependency**: `black` is in `[dependency-groups] lints` but never invoked; ruff format is used instead
- **Pre-commit excludes `__init__.py`**: All `__init__.py` files skip linting and import sorting
## 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)
- Mutable state
- Business logic methods (publish, update_title, etc.)
- Example: `Post` entity
### Value Objects
- Immutable
- Defined by attributes
- Validated on creation
- Examples: `Title`, `Content`, `Slug`
### Aggregates & Repositories
- `Post` is an aggregate root
- `PostRepository` interface in Domain
- `SQLAlchemyPostRepository` implementation in Infrastructure
### Domain Events
- Placeholder for future implementation
- Can be added via event bus in application layer
## Configuration
- `.env` file loaded by pydantic-settings
- Settings available via `app.infrastructure.config.settings`
## Database
- SQLAlchemy 2.0 with async support
- SQLite by default (aiosqlite)
- Tables auto-created on startup
- Use `init_db()` and `close_db()` in lifespan
## TDD Development Workflow
This project uses **Test-Driven Development (TDD)** with a formal test agreement process.
### Feature Lifecycle
```
User: "начнем новую фичу"
|
v
Discovery Phase (автоматически)
|-- Анализ существующего кода
|-- Определение затронутых слоев DDD
|-- Рекомендации по тесткейсам
|
v
User Agreement (согласование)
|-- Пользователь подтверждает/корректирует тесткейсы
|
v
Test Design
|-- Актуализация FEATURE_*.md
|-- Создание artifact: pyaqa/feature/{feature-name}.md
|-- Назначение TC-UNIT-NNN, TC-API-NNN, TC-WEB-NNN, TC-E2E-NNN
|
v
Write Tests (RED)
|-- Написать тесты, убедиться что они падают
|
v
Implementation (GREEN)
|-- Domain -> Application -> Infrastructure -> Presentation
|-- Минимальная реализация для прохождения тестов
|
v
Refactor
|-- Улучшение кода с сохранением зеленых тестов
|-- Линтеры, type checker
|
v
Verification
|-- ruff check, ruff format, isort, mypy
|-- pytest (coverage ≥70%)
|-- E2E tests
|
v
User Acceptance
|-- Пользователь подтверждает приемку
|
v
Commit (во все затронутые проекты)
|-- blog, pytfm, pyaqa (root)
|
v
Merge & Cleanup
|-- Дождаться влития PR в целевую ветку (dev/main)
|-- Переключиться на целевую ветку
|-- `git pull` — подтянуть изменения
|-- Удалить локальную фича-ветку: `git branch -d feature/{name}`
```
### Bugfix Lifecycle
```
User: "исправить баг"
|
v
Reproduction Phase
|-- Анализ бага, воспроизведение
|-- Определение root cause
|-- Создание artifact: pyaqa/bugfix/{name}.md
|
v
Write Regression Test
|-- Написать тест, воспроизводящий баг
|-- Убедиться что тест падает (RED)
|
v
Fix (GREEN)
|-- Минимальный фикс
|-- Убедиться что тест проходит
|
v
Verification
|-- Все существующие тесты проходят
|-- Coverage не упал
|-- Линтеры, type checker
|
v
User Acceptance
|-- Пользователь проверяет исправление
|
v
Commit (во все затронутые проекты)
|
v
Merge & Cleanup
|-- Дождаться влития PR в целевую ветку (dev/main)
|-- Переключиться на целевую ветку
|-- `git pull` — подтянуть изменения
|-- Удалить локальную фича-ветку: `git branch -d feature/{name}`
```
### Refactoring Lifecycle
```
User: "отрефакторить"
|
v
Analysis Phase
|-- Анализ кода
|-- Определение scope и рисков
|-- Создание artifact: pyaqa/refactor/{name}.md (опционально)
|
v
Pre-check
|-- Все тесты проходят ДО рефакторинга
|-- Фиксация coverage baseline
|
v
Refactoring
|-- Пошаговые изменения
|-- Проверка тестов после каждого шага
|
v
Post-check
|-- Все тесты проходят ПОСЛЕ рефакторинга
|-- Coverage не ниже baseline
|-- Поведение не изменилось
|
v
Verification
|-- Линтеры, type checker
|-- Нет новых warnings
|
v
User Acceptance (опционально)
|-- Пользователь проверяет, что ничего не сломалось
|
v
Commit (во все затронутые проекты)
|
v
Merge & Cleanup
|-- Дождаться влития PR в целевую ветку (dev/main)
|-- Переключиться на целевую ветку
|-- `git pull` — подтянуть изменения
|-- Удалить локальную фича-ветку: `git branch -d feature/{name}`
```
### Branch Naming
- **Feature**: `feature/{feature-name}` от `dev`
- **Bugfix**: `bugfix/{bug-name}` от `dev`
- **Refactor**: `refactor/{name}` от `dev`
### Test Case IDs
- `TC-UNIT-NNN` — unit тесты (domain, use cases)
- `TC-API-NNN` — API endpoint тесты
- `TC-WEB-NNN` — Web route тесты (HTML responses)
- `TC-E2E-NNN` — End-to-end тесты (Playwright)
### Test Level Selection
Все 4 уровня по умолчанию. Можно сокращать в зависимости от задачи:
- **Domain-only фича**: только TC-UNIT
- **API-only фича**: TC-UNIT + TC-API
- **Web UI фича**: TC-UNIT + TC-WEB + TC-E2E
- **Full-stack фича**: все 4 уровня
- **Bugfix**: уровни в зависимости от слоя бага (минимум unit + regression)
- **Refactor**: все существующие тесты (unit + api + web + e2e)
### Artifact Location
- **Feature**: `pyaqa/feature/TEMPLATE.md``pyaqa/feature/{feature-name}.md`
- **Bugfix**: `pyaqa/bugfix/TEMPLATE.md``pyaqa/bugfix/{bug-name}.md`
- **Refactor**: `pyaqa/refactor/TEMPLATE.md``pyaqa/refactor/{name}.md`
### Commit Rules
При завершении коммитить во ВСЕ затронутые подпроекты:
1. `blog/` — если изменен
2. `pytfm/` — если изменен
3. `pyaqa/` (root) — всегда (обновление ссылок на подпроекты)
## Notes
- Web routes (`app/presentation/web/routes.py`) currently use `MockPost` and `MOCK_POSTS` instead of real use cases — integrate with actual use cases when ready
- `alembic/` directory exists but is non-functional (no `alembic.ini`, no migration scripts)
- `tests/integration/`, `tests/api/`, `tests/e2e/` are documented in architecture but do not exist yet
- `app/domain/roles.py` exists but its symbols are not exported in `app/domain/__init__.py`
- Woodpecker CI uses `.woodpecker/` directory (3 separate YAML files) instead of single `.woodpecker.yml` — valid but non-standard
- CI pipelines have copy-paste boilerplate; `test.yaml` uses `--group tests` while `lint.yaml` and `type.yaml` use `--only-group <X>`

View File

@@ -1,149 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite+aiosqlite:///./blog.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1 +0,0 @@
Generic single-database configuration.

View File

@@ -1,64 +0,0 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
from app.infrastructure.database.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_database_url() -> str:
url = os.environ.get("DB_URL") or config.get_main_option("sqlalchemy.url")
if not url:
raise RuntimeError("Database URL not configured")
return url
def run_migrations_offline() -> None:
url = _get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
db_url = _get_database_url()
connectable = create_async_engine(db_url, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,30 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -1,48 +0,0 @@
"""Initial migration for PostORM.
Revision ID: 5357028a1574
Revises:
Create Date: 2026-05-09 20:56:26.292255
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5357028a1574"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"posts",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("slug", sa.String(200), nullable=False),
sa.Column("author_id", sa.String(100), nullable=False),
sa.Column("published", sa.Boolean(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_index("ix_posts_author_id", "posts", ["author_id"])
op.create_index("ix_posts_published", "posts", ["published"])
op.create_index("ix_posts_slug", "posts", ["slug"])
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_posts_slug", table_name="posts")
op.drop_index("ix_posts_published", table_name="posts")
op.drop_index("ix_posts_author_id", table_name="posts")
op.drop_table("posts")

View File

@@ -1,5 +1 @@
"""Blog API Application.
This package provides a complete blog API implementation following
Domain-Driven Design principles with FastAPI and SQLAlchemy.
"""
"""Application package."""

Binary file not shown.

Binary file not shown.

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API module - HTTP routes and endpoints."""

1
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API version 1 endpoints."""

View File

@@ -1,47 +0,0 @@
"""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 (
CommentResponseDTO,
CreateCommentDTO,
CreatePostDTO,
PostResponseDTO,
UpdatePostDTO,
)
from app.application.interfaces import TransactionManager
from app.application.use_cases import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
__all__ = [
"CreatePostDTO",
"UpdatePostDTO",
"PostResponseDTO",
"CreateCommentDTO",
"CommentResponseDTO",
"TransactionManager",
"CreatePostUseCase",
"GetPostUseCase",
"UpdatePostUseCase",
"DeletePostUseCase",
"ListPostsUseCase",
"PublishPostUseCase",
"TogglePostLikeUseCase",
"CreateCommentUseCase",
"DeleteCommentUseCase",
"ListCommentsUseCase",
"ToggleCommentLikeUseCase",
]

View File

@@ -1,16 +0,0 @@
"""Application DTOs.
This module re-exports all Data Transfer Objects used in the
application layer for data communication.
"""
from app.application.dtos.comment import CommentResponseDTO, CreateCommentDTO
from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO
__all__ = [
"CreatePostDTO",
"UpdatePostDTO",
"PostResponseDTO",
"CreateCommentDTO",
"CommentResponseDTO",
]

View File

@@ -1,55 +0,0 @@
"""DTOs for comment use cases.
This module defines Data Transfer Objects used for communication between
application layer comment use cases and presentation layer.
"""
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass(frozen=True)
class CreateCommentDTO:
"""DTO for creating a comment.
Carries comment creation data from API to use case.
Attributes:
post_id: UUID of the post to comment on.
author_id: Identifier of the comment author.
content: Comment content string (Markdown supported).
parent_id: Optional UUID of parent comment for replies.
"""
post_id: UUID
author_id: str
content: str
parent_id: UUID | None = None
@dataclass(frozen=True)
class CommentResponseDTO:
"""DTO for comment response.
Carries complete comment data for API responses.
Attributes:
id: Unique comment identifier.
post_id: UUID of the parent post.
author_id: Comment author identifier.
content: Comment content string.
parent_id: Optional UUID of parent comment.
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
id: UUID
post_id: UUID
author_id: str
content: str
parent_id: UUID | None = None
like_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None

View File

@@ -1,104 +0,0 @@
"""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
from uuid import UUID
@dataclass(frozen=True)
class CreatePostDTO:
"""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
author_id: str
tags: list[str] | None = None
@dataclass(frozen=True)
class UpdatePostDTO:
"""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
tags: list[str] | None = None
@dataclass(frozen=True)
class PostResponseDTO:
"""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
content: str
slug: str
author_id: str
published: bool
tags: list[str]
created_at: datetime
updated_at: datetime
like_count: int = 0
comment_count: int = 0

View File

@@ -1,9 +0,0 @@
"""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
__all__ = ["TransactionManager"]

View File

@@ -1,38 +0,0 @@
"""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.
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.
Persists all pending changes to the database.
Should be called after all operations succeed.
"""
...
@abstractmethod
async def rollback(self) -> None:
"""Rollback the current transaction.
Discards all pending changes.
Should be called when an error occurs.
"""
...

View File

@@ -1,31 +0,0 @@
"""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_comment import CreateCommentUseCase
from app.application.use_cases.create_post import CreatePostUseCase
from app.application.use_cases.delete_comment import DeleteCommentUseCase
from app.application.use_cases.delete_post import DeletePostUseCase
from app.application.use_cases.get_post import GetPostUseCase
from app.application.use_cases.list_comments import ListCommentsUseCase
from app.application.use_cases.list_posts import ListPostsUseCase
from app.application.use_cases.publish_post import PublishPostUseCase
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
from app.application.use_cases.update_post import UpdatePostUseCase
__all__ = [
"CreatePostUseCase",
"GetPostUseCase",
"UpdatePostUseCase",
"DeletePostUseCase",
"ListPostsUseCase",
"PublishPostUseCase",
"TogglePostLikeUseCase",
"CreateCommentUseCase",
"DeleteCommentUseCase",
"ListCommentsUseCase",
"ToggleCommentLikeUseCase",
]

View File

@@ -1,100 +0,0 @@
"""Create comment use case.
This module implements the use case for creating comments on blog posts.
Supports both top-level comments and nested replies via parent_id.
"""
from uuid import UUID
from app.application.dtos.comment import CommentResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities.comment import Comment
from app.domain.exceptions import NotFoundException
from app.domain.repositories import CommentRepository, PostRepository
class CreateCommentUseCase:
"""Use case for creating a comment on a blog post.
Handles top-level comments and replies to existing comments.
Validates that the target post exists before creating.
Attributes:
_post_repo: Repository for post data access.
_comment_repo: Repository for comment data access.
_tx_manager: Transaction manager for commit control.
"""
def __init__(
self,
post_repo: PostRepository,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
post_repo: Repository for post operations.
comment_repo: Repository for comment operations.
tx_manager: Transaction manager instance.
"""
self._post_repo = post_repo
self._comment_repo = comment_repo
self._tx_manager = tx_manager
async def execute(
self,
post_id: UUID,
author_id: str,
content: str,
parent_id: UUID | None = None,
) -> CommentResponseDTO:
"""Execute the use case to create a comment.
Args:
post_id: UUID of the post to comment on.
author_id: Identifier of the comment author.
content: Comment content (Markdown supported).
parent_id: Optional UUID of parent comment for replies.
Returns:
CommentResponseDTO with created comment data.
Raises:
NotFoundException: If the target post does not exist.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
comment = Comment.create(
post_id=post_id,
author_id=author_id,
content_str=content,
parent_id=parent_id,
)
await self._comment_repo.add(comment)
await self._tx_manager.commit()
return self._map_to_dto(comment)
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
"""Map domain entity to response DTO.
Args:
comment: Domain Comment entity.
Returns:
CommentResponseDTO with all comment attributes.
"""
return CommentResponseDTO(
id=comment.id,
post_id=comment.post_id,
author_id=comment.author_id,
content=comment.content.value,
parent_id=comment.parent_id,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)

View File

@@ -1,98 +0,0 @@
"""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
from app.domain.entities import Post
from app.domain.exceptions import AlreadyExistsException
from app.domain.repositories import PostRepository
class CreatePostUseCase:
"""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 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)
if await self._post_repo.slug_exists(slug.value):
raise AlreadyExistsException(f"Post with slug '{slug.value}' already exists")
post = Post.create(
title_str=dto.title,
content_str=dto.content,
author_id=dto.author_id,
tags=dto.tags or [],
)
await self._post_repo.add(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -1,60 +0,0 @@
"""Delete comment use case.
This module implements the use case for deleting comments.
Users can delete their own comments.
"""
from uuid import UUID
from app.application.interfaces import TransactionManager
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import CommentRepository
class DeleteCommentUseCase:
"""Use case for deleting a comment.
Allows users to delete their own comments.
Attributes:
_comment_repo: Repository for comment data access.
_tx_manager: Transaction manager for commit control.
"""
def __init__(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
comment_repo: Repository for comment operations.
tx_manager: Transaction manager instance.
"""
self._comment_repo = comment_repo
self._tx_manager = tx_manager
async def execute(
self,
comment_id: UUID,
user_id: str,
) -> None:
"""Delete a comment.
Args:
comment_id: UUID of the comment to delete.
user_id: Identifier of the user requesting deletion.
Raises:
NotFoundException: If the comment does not exist.
"""
comment = await self._comment_repo.get_by_id(comment_id)
if not comment:
raise NotFoundException(f"Comment with id '{comment_id}' not found")
if comment.author_id != user_id:
raise ForbiddenException("You are not allowed to delete this comment")
await self._comment_repo.delete(comment_id)
await self._tx_manager.commit()

View File

@@ -1,69 +0,0 @@
"""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
from app.application.interfaces import TransactionManager
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import PostRepository
from app.domain.roles import Role
class DeletePostUseCase:
"""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,
current_role: Role = Role.USER,
) -> None:
"""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.
current_role: Role of the requesting user (default USER).
Raises:
NotFoundException: If post with given ID does not exist.
ForbiddenException: If user is not the post author and not admin.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
if current_role != Role.ADMIN and post.author_id != current_user_id:
raise ForbiddenException("You can only delete your own posts")
await self._post_repo.delete(post_id)
await self._tx_manager.commit()

View File

@@ -1,100 +0,0 @@
"""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
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import NotFoundException
from app.domain.repositories import PostRepository
class GetPostUseCase:
"""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.
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.
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.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -1,63 +0,0 @@
"""List comments use case.
This module implements the use case for listing comments on a blog post.
"""
from uuid import UUID
from app.application.dtos.comment import CommentResponseDTO
from app.domain.entities.comment import Comment
from app.domain.repositories import CommentRepository
class ListCommentsUseCase:
"""Use case for listing comments on a blog post.
Retrieves all comments for a given post ordered by creation time.
Attributes:
_comment_repo: Repository for comment data access.
"""
def __init__(
self,
comment_repo: CommentRepository,
) -> None:
"""Initialize use case with dependencies.
Args:
comment_repo: Repository for comment operations.
"""
self._comment_repo = comment_repo
async def execute(self, post_id: UUID) -> list[CommentResponseDTO]:
"""List all comments for a post.
Args:
post_id: UUID of the post.
Returns:
List of CommentResponseDTO for the post.
"""
comments = await self._comment_repo.get_by_post(post_id)
return [self._map_to_dto(c) for c in comments]
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
"""Map domain entity to response DTO.
Args:
comment: Domain Comment entity.
Returns:
CommentResponseDTO with all comment attributes.
"""
return CommentResponseDTO(
id=comment.id,
post_id=comment.post_id,
author_id=comment.author_id,
content=comment.content.value,
parent_id=comment.parent_id,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)

View File

@@ -1,145 +0,0 @@
"""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
from app.domain.entities import Post
from app.domain.repositories import PostRepository
class ListPostsUseCase:
"""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.
Returns:
List of PostResponseDTO for all posts.
"""
posts = await self._post_repo.get_all()
return [self._map_to_dto(post) for post in posts]
async def published_posts(
self,
limit: int | None = None,
offset: int | None = None,
) -> list[PostResponseDTO]:
"""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]
async def by_author(
self,
author_id: str,
limit: int | None = None,
offset: int | None = None,
) -> list[PostResponseDTO]:
"""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]
async def by_tag(
self,
tag: str,
limit: int | None = None,
offset: int | None = None,
) -> list[PostResponseDTO]:
"""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]
async def search(
self,
query: str,
limit: int | None = None,
offset: int | None = None,
) -> list[PostResponseDTO]:
"""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.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -1,132 +0,0 @@
"""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
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import PostRepository
from app.domain.roles import Role
class PublishPostUseCase:
"""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,
current_role: Role = Role.USER,
) -> PostResponseDTO:
"""Publish a post.
Args:
post_id: Unique identifier of the post.
current_user_id: ID of the user requesting publication.
current_role: Role of the requesting user (default USER).
Returns:
PostResponseDTO with updated post data.
Raises:
NotFoundException: If post with given ID does not exist.
ForbiddenException: If user is not the post author and not admin.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
if current_role != Role.ADMIN and post.author_id != current_user_id:
raise ForbiddenException("You can only publish your own posts")
post.publish()
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
async def unpublish(
self,
post_id: UUID,
current_user_id: str,
current_role: Role = Role.USER,
) -> PostResponseDTO:
"""Unpublish a post.
Args:
post_id: Unique identifier of the post.
current_user_id: ID of the user requesting unpublish.
current_role: Role of the requesting user (default USER).
Returns:
PostResponseDTO with updated post data.
Raises:
NotFoundException: If post with given ID does not exist.
ForbiddenException: If user is not the post author and not admin.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
if current_role != Role.ADMIN and post.author_id != current_user_id:
raise ForbiddenException("You can only unpublish your own posts")
post.unpublish()
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -1,96 +0,0 @@
"""Toggle comment like use case.
This module implements the use case for toggling likes on comments.
If the user already liked the comment, the like is removed (unlike).
If not, a new like is added.
"""
from uuid import UUID
from app.application.dtos.comment import CommentResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.exceptions import NotFoundException
from app.domain.repositories import CommentRepository
class ToggleCommentLikeUseCase:
"""Use case for toggling a like on a comment.
Handles like/unlike toggle logic. If the user has already liked
the comment, the like is removed. Otherwise, a new like is created.
Attributes:
_comment_repo: Repository for comment and like data access.
_tx_manager: Transaction manager for commit control.
"""
def __init__(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
comment_repo: Repository for comment and like operations.
tx_manager: Transaction manager instance.
"""
self._comment_repo = comment_repo
self._tx_manager = tx_manager
async def execute(self, comment_id: UUID, liked_by: str) -> CommentResponseDTO:
"""Toggle like on a comment.
If the user already liked the comment, remove the like.
Otherwise, add a new like.
Args:
comment_id: UUID of the comment to toggle like on.
liked_by: User ID.
Returns:
CommentResponseDTO with updated like_count.
Raises:
NotFoundException: If comment with given ID does not exist.
"""
comment = await self._comment_repo.get_by_id(comment_id)
if not comment:
raise NotFoundException(f"Comment with id '{comment_id}' not found")
existing_like = await self._comment_repo.get_like(comment_id, liked_by)
if existing_like:
await self._comment_repo.remove_like(comment_id, liked_by)
comment.like_count = max(0, comment.like_count - 1)
else:
new_like = CommentLike(comment_id=comment_id, liked_by=liked_by)
await self._comment_repo.add_like(new_like)
comment.like_count += 1
await self._comment_repo.update(comment)
await self._tx_manager.commit()
return self._map_to_dto(comment)
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
"""Map domain entity to response DTO.
Args:
comment: Domain Comment entity.
Returns:
CommentResponseDTO with all comment attributes including like_count.
"""
return CommentResponseDTO(
id=comment.id,
post_id=comment.post_id,
author_id=comment.author_id,
content=comment.content.value,
parent_id=comment.parent_id,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)

View File

@@ -1,102 +0,0 @@
"""Toggle post like use case.
This module implements the use case for toggling likes on blog posts.
If the user already liked the post, the like is removed (unlike).
If not, a new like is added.
"""
from uuid import UUID
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.entities.like import PostLike
from app.domain.exceptions import NotFoundException
from app.domain.repositories import PostRepository
class TogglePostLikeUseCase:
"""Use case for toggling a like on a blog post.
Handles like/unlike toggle logic. If the user or device has already
liked the post, the like is removed. Otherwise, a new like is created.
Attributes:
_post_repo: Repository for post and like data access.
_tx_manager: Transaction manager for commit control.
Example:
>>> use_case = TogglePostLikeUseCase(post_repo, tx_manager)
>>> result = await use_case.execute("my-post-slug", "user-123")
"""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
post_repo: Repository for post and like operations.
tx_manager: Transaction manager instance.
"""
self._post_repo = post_repo
self._tx_manager = tx_manager
async def execute(self, post_id: UUID, liked_by: str) -> PostResponseDTO:
"""Toggle like on a post.
If the user/device already liked the post, remove the like.
Otherwise, add a new like.
Args:
post_id: UUID of the post to toggle like on.
liked_by: User ID or device identifier.
Returns:
PostResponseDTO with updated like_count.
Raises:
NotFoundException: If post with given ID does not exist.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
existing_like = await self._post_repo.get_like(post_id, liked_by)
if existing_like:
await self._post_repo.remove_like(post_id, liked_by)
post.like_count = max(0, post.like_count - 1)
else:
new_like = PostLike(post_id=post_id, liked_by=liked_by)
await self._post_repo.add_like(new_like)
post.like_count += 1
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes including like_count.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -1,115 +0,0 @@
"""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
from app.application.dtos.post import PostResponseDTO, UpdatePostDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import PostRepository
from app.domain.roles import Role
from app.domain.value_objects import Content, Title
class UpdatePostUseCase:
"""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
async def execute(
self,
post_id: UUID,
dto: UpdatePostDTO,
current_user_id: str,
current_role: Role = Role.USER,
) -> PostResponseDTO:
"""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.
current_role: Role of the requesting user (default USER).
Returns:
PostResponseDTO with updated post data.
Raises:
NotFoundException: If post with given ID does not exist.
ForbiddenException: If user is not the post author and not admin.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
if current_role != Role.ADMIN and post.author_id != current_user_id:
raise ForbiddenException("You can only update your own posts")
if dto.title is not None:
post.update_title(Title(dto.title))
if dto.content is not None:
post.update_content(Content(dto.content))
if dto.tags is not None:
for tag in post.tags[:]:
post.remove_tag(tag)
for tag in dto.tags:
post.add_tag(tag)
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

1
app/common/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Common utilities and shared components."""

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from app.core.exceptions import AppException
class ErrorResponse(BaseModel):
status_code: int
message: str
details: dict | None = None
timestamp: str
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"message": exc.message,
"timestamp": datetime.utcnow().isoformat(),
},
)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"message": str(exc.detail),
"timestamp": datetime.utcnow().isoformat(),
},
)
def register_exception_handlers(app: FastAPI):
app.add_exception_handler(
AppException,
app_exception_handler, # type: ignore[arg-type]
)
app.add_exception_handler(
HTTPException,
http_exception_handler, # type: ignore[arg-type]
)

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core module - shared functionality and configuration."""

15
app/core/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Blog API"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
database_url: str | None = None
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()

25
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,25 @@
class AppException(Exception):
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class NotFoundError(AppException):
def __init__(self, message: str = "Resource not found"):
super().__init__(message, status_code=404)
class ValidationError(AppException):
def __init__(self, message: str = "Validation failed"):
super().__init__(message, status_code=400)
class UnauthorizedError(AppException):
def __init__(self, message: str = "Unauthorized"):
super().__init__(message, status_code=401)
class ForbiddenError(AppException):
def __init__(self, message: str = "Forbidden"):
super().__init__(message, status_code=403)

View File

@@ -1,38 +0,0 @@
"""Domain layer exports.
This module re-exports all domain layer components including
entities, value objects, repositories, and exceptions.
"""
from app.domain.entities import BaseEntity, Comment, CommentLike, Post, PostLike
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
ValidationException,
)
from app.domain.repositories import CommentRepository, PostRepository, Repository
from app.domain.value_objects import Content, Slug, Title, ValueObject
__all__ = [
"BaseEntity",
"Post",
"PostLike",
"Comment",
"CommentLike",
"ValueObject",
"Title",
"Content",
"Slug",
"Repository",
"PostRepository",
"CommentRepository",
"DomainException",
"ValidationException",
"NotFoundException",
"AlreadyExistsException",
"UnauthorizedException",
"ForbiddenException",
]

View File

@@ -1,13 +0,0 @@
"""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.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.entities.like import PostLike
from app.domain.entities.post import Post
__all__ = ["BaseEntity", "Post", "PostLike", "Comment", "CommentLike"]

View File

@@ -1,75 +0,0 @@
"""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
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4
@dataclass(kw_only=True)
class BaseEntity(ABC):
"""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.
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 representation.
Returns:
Dictionary containing all entity attributes.
"""
...

View File

@@ -1,79 +0,0 @@
"""Domain entity for Comment.
This module defines the Comment entity that represents a comment on a blog
post. Comments can be top-level (parent_id=None) or replies to other
comments (parent_id set).
"""
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from app.domain.entities.base import BaseEntity
from app.domain.value_objects.comment_content import CommentContent
@dataclass(kw_only=True)
class Comment(BaseEntity):
"""Comment domain entity.
Represents a comment on a blog post with optional parent reference
for nested replies. Supports Markdown content and like tracking.
Attributes:
post_id: UUID of the post this comment belongs to.
author_id: Identifier of the comment author.
content: CommentContent value object with Markdown text.
parent_id: UUID of parent comment for replies, or None.
like_count: Number of likes on this comment.
"""
post_id: UUID
author_id: str
content: CommentContent
parent_id: UUID | None = None
like_count: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary.
Returns:
Dictionary representation with all comment attributes.
"""
return {
"id": str(self.id),
"post_id": str(self.post_id),
"author_id": self.author_id,
"content": self.content.value,
"parent_id": str(self.parent_id) if self.parent_id else None,
"like_count": self.like_count,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def create(
cls,
post_id: UUID,
author_id: str,
content_str: str,
parent_id: UUID | None = None,
) -> "Comment":
"""Factory method to create a new comment.
Args:
post_id: UUID of the post to comment on.
author_id: Identifier of the comment author.
content_str: Comment content string (Markdown supported).
parent_id: Optional UUID of parent comment for replies.
Returns:
New Comment instance with validated content.
"""
content = CommentContent(content_str)
return cls(
post_id=post_id,
author_id=author_id,
content=content,
parent_id=parent_id,
)

View File

@@ -1,40 +0,0 @@
"""Domain entity for CommentLike.
This module defines the CommentLike entity that tracks which users
have liked which comments.
"""
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from app.domain.entities.base import BaseEntity
@dataclass(kw_only=True)
class CommentLike(BaseEntity):
"""Comment like domain entity.
Tracks a like on a comment by a user. Each like is uniquely
identified by its entity ID.
Attributes:
comment_id: UUID of the liked comment.
liked_by: Identifier of the user who liked.
"""
comment_id: UUID
liked_by: str
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary.
Returns:
Dictionary with all CommentLike attributes.
"""
return {
"id": str(self.id),
"comment_id": str(self.comment_id),
"liked_by": self.liked_by,
"created_at": self.created_at.isoformat(),
}

View File

@@ -1,40 +0,0 @@
"""Domain entity for PostLike.
This module defines the PostLike entity that tracks which users
or devices have liked which posts.
"""
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from app.domain.entities.base import BaseEntity
@dataclass(kw_only=True)
class PostLike(BaseEntity):
"""Post like domain entity.
Tracks a like on a blog post by a user or device.
Each like is uniquely identified by its entity ID.
Attributes:
post_id: UUID of the liked post.
liked_by: Identifier of the user or device that liked.
"""
post_id: UUID
liked_by: str
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary.
Returns:
Dictionary with all PostLike attributes.
"""
return {
"id": str(self.id),
"post_id": str(self.post_id),
"liked_by": self.liked_by,
"created_at": self.created_at.isoformat(),
}

View File

@@ -1,152 +0,0 @@
"""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
from app.domain.entities.base import BaseEntity
from app.domain.value_objects.content import Content
from app.domain.value_objects.slug import Slug
from app.domain.value_objects.title import Title
@dataclass(kw_only=True)
class Post(BaseEntity):
"""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
slug: Slug
author_id: str
published: bool = False
like_count: int = 0
tags: list[str] = field(default_factory=list)
def publish(self) -> None:
"""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.
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.
Args:
content: New content value object.
"""
self.content = content
self.touch()
def update_title(self, title: Title) -> None:
"""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.
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.
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.
Returns:
Dictionary representation with all post attributes.
"""
return {
"id": str(self.id),
"title": self.title.value,
"content": self.content.value,
"slug": self.slug.value,
"author_id": self.author_id,
"published": self.published,
"like_count": self.like_count,
"tags": self.tags.copy(),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def create(
cls,
title_str: str,
content_str: str,
author_id: str,
tags: list[str] | None = None,
) -> "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)
return cls(
title=title,
content=content,
slug=slug,
author_id=author_id,
tags=tags or [],
)

View File

@@ -1,78 +0,0 @@
"""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.
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.
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.
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.
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.
Used when authentication is required but not provided or invalid.
Example:
>>> raise UnauthorizedException("Authentication required")
"""
class ForbiddenException(DomainException):
"""Raised when access is forbidden.
Used when authenticated user lacks required permissions.
Example:
>>> raise ForbiddenException("Only admins can delete posts")
"""

View File

@@ -1,11 +0,0 @@
"""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.comment import CommentRepository
from app.domain.repositories.post import PostRepository
__all__ = ["Repository", "PostRepository", "CommentRepository"]

View File

@@ -1,89 +0,0 @@
"""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
from uuid import UUID
from app.domain.entities.base import BaseEntity
T = TypeVar("T", bound=BaseEntity)
class Repository(ABC, Generic[T]):
"""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.
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.
Returns:
List of all entity instances.
"""
...
@abstractmethod
async def add(self, entity: T) -> None:
"""Add new entity.
Args:
entity: Entity instance to add.
"""
...
@abstractmethod
async def update(self, entity: T) -> None:
"""Update existing entity.
Args:
entity: Entity instance with updated data.
"""
...
@abstractmethod
async def delete(self, entity_id: UUID) -> None:
"""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.
Args:
entity_id: Unique identifier of the entity.
Returns:
True if entity exists, False otherwise.
"""
...

View File

@@ -1,80 +0,0 @@
"""Comment repository interface.
This module defines the repository interface for Comment entities
including nested comment queries and like management.
"""
from abc import abstractmethod
from uuid import UUID
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.repositories.base import Repository
class CommentRepository(Repository[Comment]):
"""Repository interface for Comments.
Extends the generic repository with comment-specific operations
including post-based listing and like management.
Example:
>>> comments = await repo.get_by_post(post_id)
>>> like = await repo.get_like(comment_id, "user-123")
"""
@abstractmethod
async def get_by_post(self, post_id: UUID) -> list[Comment]:
"""Get all comments for a post, ordered by creation time.
Args:
post_id: UUID of the post.
Returns:
List of Comment entities for the post.
"""
...
@abstractmethod
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
"""Get a like by comment and user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
Returns:
CommentLike if found, None otherwise.
"""
...
@abstractmethod
async def add_like(self, like: CommentLike) -> None:
"""Add a new like to a comment.
Args:
like: CommentLike entity to add.
"""
...
@abstractmethod
async def count_by_post(self, post_id: UUID) -> int:
"""Get comment count for a post.
Args:
post_id: UUID of the post.
Returns:
Number of comments on the post.
"""
...
@abstractmethod
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
"""Remove a like from a comment by user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
"""
...

View File

@@ -1,156 +0,0 @@
"""Post repository interface.
This module extends the base repository interface with post-specific
query methods including slug lookup, author filtering, search, and
like management.
"""
from abc import abstractmethod
from uuid import UUID
from app.domain.entities.like import PostLike
from app.domain.entities.post import Post
from app.domain.repositories.base import Repository
class PostRepository(Repository[Post]):
"""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.
Args:
slug: URL-friendly slug identifier.
Returns:
Post instance if found, None otherwise.
"""
...
@abstractmethod
async def get_by_author(
self,
author_id: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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
async def get_published(
self,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get all published posts.
Args:
limit: Maximum number of posts to return.
offset: Number of posts to skip.
Returns:
List of published posts.
"""
...
@abstractmethod
async def get_by_tag(
self,
tag: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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.
Args:
slug: Slug to check for existence.
Returns:
True if slug exists, False otherwise.
"""
...
@abstractmethod
async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None:
"""Get a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
Returns:
PostLike if found, None otherwise.
"""
...
@abstractmethod
async def add_like(self, like: PostLike) -> None:
"""Add a new like.
Args:
like: PostLike entity to add.
"""
...
@abstractmethod
async def remove_like(self, post_id: UUID, liked_by: str) -> None:
"""Remove a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
"""
...
@abstractmethod
async def search(
self,
query: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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.
"""
...

View File

@@ -1,165 +0,0 @@
"""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
from functools import wraps
from typing import Any
from app.domain.exceptions import ForbiddenException
class Role(str, Enum):
"""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"
GUEST = "guest"
class Permission:
"""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_CREATE = "post:create"
POST_READ = "post:read"
POST_READ_UNPUBLISHED = "post:read_unpublished"
POST_UPDATE = "post:update"
POST_DELETE = "post:delete"
POST_PUBLISH = "post:publish"
ROLE_PERMISSIONS: dict[Role, list[str]] = {
Role.ADMIN: [
Permission.POST_CREATE,
Permission.POST_READ,
Permission.POST_READ_UNPUBLISHED,
Permission.POST_UPDATE,
Permission.POST_DELETE,
Permission.POST_PUBLISH,
],
Role.USER: [
Permission.POST_CREATE,
Permission.POST_READ,
Permission.POST_UPDATE,
Permission.POST_DELETE,
Permission.POST_PUBLISH,
],
Role.GUEST: [
Permission.POST_READ,
],
}
def has_permission(role: Role, permission: str) -> bool:
"""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.
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:
token_info = kwargs.get("token_info")
if not token_info:
raise ForbiddenException("Authentication required")
roles = getattr(token_info, "roles", [])
if Role.ADMIN.value in roles:
role = Role.ADMIN
elif Role.USER.value in roles:
role = Role.USER
else:
role = Role.GUEST
if not has_permission(role, permission):
raise ForbiddenException(
f"Permission '{permission}' required for role '{role.value}'"
)
return await func(*args, **kwargs)
return wrapper
return decorator
def get_effective_role(roles: list[str]) -> Role:
"""Determine effective role from list of roles.
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
elif Role.USER.value in roles:
return Role.USER
else:
return Role.GUEST

View File

@@ -1,13 +0,0 @@
"""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.comment_content import CommentContent
from app.domain.value_objects.content import Content
from app.domain.value_objects.slug import Slug
from app.domain.value_objects.title import Title
__all__ = ["ValueObject", "Title", "Content", "Slug", "CommentContent"]

View File

@@ -1,91 +0,0 @@
"""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
from typing import Any, Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True, slots=True)
class ValueObject(ABC, Generic[T]):
"""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.
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.
Returns:
The underlying primitive value.
"""
return self.value

View File

@@ -1,47 +0,0 @@
"""Value object for comment content.
This module defines the CommentContent value object that validates
and encapsulates comment text content.
"""
from dataclasses import dataclass
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class CommentContent(ValueObject[str]):
"""Comment content value object.
Wraps and validates comment content ensuring it meets length
requirements and is not empty.
Attributes:
value: The comment content string.
MAX_LENGTH: Maximum allowed content length (5000 characters).
Raises:
ValueError: If content is empty or too long.
Example:
>>> content = CommentContent("This is a **bold** comment.")
>>> content.value
'This is a **bold** comment.'
"""
MAX_LENGTH: int = 5000
def _validate(self) -> None:
"""Validate comment content.
Checks that content is a non-empty string within length bounds.
Raises:
ValueError: If content fails validation criteria.
"""
if not isinstance(self.value, str):
raise ValueError("Comment content must be a string")
if not self.value.strip():
raise ValueError("Comment content cannot be empty")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Comment content must be at most {self.MAX_LENGTH} characters")

View File

@@ -1,50 +0,0 @@
"""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
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class Content(ValueObject[str]):
"""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():
raise ValueError("Content cannot be empty or whitespace")
if len(self.value) < self.MIN_LENGTH:
raise ValueError(f"Content must be at least {self.MIN_LENGTH} characters")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Content must be at most {self.MAX_LENGTH} characters")

View File

@@ -1,73 +0,0 @@
"""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
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class Slug(ValueObject[str]):
"""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:
raise ValueError(f"Slug must be at most {self.MAX_LENGTH} characters")
if not re.match(self.SLUG_PATTERN, self.value):
raise ValueError("Slug must contain only lowercase letters, numbers, and hyphens")
@classmethod
def from_title(cls, title: str) -> "Slug":
"""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()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[-\s]+", "-", slug)
max_len = 200
slug = slug[:max_len].strip("-")
if not slug:
slug = "post"
return cls(value=slug)

View File

@@ -1,50 +0,0 @@
"""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
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class Title(ValueObject[str]):
"""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:
raise ValueError(f"Title must be at least {self.MIN_LENGTH} characters")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Title must be at most {self.MAX_LENGTH} characters")
if not self.value.strip():
raise ValueError("Title cannot be empty or whitespace")

View File

@@ -1,34 +0,0 @@
"""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 (
AsyncSessionLocal,
Base,
PostORM,
close_db,
engine,
get_session,
init_db,
)
from app.infrastructure.di import create_container
from app.infrastructure.middleware import register_exception_handlers
from app.infrastructure.repositories import SQLAlchemyPostRepository
__all__ = [
"Settings",
"settings",
"Base",
"PostORM",
"engine",
"AsyncSessionLocal",
"get_session",
"init_db",
"close_db",
"SQLAlchemyPostRepository",
"create_container",
"register_exception_handlers",
]

View File

@@ -1,11 +0,0 @@
"""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.mock_client import MockKeycloakClient
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
__all__ = ["KeycloakAuthClient", "KeycloakUser", "MockKeycloakClient", "TokenInfo"]

View File

@@ -1,185 +0,0 @@
"""Keycloak authentication client.
This module provides a client for Keycloak authentication operations
including token introspection and user info retrieval.
"""
import time
import httpx
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
from app.infrastructure.config.settings import Settings
class KeycloakAuthClient:
"""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.
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
self._client_secret = settings.kc.client_secret
self._cache: dict[str, tuple[TokenInfo, float]] = {}
self._cache_ttl = settings.kc.token_cache_ttl
def _get_introspection_url(self) -> str:
"""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.
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.
Args:
token: Access token string.
Returns:
Cached TokenInfo if valid and not expired, None otherwise.
"""
if token not in self._cache:
return None
token_info, cached_at = self._cache[token]
if time.time() - cached_at > self._cache_ttl:
del self._cache[token]
return None
return token_info
def _cache_token(self, token: str, token_info: TokenInfo) -> None:
"""Cache token info.
Args:
token: Access token string as cache key.
token_info: TokenInfo to cache.
"""
self._cache[token] = (token_info, time.time())
current_time = time.time()
expired_keys = [
k for k, (_, t) in self._cache.items() if current_time - t > self._cache_ttl
]
for k in expired_keys:
del self._cache[k]
async def introspect_token(self, token: str) -> TokenInfo:
"""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
data = {
"token": token,
"client_id": self._client_id,
"client_secret": self._client_secret,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self._get_introspection_url(),
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10.0,
)
response.raise_for_status()
result = response.json()
except httpx.HTTPError as e:
return TokenInfo(active=False, raw_claims={"error": str(e)})
if not result.get("active", False):
return TokenInfo(active=False, raw_claims=result)
roles: list[str] = []
realm_access = result.get("realm_access", {})
if isinstance(realm_access, dict):
roles.extend(realm_access.get("roles", []))
token_info = TokenInfo(
active=True,
user_id=result.get("sub", ""),
username=result.get("preferred_username", ""),
email=result.get("email", ""),
roles=roles,
raw_claims=result,
)
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.
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(
self._get_userinfo_url(),
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return None
return KeycloakUser(
id=data.get("sub", ""),
username=data.get("preferred_username", ""),
email=data.get("email", ""),
first_name=data.get("given_name", ""),
last_name=data.get("family_name", ""),
roles=data.get("realm_access", {}).get("roles", [])
if isinstance(data.get("realm_access"), dict)
else [],
)

View File

@@ -1,75 +0,0 @@
"""Mock Keycloak client for development mode.
This module provides a mock Keycloak authentication client that bypasses
real Keycloak server authentication in development mode. It generates
token info based on dev-specific token formats.
"""
from app.infrastructure.auth.models import TokenInfo
class MockKeycloakClient:
"""Mock Keycloak client for development and testing.
Bypasses real Keycloak server authentication. Parses dev-specific
token formats to generate TokenInfo with configurable roles.
Attributes:
_settings: Application settings.
Example:
>>> client = MockKeycloakClient()
>>> token_info = await client.introspect_token("dev-token-admin")
"""
def __init__(self) -> None:
"""Initialize mock client."""
pass
async def introspect_token(self, token: str) -> TokenInfo:
"""Introspect token in dev mode.
If token starts with 'dev-token-', parses role from suffix.
Otherwise returns inactive token.
Args:
token: Access token string.
Returns:
TokenInfo with dev user data if dev token, inactive otherwise.
"""
dev_users: dict[str, dict[str, str]] = {
"dev-token-user": {
"user_id": "dev-user",
"username": "Dev User",
"email": "dev.user@example.com",
"role": "user",
},
"dev-token-user2": {
"user_id": "dev-user2",
"username": "Test User",
"email": "test.user@example.com",
"role": "user",
},
"dev-token-admin": {
"user_id": "dev-admin",
"username": "Dev Admin",
"email": "dev.admin@example.com",
"role": "admin",
},
}
if token == "dev-token-guest":
return TokenInfo(active=False)
if token in dev_users:
user = dev_users[token]
return TokenInfo(
active=True,
user_id=user["user_id"],
username=user["username"],
email=user["email"],
roles=[user["role"]],
)
return TokenInfo(active=False)

View File

@@ -1,74 +0,0 @@
"""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
@dataclass(frozen=True)
class TokenInfo:
"""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 = ""
username: str = ""
email: str = ""
roles: list[str] = field(default_factory=list)
raw_claims: dict[str, Any] = field(default_factory=dict, repr=False)
@property
def is_valid(self) -> bool:
"""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.
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
email: str
first_name: str = ""
last_name: str = ""
roles: list[str] = field(default_factory=list)
is_active: bool = True

View File

@@ -1,25 +0,0 @@
"""Infrastructure configuration.
This module re-exports all configuration classes and the global
settings instance for application configuration.
"""
from app.infrastructure.config.settings import (
AppConfig,
DBConfig,
Environment,
KCConfig,
SecurityConfig,
Settings,
settings,
)
__all__ = [
"AppConfig",
"DBConfig",
"KCConfig",
"SecurityConfig",
"Environment",
"Settings",
"settings",
]

View File

@@ -1,285 +0,0 @@
"""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
from pydantic import Field, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Environment(str, Enum):
"""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.
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
host: str = "0.0.0.0"
port: int = 8000
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file_encoding="utf-8",
case_sensitive=False,
)
class DBConfig(BaseSettings):
"""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")
"""
url: str | None = None
echo: bool = False
host: str = "localhost"
port: int = 5432
user: str = "postgres"
password: str = "postgres"
name: str = "blog"
model_config = SettingsConfigDict(
env_prefix="DB_",
env_file_encoding="utf-8",
case_sensitive=False,
)
@field_validator("url")
@classmethod
def validate_url(cls, v: str | None) -> str | None:
"""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+")):
raise ValueError("Database URL must start with 'sqlite+' or 'postgresql+'")
return v
class KCConfig(BaseSettings):
"""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"
client_id: str = "blog-api"
client_secret: str = Field(
default="",
description="Keycloak client secret - must be set via env in production",
)
token_cache_ttl: int = 60
model_config = SettingsConfigDict(
env_prefix="KC_",
env_file_encoding="utf-8",
case_sensitive=False,
)
@property
def is_configured(self) -> bool:
"""Check if Keycloak is properly configured.
Returns:
True if client_secret is set.
"""
return bool(self.client_secret)
class SecurityConfig(BaseSettings):
"""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"
)
access_token_expire_minutes: int = 30
model_config = SettingsConfigDict(
env_prefix="SECURITY_",
env_file_encoding="utf-8",
case_sensitive=False,
)
@property
def is_configured(self) -> bool:
"""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.
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: Environment = Environment.DEV
app: AppConfig = Field(default_factory=AppConfig)
db: DBConfig = Field(default_factory=DBConfig)
kc: KCConfig = Field(default_factory=KCConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
env_nested_delimiter="__",
)
def model_post_init(self, __context: object) -> None:
"""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")
if not self.kc.is_configured:
raise ValueError("KC_CLIENT_SECRET must be set in production mode")
@cached_property
def database_url(self) -> str:
"""Get database URL based on environment.
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:
return str(
PostgresDsn.build(
scheme="postgresql+asyncpg",
username=self.db.user,
password=self.db.password,
host=self.db.host,
port=self.db.port,
path=self.db.name,
)
)
return "sqlite+aiosqlite:///./blog.db"
@property
def is_dev(self) -> bool:
"""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.
Returns:
True if environment is PROD.
"""
return self.environment == Environment.PROD
settings = Settings()

View File

@@ -1,26 +0,0 @@
"""Database infrastructure.
This module re-exports database connection utilities and ORM models
for data persistence.
"""
from app.infrastructure.database.connection import (
AsyncSessionLocal,
close_db,
engine,
get_session,
get_session_context,
init_db,
)
from app.infrastructure.database.models import Base, PostORM
__all__ = [
"Base",
"PostORM",
"engine",
"AsyncSessionLocal",
"get_session",
"get_session_context",
"init_db",
"close_db",
]

View File

@@ -1,100 +0,0 @@
"""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
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.infrastructure.config import settings
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
engine: AsyncEngine = create_async_engine(
_get_database_url(),
echo=settings.db.echo,
future=True,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def get_session() -> AsyncGenerator[AsyncSession]:
"""Get database session.
Yields:
AsyncSession instance for database operations.
"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
@asynccontextmanager
async def get_session_context() -> AsyncGenerator[AsyncSession]:
"""Get database session as context manager.
Yields:
AsyncSession instance for database operations.
"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db() -> None:
"""Initialize database tables.
Creates all tables defined in the metadata.
Should be called on application startup.
Skipped in CI/production where alembic manages schema.
"""
import os
if os.environ.get("SKIP_INIT_DB", "").lower() in ("1", "true", "yes"):
return
from app.infrastructure.database.models import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""Close database connections.
Disposes of the engine and all connections.
Should be called on application shutdown.
"""
await engine.dispose()

View File

@@ -1,178 +0,0 @@
"""SQLAlchemy ORM models.
This module defines the database ORM models that map to database tables.
Models are used by repositories for data persistence.
"""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
class PostORM(Base): # type: ignore[valid-type,misc]
"""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"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
like_count: Mapped[int] = mapped_column(default=0, nullable=False)
likes: Mapped[list[PostLikeORM]] = relationship(
back_populates="post", cascade="all, delete-orphan"
)
comments: Mapped[list[CommentORM]] = relationship(
back_populates="post", cascade="all, delete-orphan"
)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
class PostLikeORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for PostLike.
Database table representation of post likes.
Maps to the 'post_likes' table tracking which users/devices liked which posts.
Attributes:
id: Primary key as UUID string.
post_id: Foreign key to the liked post.
liked_by: User ID or device identifier.
created_at: Creation timestamp.
"""
__tablename__ = "post_likes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
post_id: Mapped[str] = mapped_column(
String(36), ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True
)
liked_by: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
post: Mapped[PostORM] = relationship(back_populates="likes")
class CommentORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for Comment.
Database table representation of comments on blog posts.
Maps to the 'comments' table with support for nested replies.
Attributes:
id: Primary key as UUID string.
post_id: Foreign key to the parent post.
author_id: Comment author reference.
content: Comment text content (Markdown supported).
parent_id: Optional foreign key to parent comment (replies).
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
__tablename__ = "comments"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
post_id: Mapped[str] = mapped_column(
String(36), ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True
)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("comments.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
like_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
post: Mapped[PostORM] = relationship(back_populates="comments")
replies: Mapped[list[CommentORM]] = relationship(
back_populates="parent",
)
parent: Mapped[CommentORM | None] = relationship(
back_populates="replies",
remote_side="CommentORM.id",
)
class CommentLikeORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for CommentLike.
Database table representation of comment likes.
Maps to the 'comment_likes' table tracking which users liked which comments.
Attributes:
id: Primary key as UUID string.
comment_id: Foreign key to the liked comment.
liked_by: User identifier.
created_at: Creation timestamp.
"""
__tablename__ = "comment_likes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
comment_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("comments.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
liked_by: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)

View File

@@ -1,11 +0,0 @@
"""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
__all__ = [
"create_container",
]

View File

@@ -1,20 +0,0 @@
"""Dishka container setup."""
from dishka import AsyncContainer, make_async_container
from app.infrastructure.di.providers import (
DatabaseProvider,
RepositoryProvider,
TransactionManagerProvider,
UseCaseProvider,
)
def create_container() -> AsyncContainer:
"""Create and configure Dishka container."""
return make_async_container(
DatabaseProvider(),
RepositoryProvider(),
TransactionManagerProvider(),
UseCaseProvider(),
)

View File

@@ -1,380 +0,0 @@
"""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
from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.application import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
from app.application.interfaces import TransactionManager
from app.domain.repositories import CommentRepository, PostRepository
from app.infrastructure.auth import KeycloakAuthClient, MockKeycloakClient
from app.infrastructure.config.settings import settings
from app.infrastructure.database.connection import AsyncSessionLocal, engine
from app.infrastructure.repositories.comment import SQLAlchemyCommentRepository
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
class DatabaseProvider(Provider):
"""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.
Returns:
AsyncEngine instance for database operations.
"""
return engine
@provide(scope=Scope.REQUEST)
async def get_session(self) -> AsyncGenerator[AsyncSession]:
"""Provide database session per request.
Yields:
AsyncSession instance for the request lifetime.
"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
class RepositoryProvider(Provider):
"""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.
Args:
session: Database session from DI container.
Returns:
SQLAlchemyPostRepository instance.
"""
return SQLAlchemyPostRepository(session)
@provide(scope=Scope.REQUEST)
def get_comment_repository(self, session: AsyncSession) -> CommentRepository:
"""Provide CommentRepository implementation.
Args:
session: Database session from DI container.
Returns:
SQLAlchemyCommentRepository instance.
"""
return SQLAlchemyCommentRepository(session)
class TransactionManagerProvider(Provider):
"""Provider for transaction manager.
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.
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.
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(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> 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,
)
@provide(scope=Scope.REQUEST)
def get_get_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> 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,
)
@provide(scope=Scope.REQUEST)
def get_update_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> 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,
)
@provide(scope=Scope.REQUEST)
def get_delete_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> 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,
)
@provide(scope=Scope.REQUEST)
def get_list_posts_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> 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,
)
@provide(scope=Scope.REQUEST)
def get_publish_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> 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,
)
@provide(scope=Scope.REQUEST)
def get_toggle_like_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> TogglePostLikeUseCase:
"""Provide TogglePostLikeUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured TogglePostLikeUseCase instance.
"""
return TogglePostLikeUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_create_comment_use_case(
self,
post_repo: PostRepository,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> CreateCommentUseCase:
"""Provide CreateCommentUseCase.
Args:
post_repo: Post repository dependency.
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured CreateCommentUseCase instance.
"""
return CreateCommentUseCase(
post_repo=post_repo,
comment_repo=comment_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_list_comments_use_case(
self,
comment_repo: CommentRepository,
) -> ListCommentsUseCase:
"""Provide ListCommentsUseCase.
Args:
comment_repo: Comment repository dependency.
Returns:
Configured ListCommentsUseCase instance.
"""
return ListCommentsUseCase(
comment_repo=comment_repo,
)
@provide(scope=Scope.REQUEST)
def get_delete_comment_use_case(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> DeleteCommentUseCase:
"""Provide DeleteCommentUseCase.
Args:
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured DeleteCommentUseCase instance.
"""
return DeleteCommentUseCase(
comment_repo=comment_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_toggle_comment_like_use_case(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> ToggleCommentLikeUseCase:
"""Provide ToggleCommentLikeUseCase.
Args:
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured ToggleCommentLikeUseCase instance.
"""
return ToggleCommentLikeUseCase(
comment_repo=comment_repo,
tx_manager=tx_manager,
)
class KeycloakProvider(Provider):
"""Provider for Keycloak authentication client.
Provides Keycloak client as application-scoped singleton.
In development mode uses MockKeycloakClient for local testing.
Example:
>>> provider = KeycloakProvider()
"""
@provide(scope=Scope.APP)
def get_keycloak_client(self) -> KeycloakAuthClient:
"""Provide KeycloakAuthClient or MockKeycloakClient singleton.
Returns MockKeycloakClient in dev mode for local testing
without a real Keycloak server.
Returns:
KeycloakAuthClient instance.
"""
if settings.is_dev:
return MockKeycloakClient() # type: ignore[return-value]
return KeycloakAuthClient(settings)

View File

@@ -1,48 +0,0 @@
"""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
from app.application.interfaces import TransactionManager
class SessionTransactionManager(TransactionManager):
"""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.
Persists all pending changes to the database.
"""
await self._session.commit()
async def rollback(self) -> None:
"""Rollback the current transaction.
Discards all pending changes.
"""
await self._session.rollback()

View File

@@ -1,9 +0,0 @@
"""Internationalization support for the blog application.
This package provides translation dictionaries and the translation service
for localizing the web UI into multiple languages.
"""
from app.infrastructure.i18n.translator import SUPPORTED_LOCALES, TranslationService, _
__all__ = ["SUPPORTED_LOCALES", "TranslationService", "_"]

View File

@@ -1,377 +0,0 @@
"""Translation dictionaries for i18n support.
This module provides translation dictionaries for all supported locales.
Translations are organized by feature area for maintainability.
Keys use dot-separated namespacing to avoid collisions.
"""
TRANSLATIONS: dict[str, dict[str, str]] = {
"en": {
"nav.home": "Home",
"nav.posts": "Posts",
"nav.about": "About",
"header.logo": "Blog",
"header.profile": "Profile",
"header.new_post": "New Post",
"header.sign_out": "Sign Out",
"header.sign_in": "Sign In",
"header.toggle_menu": "Toggle menu",
"header.toggle_theme": "Toggle dark mode",
"header.lang_switcher": "Language",
"footer.copyright": "© 2026 Blog. All rights reserved.",
"footer.about": "About",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.api": "API",
"home.title": "Blog - Home",
"home.meta_description": "Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.",
"home.meta_keywords": "blog, articles, posts, writing, fastapi, python",
"home.page_title": "Latest Posts",
"home.page_subtitle": "Discover stories, thinking, and expertise from writers on any topic.",
"home.write_post": "Write a Post",
"home.status_published": "Published",
"home.status_draft": "Draft",
"home.read_more": "Read more",
"home.pagination_previous": "Previous",
"home.pagination_next": "Next",
"home.empty_title": "No posts yet",
"home.empty_description": "Be the first to write a post!",
"home.empty_action": "Create your first post",
"post.status_published": "Published",
"post.status_draft": "Draft",
"post.back_to_posts": "Back to posts",
"post.edit": "Edit",
"post.delete": "Delete",
"post.delete_confirm": "Are you sure you want to delete this post?",
"post.comments": "Comments",
"post.write_comment": "Write a Comment",
"post.comment_placeholder": "Write a comment... Markdown is supported.",
"post.replying_to": "Replying to",
"post.cancel_reply": "Cancel reply",
"post.cancel": "Cancel",
"post.submit_comment": "Submit Comment",
"post.reply": "Reply",
"post.no_comments": "No comments yet. Be the first to comment!",
"post.comment_error": "Failed to post comment. Please try again.",
"post_form.title_edit": "Edit Post",
"post_form.title_new": "New Post",
"post_form.page_title_edit": "Edit Post",
"post_form.page_title_new": "Create New Post",
"post_form.label_title": "Title",
"post_form.placeholder_title": "Enter post title",
"post_form.hint_title": "A catchy title for your post",
"post_form.label_content": "Content",
"post_form.placeholder_content": "Write your post content here...",
"post_form.hint_content": "The main content of your post. Markdown is supported.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Comma-separated list of tags",
"post_form.cancel": "Cancel",
"post_form.save_draft": "Save as Draft",
"post_form.update_post": "Update Post",
"post_form.publish_post": "Publish Post",
"profile.title": "User Profile",
"profile.email": "Email:",
"profile.not_provided": "Not provided",
"profile.user_id": "User ID:",
"profile.name": "Name:",
"profile.back_home": "Back to Home",
"profile.new_post": "New Post",
"about.title": "About",
"about.page_title": "About",
"about.description": "A modern blog built with FastAPI and Domain-Driven Design architecture.",
"about.signed_in": "Signed in as {username}.",
"about.browsing_guest": "You are browsing as a guest.",
"about.back_home": "Back to Home",
"flash.post_published": "Post published successfully!",
"flash.post_saved_draft": "Post saved as draft!",
"flash.post_updated": "Post updated successfully!",
"flash.post_deleted": "Post deleted successfully!",
"flash.post_not_found": "Post not found.",
"base.close_message": "Close message",
"base.default_title": "Blog",
"base.meta_description": "Blog - A modern blogging platform built with FastAPI",
"base.meta_keywords": "blog, articles, posts, writing",
"base.meta_author": "Blog Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"ru": {
"nav.home": "Главная",
"nav.posts": "Статьи",
"nav.about": "О нас",
"header.logo": "Блог",
"header.profile": "Профиль",
"header.new_post": "Новая статья",
"header.sign_out": "Выйти",
"header.sign_in": "Войти",
"header.toggle_menu": "Открыть меню",
"header.toggle_theme": "Сменить тему",
"header.lang_switcher": "Язык",
"footer.copyright": "© 2026 Блог. Все права защищены.",
"footer.about": "О нас",
"footer.privacy": "Конфиденциальность",
"footer.terms": "Условия",
"footer.api": "API",
"home.title": "Блог — Главная",
"home.meta_description": "Откройте для себя истории, мысли и опыт авторов на любую тему. Современный блог на FastAPI.",
"home.meta_keywords": "блог, статьи, посты, fastapi, python",
"home.page_title": "Последние статьи",
"home.page_subtitle": "Откройте для себя истории, мысли и опыт авторов на любую тему.",
"home.write_post": "Написать статью",
"home.status_published": "Опубликовано",
"home.status_draft": "Черновик",
"home.read_more": "Читать далее",
"home.pagination_previous": "Назад",
"home.pagination_next": "Вперёд",
"home.empty_title": "Статей пока нет",
"home.empty_description": "Будьте первым, кто напишет статью!",
"home.empty_action": "Создать первую статью",
"post.status_published": "Опубликовано",
"post.status_draft": "Черновик",
"post.back_to_posts": "К списку статей",
"post.edit": "Редактировать",
"post.delete": "Удалить",
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
"post.comments": "Комментарии",
"post.write_comment": "Написать комментарий",
"post.comment_placeholder": "Напишите комментарий... Поддерживается Markdown.",
"post.replying_to": "Ответ",
"post.cancel_reply": "Отменить ответ",
"post.cancel": "Отмена",
"post.submit_comment": "Отправить",
"post.reply": "Ответить",
"post.no_comments": "Пока нет комментариев. Будьте первым!",
"post.comment_error": "Не удалось отправить комментарий. Попробуйте снова.",
"post_form.title_edit": "Редактировать статью",
"post_form.title_new": "Новая статья",
"post_form.page_title_edit": "Редактировать статью",
"post_form.page_title_new": "Создать новую статью",
"post_form.label_title": "Заголовок",
"post_form.placeholder_title": "Введите заголовок статьи",
"post_form.hint_title": "Запоминающийся заголовок для вашей статьи",
"post_form.label_content": "Содержание",
"post_form.placeholder_content": "Напишите содержание статьи здесь...",
"post_form.hint_content": "Основное содержание вашей статьи. Поддерживается Markdown.",
"post_form.label_tags": "Теги",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Список тегов через запятую",
"post_form.cancel": "Отмена",
"post_form.save_draft": "Сохранить черновик",
"post_form.update_post": "Обновить статью",
"post_form.publish_post": "Опубликовать статью",
"profile.title": "Профиль пользователя",
"profile.email": "Email:",
"profile.not_provided": "Не указан",
"profile.user_id": "ID пользователя:",
"profile.name": "Имя:",
"profile.back_home": "На главную",
"profile.new_post": "Новая статья",
"about.title": "О нас",
"about.page_title": "О нас",
"about.description": "Современный блог на FastAPI с архитектурой Domain-Driven Design.",
"about.signed_in": "Вы вошли как {username}.",
"about.browsing_guest": "Вы просматриваете как гость.",
"about.back_home": "На главную",
"flash.post_published": "Статья успешно опубликована!",
"flash.post_saved_draft": "Статья сохранена как черновик!",
"flash.post_updated": "Статья успешно обновлена!",
"flash.post_deleted": "Статья успешно удалена!",
"flash.post_not_found": "Статья не найдена.",
"base.close_message": "Закрыть сообщение",
"base.default_title": "Блог",
"base.meta_description": "Блог — современная платформа для блогов на FastAPI",
"base.meta_keywords": "блог, статьи, посты, письмо",
"base.meta_author": "Команда блога",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"fr": {
"nav.home": "Accueil",
"nav.posts": "Articles",
"nav.about": "À propos",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Nouvel article",
"header.sign_out": "Déconnexion",
"header.sign_in": "Connexion",
"header.toggle_menu": "Menu",
"header.toggle_theme": "Changer le thème",
"header.lang_switcher": "Langue",
"footer.copyright": "© 2026 Blog. Tous droits réservés.",
"footer.about": "À propos",
"footer.privacy": "Confidentialité",
"footer.terms": "Conditions",
"footer.api": "API",
"home.title": "Blog — Accueil",
"home.meta_description": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets. Un blog moderne avec FastAPI.",
"home.meta_keywords": "blog, articles, posts, écriture, fastapi, python",
"home.page_title": "Derniers articles",
"home.page_subtitle": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets.",
"home.write_post": "Écrire un article",
"home.status_published": "Publié",
"home.status_draft": "Brouillon",
"home.read_more": "Lire la suite",
"home.pagination_previous": "Précédent",
"home.pagination_next": "Suivant",
"home.empty_title": "Aucun article pour le moment",
"home.empty_description": "Soyez le premier à écrire un article !",
"home.empty_action": "Créer votre premier article",
"post.status_published": "Publié",
"post.status_draft": "Brouillon",
"post.back_to_posts": "Retour aux articles",
"post.edit": "Modifier",
"post.delete": "Supprimer",
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
"post.comments": "Commentaires",
"post.write_comment": "Écrire un commentaire",
"post.comment_placeholder": "Écrivez un commentaire... Markdown est supporté.",
"post.replying_to": "Répondre à",
"post.cancel_reply": "Annuler la réponse",
"post.cancel": "Annuler",
"post.submit_comment": "Soumettre",
"post.reply": "Répondre",
"post.no_comments": "Aucun commentaire pour le moment. Soyez le premier !",
"post.comment_error": "Échec de l'envoi du commentaire. Veuillez réessayer.",
"post_form.title_edit": "Modifier l'article",
"post_form.title_new": "Nouvel article",
"post_form.page_title_edit": "Modifier l'article",
"post_form.page_title_new": "Créer un nouvel article",
"post_form.label_title": "Titre",
"post_form.placeholder_title": "Entrez le titre de l'article",
"post_form.hint_title": "Un titre accrocheur pour votre article",
"post_form.label_content": "Contenu",
"post_form.placeholder_content": "Écrivez votre article ici...",
"post_form.hint_content": "Le contenu principal de votre article. Markdown est supporté.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Liste de tags séparés par des virgules",
"post_form.cancel": "Annuler",
"post_form.save_draft": "Sauvegarder le brouillon",
"post_form.update_post": "Mettre à jour",
"post_form.publish_post": "Publier",
"profile.title": "Profil utilisateur",
"profile.email": "Email :",
"profile.not_provided": "Non fourni",
"profile.user_id": "ID utilisateur :",
"profile.name": "Nom :",
"profile.back_home": "Retour à l'accueil",
"profile.new_post": "Nouvel article",
"about.title": "À propos",
"about.page_title": "À propos",
"about.description": "Un blog moderne construit avec FastAPI et une architecture Domain-Driven Design.",
"about.signed_in": "Connecté en tant que {username}.",
"about.browsing_guest": "Vous naviguez en tant qu'invité.",
"about.back_home": "Retour à l'accueil",
"flash.post_published": "Article publié avec succès !",
"flash.post_saved_draft": "Article sauvegardé comme brouillon !",
"flash.post_updated": "Article mis à jour avec succès !",
"flash.post_deleted": "Article supprimé avec succès !",
"flash.post_not_found": "Article non trouvé.",
"base.close_message": "Fermer le message",
"base.default_title": "Blog",
"base.meta_description": "Blog — Une plateforme de blog moderne construite avec FastAPI",
"base.meta_keywords": "blog, articles, posts, écriture",
"base.meta_author": "Équipe du blog",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"de": {
"nav.home": "Startseite",
"nav.posts": "Beiträge",
"nav.about": "Über uns",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Neuer Beitrag",
"header.sign_out": "Abmelden",
"header.sign_in": "Anmelden",
"header.toggle_menu": "Menü umschalten",
"header.toggle_theme": "Design umschalten",
"header.lang_switcher": "Sprache",
"footer.copyright": "© 2026 Blog. Alle Rechte vorbehalten.",
"footer.about": "Über uns",
"footer.privacy": "Datenschutz",
"footer.terms": "AGB",
"footer.api": "API",
"home.title": "Blog — Startseite",
"home.meta_description": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema. Ein moderner Blog mit FastAPI.",
"home.meta_keywords": "Blog, Artikel, Beiträge, Schreiben, Fastapi, Python",
"home.page_title": "Neueste Beiträge",
"home.page_subtitle": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema.",
"home.write_post": "Beitrag schreiben",
"home.status_published": "Veröffentlicht",
"home.status_draft": "Entwurf",
"home.read_more": "Weiterlesen",
"home.pagination_previous": "Zurück",
"home.pagination_next": "Weiter",
"home.empty_title": "Noch keine Beiträge",
"home.empty_description": "Schreiben Sie den ersten Beitrag!",
"home.empty_action": "Ersten Beitrag erstellen",
"post.status_published": "Veröffentlicht",
"post.status_draft": "Entwurf",
"post.back_to_posts": "Zurück zu den Beiträgen",
"post.edit": "Bearbeiten",
"post.delete": "Löschen",
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
"post.comments": "Kommentare",
"post.write_comment": "Kommentar schreiben",
"post.comment_placeholder": "Schreiben Sie einen Kommentar... Markdown wird unterstützt.",
"post.replying_to": "Antwort an",
"post.cancel_reply": "Antwort abbrechen",
"post.cancel": "Abbrechen",
"post.submit_comment": "Absenden",
"post.reply": "Antworten",
"post.no_comments": "Noch keine Kommentare. Seien Sie der Erste!",
"post.comment_error": "Kommentar konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
"post_form.title_edit": "Beitrag bearbeiten",
"post_form.title_new": "Neuer Beitrag",
"post_form.page_title_edit": "Beitrag bearbeiten",
"post_form.page_title_new": "Neuen Beitrag erstellen",
"post_form.label_title": "Titel",
"post_form.placeholder_title": "Geben Sie den Beitragstitel ein",
"post_form.hint_title": "Ein eingängiger Titel für Ihren Beitrag",
"post_form.label_content": "Inhalt",
"post_form.placeholder_content": "Schreiben Sie Ihren Beitrag hier...",
"post_form.hint_content": "Der Hauptinhalt Ihres Beitrags. Markdown wird unterstützt.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Kommagetrennte Tag-Liste",
"post_form.cancel": "Abbrechen",
"post_form.save_draft": "Als Entwurf speichern",
"post_form.update_post": "Beitrag aktualisieren",
"post_form.publish_post": "Beitrag veröffentlichen",
"profile.title": "Benutzerprofil",
"profile.email": "E-Mail:",
"profile.not_provided": "Nicht angegeben",
"profile.user_id": "Benutzer-ID:",
"profile.name": "Name:",
"profile.back_home": "Zurück zur Startseite",
"profile.new_post": "Neuer Beitrag",
"about.title": "Über uns",
"about.page_title": "Über uns",
"about.description": "Ein moderner Blog, erstellt mit FastAPI und Domain-Driven Design Architektur.",
"about.signed_in": "Angemeldet als {username}.",
"about.browsing_guest": "Sie surfen als Gast.",
"about.back_home": "Zurück zur Startseite",
"flash.post_published": "Beitrag erfolgreich veröffentlicht!",
"flash.post_saved_draft": "Beitrag als Entwurf gespeichert!",
"flash.post_updated": "Beitrag erfolgreich aktualisiert!",
"flash.post_deleted": "Beitrag erfolgreich gelöscht!",
"flash.post_not_found": "Beitrag nicht gefunden.",
"base.close_message": "Nachricht schließen",
"base.default_title": "Blog",
"base.meta_description": "Blog — Eine moderne Blogging-Plattform mit FastAPI",
"base.meta_keywords": "Blog, Artikel, Beiträge, Schreiben",
"base.meta_author": "Blog-Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
}

View File

@@ -1,78 +0,0 @@
"""Translation service for i18n support.
This module provides the translation service that resolves translation keys
to localized strings using in-memory translation dictionaries. Falls back
from requested locale through English to the raw key.
"""
from app.infrastructure.i18n.translations import TRANSLATIONS
SUPPORTED_LOCALES = frozenset({"en", "ru", "fr", "de"})
DEFAULT_LOCALE = "en"
class TranslationService:
"""Service for resolving translation keys to localized strings.
Provides a singleton-like interface for translating UI strings
across the application. Falls back through requested locale to
English and finally to the raw key if no translation exists.
Attributes:
translations: Dictionary of locale to key to string mappings.
"""
_instance: "TranslationService | None" = None
def __init__(self) -> None:
"""Initialize translation service with translation data."""
self.translations = TRANSLATIONS
@classmethod
def get_instance(cls) -> "TranslationService":
"""Get or create the singleton instance.
Returns:
The shared TranslationService instance.
"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_text(self, key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Get translated text for a given key and locale.
Resolves the key through the locale chain: requested locale,
then English fallback, then the raw key itself.
Args:
key: Translation key (e.g. ``nav.home``).
locale: Target locale code (e.g. ``en``, ``ru``, ``fr``, ``de``).
Returns:
Translated string if found, otherwise the English version
or the key itself as last resort.
"""
locale_translations = self.translations.get(locale)
if locale_translations is not None and key in locale_translations:
return locale_translations[key]
if locale != DEFAULT_LOCALE:
fallback = self.translations.get(DEFAULT_LOCALE, {}).get(key)
if fallback is not None:
return fallback
return key
def _(key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Convenience function for translating a single key.
Args:
key: Translation key to look up.
locale: Target locale code.
Returns:
Translated string or the key itself if no translation found.
"""
return TranslationService.get_instance().get_text(key, locale)

View File

@@ -1,19 +0,0 @@
"""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,
generic_exception_handler,
http_exception_handler,
register_exception_handlers,
)
__all__ = [
"domain_exception_handler",
"http_exception_handler",
"generic_exception_handler",
"register_exception_handlers",
]

View File

@@ -1,132 +0,0 @@
"""Exception handling middleware.
This module provides exception handlers for FastAPI application.
Maps domain exceptions to appropriate HTTP status codes.
"""
from datetime import UTC, datetime
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
ValidationException,
)
def get_status_code(exc: DomainException) -> int:
"""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
case UnauthorizedException():
return 401
case ForbiddenException():
return 403
case NotFoundException():
return 404
case AlreadyExistsException():
return 409
case _:
return 500
async def domain_exception_handler(request: Request, exc: DomainException) -> JSONResponse:
"""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,
content={
"error": exc.__class__.__name__,
"message": exc.message,
"timestamp": datetime.now(UTC).isoformat(),
"path": str(request.url.path),
},
)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""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={
"error": "HTTPException",
"message": str(exc.detail),
"timestamp": datetime.now(UTC).isoformat(),
"path": str(request.url.path),
},
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""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={
"error": "InternalServerError",
"message": "An unexpected error occurred",
"timestamp": datetime.now(UTC).isoformat(),
"path": str(request.url.path),
},
)
def register_exception_handlers(app: FastAPI) -> None:
"""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")
app.add_exception_handler(DomainException, domain_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, http_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, generic_exception_handler)

View File

@@ -1,9 +0,0 @@
"""Repository implementations.
This module re-exports concrete repository implementations
for data access using SQLAlchemy ORM.
"""
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
__all__ = ["SQLAlchemyPostRepository"]

View File

@@ -1,240 +0,0 @@
"""SQLAlchemy implementation of CommentRepository.
This module provides the concrete implementation of CommentRepository
using SQLAlchemy ORM for data persistence.
"""
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.repositories import CommentRepository
from app.domain.value_objects.comment_content import CommentContent
from app.infrastructure.database.models import CommentLikeORM, CommentORM
class SQLAlchemyCommentRepository(CommentRepository):
"""SQLAlchemy implementation of Comment repository.
Provides data access methods for Comment entities using SQLAlchemy ORM.
Handles conversion between domain entities and ORM models.
Attributes:
_session: SQLAlchemy async session for database operations.
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with session.
Args:
session: SQLAlchemy async session instance.
"""
self._session = session
def _to_domain(self, orm: CommentORM) -> Comment:
"""Convert ORM model to domain entity.
Args:
orm: SQLAlchemy ORM model instance.
Returns:
Domain Comment entity with validated value objects.
"""
return Comment(
id=UUID(orm.id),
post_id=UUID(orm.post_id),
author_id=orm.author_id,
content=CommentContent(orm.content),
parent_id=UUID(orm.parent_id) if orm.parent_id else None,
like_count=orm.like_count,
created_at=orm.created_at,
updated_at=orm.updated_at,
)
def _to_orm(self, comment: Comment) -> CommentORM:
"""Convert domain entity to ORM model.
Args:
comment: Domain Comment entity.
Returns:
SQLAlchemy ORM model instance.
"""
return CommentORM(
id=str(comment.id),
post_id=str(comment.post_id),
author_id=comment.author_id,
content=comment.content.value,
parent_id=str(comment.parent_id) if comment.parent_id else None,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)
async def get_by_id(self, entity_id: UUID) -> Comment | None:
"""Get comment by ID.
Args:
entity_id: Unique identifier of the comment.
Returns:
Comment entity if found, None otherwise.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None
async def get_all(self) -> list[Comment]:
"""Get all comments.
Returns:
List of all Comment entities.
"""
result = await self._session.execute(select(CommentORM))
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def add(self, entity: Comment) -> None:
"""Add new comment.
Args:
entity: Comment entity to add.
"""
orm = self._to_orm(entity)
self._session.add(orm)
async def update(self, entity: Comment) -> None:
"""Update existing comment.
Args:
entity: Comment entity with updated data.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity.id))
)
orm = result.scalar_one()
orm.content = entity.content.value
orm.like_count = entity.like_count
orm.updated_at = entity.updated_at
async def delete(self, entity_id: UUID) -> None:
"""Delete comment by ID.
Args:
entity_id: Unique identifier of the comment to delete.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)
async def exists(self, entity_id: UUID) -> bool:
"""Check if comment exists.
Args:
entity_id: Unique identifier of the comment.
Returns:
True if comment exists, False otherwise.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
return result.scalar_one_or_none() is not None
async def get_by_post(self, post_id: UUID) -> list[Comment]:
"""Get all comments for a post, ordered by creation time.
Args:
post_id: UUID of the post.
Returns:
List of Comment entities for the post.
"""
result = await self._session.execute(
select(CommentORM)
.where(CommentORM.post_id == str(post_id))
.order_by(CommentORM.created_at.asc())
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def count_by_post(self, post_id: UUID) -> int:
"""Get comment count for a post.
Args:
post_id: UUID of the post.
Returns:
Number of comments on the post.
"""
result = await self._session.execute(
select(func.count()).select_from(CommentORM).where(CommentORM.post_id == str(post_id))
)
count: int = result.scalar() or 0
return count
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
"""Get a like by comment and user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
Returns:
CommentLike if found, None otherwise.
"""
result = await self._session.execute(
select(CommentLikeORM).where(
CommentLikeORM.comment_id == str(comment_id),
CommentLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if not orm:
return None
return CommentLike(
id=UUID(orm.id),
comment_id=UUID(orm.comment_id),
liked_by=orm.liked_by,
created_at=orm.created_at,
)
async def add_like(self, like: CommentLike) -> None:
"""Add a new like to a comment.
Args:
like: CommentLike entity to add.
"""
orm = CommentLikeORM(
id=str(like.id),
comment_id=str(like.comment_id),
liked_by=like.liked_by,
created_at=like.created_at,
)
self._session.add(orm)
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
"""Remove a like from a comment by user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
"""
result = await self._session.execute(
select(CommentLikeORM).where(
CommentLikeORM.comment_id == str(comment_id),
CommentLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)

View File

@@ -1,347 +0,0 @@
"""SQLAlchemy implementation of PostRepository.
This module provides the concrete implementation of PostRepository
using SQLAlchemy ORM for data persistence.
"""
from uuid import UUID
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities import Post
from app.domain.entities.like import PostLike
from app.domain.repositories import PostRepository
from app.domain.value_objects import Content, Slug, Title
from app.infrastructure.database.models import PostLikeORM, PostORM
class SQLAlchemyPostRepository(PostRepository):
"""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.
Args:
orm: SQLAlchemy ORM model instance.
Returns:
Domain Post entity with validated value objects.
"""
return Post(
id=UUID(orm.id),
title=Title(orm.title),
content=Content(orm.content),
slug=Slug(orm.slug),
author_id=orm.author_id,
published=orm.published,
like_count=orm.like_count,
tags=orm.tags or [],
created_at=orm.created_at,
updated_at=orm.updated_at,
)
def _to_orm(self, post: Post) -> PostORM:
"""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,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags,
created_at=post.created_at,
updated_at=post.updated_at,
)
async def get_by_id(self, entity_id: UUID) -> Post | None:
"""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.
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.
Args:
entity: Post entity to add.
"""
orm = self._to_orm(entity)
self._session.add(orm)
async def update(self, entity: Post) -> None:
"""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()
orm.title = entity.title.value
orm.content = entity.content.value
orm.slug = entity.slug.value
orm.published = entity.published
orm.like_count = entity.like_count
orm.tags = entity.tags
orm.updated_at = entity.updated_at
async def delete(self, entity_id: UUID) -> None:
"""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.
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.
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
async def get_by_author(
self,
author_id: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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)
.order_by(PostORM.created_at.desc())
)
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
result = await self._session.execute(query)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_published(
self,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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)).order_by(PostORM.created_at.desc())
)
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
result = await self._session.execute(query)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_by_tag(
self,
tag: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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)
if offset is not None:
query = query.offset(offset)
result = await self._session.execute(query)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def slug_exists(self, slug: str) -> bool:
"""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
async def search(
self,
query: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""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_(
PostORM.title.ilike(search_pattern),
PostORM.content.ilike(search_pattern),
)
)
if limit is not None:
stmt = stmt.limit(limit)
if offset is not None:
stmt = stmt.offset(offset)
result = await self._session.execute(stmt)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None:
"""Get a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
Returns:
PostLike if found, None otherwise.
"""
result = await self._session.execute(
select(PostLikeORM).where(
PostLikeORM.post_id == str(post_id),
PostLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if not orm:
return None
return PostLike(
id=UUID(orm.id),
post_id=UUID(orm.post_id),
liked_by=orm.liked_by,
created_at=orm.created_at,
)
async def add_like(self, like: PostLike) -> None:
"""Add a new like.
Args:
like: PostLike entity to add.
"""
orm = PostLikeORM(
id=str(like.id),
post_id=str(like.post_id),
liked_by=like.liked_by,
created_at=like.created_at,
)
self._session.add(orm)
async def remove_like(self, post_id: UUID, liked_by: str) -> None:
"""Remove a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
"""
result = await self._session.execute(
select(PostLikeORM).where(
PostLikeORM.post_id == str(post_id),
PostLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)

View File

@@ -1,152 +1,21 @@
"""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, Awaitable, Callable
from contextlib import asynccontextmanager
import uvicorn
from dishka import make_async_container
from dishka.integrations.fastapi import setup_dishka
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.infrastructure import close_db, init_db, register_exception_handlers, settings
from app.infrastructure.di.providers import (
DatabaseProvider,
KeycloakProvider,
RepositoryProvider,
TransactionManagerProvider,
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
from app.presentation.web.locale import setup_locale_manager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
"""Application lifespan manager.
Handles startup and shutdown tasks for the application.
Args:
app: FastAPI application instance.
Yields:
None during application runtime.
"""
await init_db()
async def lifespan(app: FastAPI):
yield
await close_db()
def app_factory() -> FastAPI:
"""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,
lifespan=lifespan,
docs_url="/docs" if settings.is_dev else None,
redoc_url="/redoc" if settings.is_dev else None,
)
container = make_async_container(
DatabaseProvider(),
RepositoryProvider(),
TransactionManagerProvider(),
UseCaseProvider(),
KeycloakProvider(),
)
setup_dishka(container, app)
register_exception_handlers(app)
register_error_handlers(app)
@app.middleware("http")
async def flash_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Middleware to setup flash manager for each request."""
await setup_flash_manager(request)
response = await call_next(request)
if hasattr(request.state, "flash_manager"):
request.state.flash_manager.set_cookie(response)
return response
@app.middleware("http")
async def locale_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Middleware to detect and set locale for each request."""
await setup_locale_manager(request)
response = await call_next(request)
return response
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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
)
@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,
"env": settings.environment.value,
}
def app_factory():
app = FastAPI(lifespan=lifespan)
return app
def main() -> None:
"""Run the application.
Starts uvicorn server with application factory.
"""
uvicorn.run(
app_factory,
factory=True,
host=settings.app.host,
port=settings.app.port,
)
def main():
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
if __name__ == "__main__":

1
app/modules/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Feature modules - business logic organized by domain."""

View File

@@ -1,21 +0,0 @@
"""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 (
PostCreateSchema,
PostListResponseSchema,
PostResponseSchema,
PostUpdateSchema,
)
__all__ = [
"router",
"PostCreateSchema",
"PostUpdateSchema",
"PostResponseSchema",
"PostListResponseSchema",
]

View File

@@ -1,12 +0,0 @@
"""API router configuration.
This module sets up the main API router and includes versioned
sub-routers for API organization.
"""
from fastapi import APIRouter
from app.presentation.api.v1 import router as v1_router
router = APIRouter()
router.include_router(v1_router)

View File

@@ -1,200 +0,0 @@
"""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
from dishka.integrations.fastapi import FromDishka
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.application import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
from app.domain.exceptions import ForbiddenException, UnauthorizedException
from app.domain.roles import Role, get_effective_role
from app.infrastructure.auth import KeycloakAuthClient, TokenInfo
CreatePostDep = FromDishka[CreatePostUseCase]
GetPostDep = FromDishka[GetPostUseCase]
UpdatePostDep = FromDishka[UpdatePostUseCase]
DeletePostDep = FromDishka[DeletePostUseCase]
ListPostsDep = FromDishka[ListPostsUseCase]
PublishPostDep = FromDishka[PublishPostUseCase]
ToggleLikeDep = FromDishka[TogglePostLikeUseCase]
CreateCommentDep = FromDishka[CreateCommentUseCase]
DeleteCommentDep = FromDishka[DeleteCommentUseCase]
ListCommentsDep = FromDishka[ListCommentsUseCase]
ToggleCommentLikeDep = FromDishka[ToggleCommentLikeUseCase]
security = HTTPBearer(auto_error=False)
async 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 = await request.state.dishka_container.get(KeycloakAuthClient)
return client
async def get_current_token_info(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
request: Request,
) -> TokenInfo:
"""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")
keycloak_client = await get_keycloak_client(request)
token = credentials.credentials
token_info = await keycloak_client.introspect_token(token)
if not token_info.is_valid:
raise UnauthorizedException("Invalid or expired token")
return 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.
Args:
token_info: Validated token info.
Returns:
User ID string from token.
"""
return token_info.user_id
CurrentUserDep = Annotated[str, Depends(get_current_user_id)]
TokenInfoDep = Annotated[TokenInfo, Depends(get_current_token_info)]
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.
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
keycloak_client = await get_keycloak_client(request)
token = credentials.credentials
token_info = await keycloak_client.introspect_token(token)
if token_info.is_valid:
return token_info
return None
OptionalTokenInfoDep = Annotated[TokenInfo | None, Depends(get_optional_token_info)]
async def get_optional_user_id(
token_info: OptionalTokenInfoDep,
) -> str | 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
OptionalUserDep = Annotated[str | None, Depends(get_optional_user_id)]
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)
return Role.GUEST
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.
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:
raise ForbiddenException(
f"Access denied. Required roles: {[r.value for r in allowed_roles]}"
)
return role
return Depends(check_role)
RequireAdmin = require_roles([Role.ADMIN])
RequireUser = require_roles([Role.USER, Role.ADMIN])
RequireAny = require_roles([Role.GUEST, Role.USER, Role.ADMIN])

View File

@@ -1,14 +0,0 @@
"""API v1 router.
This module sets up the version 1 API router and includes
all v1 endpoint routers.
"""
from fastapi import APIRouter
from app.presentation.api.v1.comments import router as comments_router
from app.presentation.api.v1.posts import router as posts_router
router = APIRouter(prefix="/v1")
router.include_router(posts_router)
router.include_router(comments_router)

View File

@@ -1,131 +0,0 @@
"""Comments API routes.
This module defines FastAPI routes for comment operations including
CRUD and like/unlike toggle.
"""
from uuid import UUID
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, status
from app.presentation.api.deps import (
CreateCommentDep,
CurrentRoleDep,
CurrentUserDep,
DeleteCommentDep,
ListCommentsDep,
ToggleCommentLikeDep,
)
from app.presentation.schemas import (
CommentCreateSchema,
CommentLikeResponseSchema,
CommentResponseSchema,
)
router = APIRouter(tags=["comments"], route_class=DishkaRoute)
@router.post(
"/posts/{post_id}/comments",
response_model=CommentResponseSchema,
status_code=status.HTTP_201_CREATED,
summary="Create a comment on a post",
)
async def create_comment(
post_id: UUID,
schema: CommentCreateSchema,
use_case: CreateCommentDep,
current_user_id: CurrentUserDep,
) -> CommentResponseSchema:
"""Create a comment on a blog post.
Args:
post_id: UUID of the post to comment on.
schema: Comment creation data.
use_case: CreateCommentUseCase dependency.
current_user_id: Authenticated user ID.
Returns:
CommentResponseSchema with created comment data.
"""
result = await use_case.execute(
post_id=post_id,
author_id=current_user_id,
content=schema.content,
parent_id=schema.parent_id,
)
return CommentResponseSchema(**result.__dict__)
@router.get(
"/posts/{post_id}/comments",
response_model=list[CommentResponseSchema],
summary="List comments for a post",
)
async def list_comments(
post_id: UUID,
use_case: ListCommentsDep,
) -> list[CommentResponseSchema]:
"""Get all comments for a blog post.
Args:
post_id: UUID of the post.
use_case: ListCommentsUseCase dependency.
Returns:
List of CommentResponseSchema for the post.
"""
results = await use_case.execute(post_id=post_id)
return [CommentResponseSchema(**r.__dict__) for r in results]
@router.delete(
"/comments/{comment_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a comment",
)
async def delete_comment(
comment_id: UUID,
use_case: DeleteCommentDep,
current_user_id: CurrentUserDep,
role: CurrentRoleDep,
) -> None:
"""Delete a comment.
Users can delete their own comments.
Args:
comment_id: UUID of the comment to delete.
use_case: DeleteCommentUseCase dependency.
current_user_id: Authenticated user ID.
role: Current user role.
"""
await use_case.execute(comment_id=comment_id, user_id=current_user_id)
@router.post(
"/comments/{comment_id}/like",
response_model=CommentLikeResponseSchema,
summary="Toggle like on a comment",
)
async def toggle_comment_like(
comment_id: UUID,
use_case: ToggleCommentLikeDep,
current_user_id: CurrentUserDep,
) -> CommentLikeResponseSchema:
"""Toggle like/unlike on a comment.
If the user already liked the comment, the like is removed (unlike).
Otherwise, a new like is added.
Args:
comment_id: UUID of the comment.
use_case: ToggleCommentLikeUseCase dependency.
current_user_id: Authenticated user ID.
Returns:
CommentLikeResponseSchema with updated like_count.
"""
result = await use_case.execute(comment_id, current_user_id)
return CommentLikeResponseSchema(id=result.id, like_count=result.like_count)

View File

@@ -1,374 +0,0 @@
"""Posts API routes.
This module defines FastAPI routes for blog post operations.
Implements CRUD endpoints with authentication and authorization.
"""
from uuid import UUID
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, status
from app.application.dtos import CreatePostDTO, UpdatePostDTO
from app.domain.exceptions import ForbiddenException
from app.domain.roles import Permission, has_permission
from app.presentation.api.deps import (
CreatePostDep,
CurrentRoleDep,
CurrentUserDep,
DeletePostDep,
GetPostDep,
ListPostsDep,
PublishPostDep,
ToggleLikeDep,
UpdatePostDep,
)
from app.presentation.schemas import (
PostCreateSchema,
PostListResponseSchema,
PostResponseSchema,
PostUpdateSchema,
)
router = APIRouter(prefix="/posts", tags=["posts"], route_class=DishkaRoute)
@router.post(
"",
response_model=PostResponseSchema,
status_code=status.HTTP_201_CREATED,
summary="Create a new post",
)
async def create_post(
schema: PostCreateSchema,
use_case: CreatePostDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""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,
author_id=current_user_id,
tags=schema.tags,
)
result = await use_case.execute(dto)
return PostResponseSchema(**result.__dict__)
@router.get(
"",
response_model=PostListResponseSchema,
summary="List posts",
)
async def list_posts(
use_case: ListPostsDep,
role: CurrentRoleDep,
include_unpublished: bool = False,
limit: int = 10,
offset: int = 0,
) -> PostListResponseSchema:
"""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.
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.
"""
limit = max(1, min(limit, 100))
offset = max(0, offset)
if include_unpublished:
if not has_permission(role, Permission.POST_READ_UNPUBLISHED):
raise ForbiddenException("Only admins can view unpublished posts")
results = await use_case.all_posts()
else:
results = await use_case.published_posts(limit=limit, offset=offset)
items = [PostResponseSchema(**r.__dict__) for r in results]
return PostListResponseSchema(items=items, total=len(items))
@router.get(
"/published",
response_model=PostListResponseSchema,
summary="List published posts",
)
async def list_published_posts(
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""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))
@router.get(
"/search",
response_model=PostListResponseSchema,
summary="Search posts",
)
async def search_posts(
query: str,
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""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))
@router.get(
"/by-tag/{tag}",
response_model=PostListResponseSchema,
summary="Get posts by tag",
)
async def get_posts_by_tag(
tag: str,
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""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))
@router.get(
"/by-author/{author_id}",
response_model=PostListResponseSchema,
summary="Get posts by author",
)
async def get_posts_by_author(
author_id: str,
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""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))
@router.get(
"/{post_id}",
response_model=PostResponseSchema,
summary="Get post by ID",
)
async def get_post(
post_id: UUID,
use_case: GetPostDep,
) -> PostResponseSchema:
"""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__)
@router.get(
"/slug/{slug}",
response_model=PostResponseSchema,
summary="Get post by slug",
)
async def get_post_by_slug(
slug: str,
use_case: GetPostDep,
) -> PostResponseSchema:
"""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__)
@router.patch(
"/{post_id}",
response_model=PostResponseSchema,
summary="Update post",
)
async def update_post(
post_id: UUID,
schema: PostUpdateSchema,
use_case: UpdatePostDep,
current_user_id: CurrentUserDep,
role: CurrentRoleDep,
) -> PostResponseSchema:
"""Update a post.
Args:
post_id: Unique post identifier.
schema: Update data.
use_case: UpdatePostUseCase dependency.
current_user_id: Authenticated user ID.
role: Current user role.
Returns:
PostResponseSchema with updated post data.
"""
dto = UpdatePostDTO(
title=schema.title,
content=schema.content,
tags=schema.tags,
)
result = await use_case.execute(post_id, dto, current_user_id, role)
return PostResponseSchema(**result.__dict__)
@router.delete(
"/{post_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete post",
)
async def delete_post(
post_id: UUID,
use_case: DeletePostDep,
current_user_id: CurrentUserDep,
role: CurrentRoleDep,
) -> None:
"""Delete a post.
Args:
post_id: Unique post identifier.
use_case: DeletePostUseCase dependency.
current_user_id: Authenticated user ID.
role: Current user role.
"""
await use_case.execute(post_id, current_user_id, role)
@router.post(
"/{post_id}/publish",
response_model=PostResponseSchema,
summary="Publish post",
)
async def publish_post(
post_id: UUID,
use_case: PublishPostDep,
current_user_id: CurrentUserDep,
role: CurrentRoleDep,
) -> PostResponseSchema:
"""Publish a post.
Args:
post_id: Unique post identifier.
use_case: PublishPostUseCase dependency.
current_user_id: Authenticated user ID.
role: Current user role.
Returns:
PostResponseSchema with published post data.
"""
result = await use_case.publish(post_id, current_user_id, role)
return PostResponseSchema(**result.__dict__)
@router.post(
"/{post_id}/unpublish",
response_model=PostResponseSchema,
summary="Unpublish post",
)
async def unpublish_post(
post_id: UUID,
use_case: PublishPostDep,
current_user_id: CurrentUserDep,
role: CurrentRoleDep,
) -> PostResponseSchema:
"""Unpublish a post.
Args:
post_id: Unique post identifier.
use_case: PublishPostUseCase dependency.
current_user_id: Authenticated user ID.
role: Current user role.
Returns:
PostResponseSchema with unpublished post data.
"""
result = await use_case.unpublish(post_id, current_user_id, role)
return PostResponseSchema(**result.__dict__)
@router.post(
"/{post_id}/like",
response_model=PostResponseSchema,
summary="Toggle like on a post",
)
async def toggle_like(
post_id: UUID,
use_case: ToggleLikeDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""Toggle like/unlike on a post.
If the user already liked the post, the like is removed (unlike).
Otherwise, a new like is added.
Args:
post_id: Unique identifier of the post.
use_case: TogglePostLikeUseCase dependency.
current_user_id: Authenticated user ID.
Returns:
PostResponseSchema with updated like_count.
"""
result = await use_case.execute(post_id, current_user_id)
return PostResponseSchema(**result.__dict__)

View File

@@ -1,33 +0,0 @@
"""Presentation schemas.
This module re-exports all Pydantic schemas used for
request/response validation in the API layer.
"""
from app.presentation.schemas.comment import (
CommentCreateSchema,
CommentLikeResponseSchema,
CommentResponseSchema,
)
from app.presentation.schemas.post import (
PostBaseSchema,
PostCreateSchema,
PostListResponseSchema,
PostPublishSchema,
PostResponseSchema,
PostSearchSchema,
PostUpdateSchema,
)
__all__ = [
"PostBaseSchema",
"PostCreateSchema",
"PostUpdateSchema",
"PostResponseSchema",
"PostListResponseSchema",
"PostSearchSchema",
"PostPublishSchema",
"CommentCreateSchema",
"CommentResponseSchema",
"CommentLikeResponseSchema",
]

View File

@@ -1,58 +0,0 @@
"""Pydantic schemas for comments.
This module defines Pydantic models for comment request/response
validation in the API layer.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class CommentCreateSchema(BaseModel):
"""Schema for creating a comment.
Attributes:
content: Comment text content (Markdown supported).
parent_id: Optional parent comment ID for replies.
"""
content: str = Field(..., min_length=1, max_length=5000, description="Comment content")
parent_id: UUID | None = Field(default=None, description="Parent comment ID for replies")
class CommentResponseSchema(BaseModel):
"""Schema for comment response.
Attributes:
id: Unique comment identifier.
post_id: UUID of the parent post.
author_id: Comment author identifier.
content: Comment content text.
parent_id: Optional parent comment ID.
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
id: UUID
post_id: UUID
author_id: str
content: str
parent_id: UUID | None = None
like_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class CommentLikeResponseSchema(BaseModel):
"""Schema for comment like response.
Attributes:
id: Comment identifier.
like_count: Updated like count.
"""
id: UUID
like_count: int

View File

@@ -1,125 +0,0 @@
"""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
from pydantic import BaseModel, ConfigDict, Field
class PostBaseSchema(BaseModel):
"""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)
title: str = Field(..., min_length=3, max_length=200)
content: str = Field(..., min_length=10, max_length=50000)
class PostCreateSchema(PostBaseSchema):
"""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.
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)
title: str | None = Field(None, min_length=3, max_length=200)
content: str | None = Field(None, min_length=10, max_length=50000)
tags: list[str] | None = None
class PostResponseSchema(BaseModel):
"""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)
id: UUID
title: str
content: str
slug: str
author_id: str
published: bool
like_count: int = 0
tags: list[str]
created_at: datetime
updated_at: datetime
class PostListResponseSchema(BaseModel):
"""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.
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.
Publication status toggle.
Attributes:
published: Desired publication status.
"""
published: bool

View File

@@ -1,184 +0,0 @@
<!DOCTYPE html>
<html lang="{{ current_locale }}" 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 %}{{ _('base.meta_description', current_locale) }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}{{ _('base.meta_keywords', current_locale) }}{% endblock %}">
<meta name="author" content="{% block meta_author %}{{ _('base.meta_author', current_locale) }}{% 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 %}{{ _('base.default_title', current_locale) }}{% 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">
<link rel="stylesheet" href="/static/css/markdown.css" data-testid="markdown-stylesheet">
<link rel="stylesheet" href="/static/css/pygments.css" data-testid="pygments-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="{{ _('base.close_message', current_locale) }}">&times;</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>

View File

@@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %}
{% block content %}
<div class="page-header" data-testid="page-header-about">
<h1 class="page-title" data-testid="page-title-about">{{ _('about.page_title', current_locale) }}</h1>
</div>
<div class="card" data-testid="about-card">
<div class="card-body" data-testid="about-card-body">
<p data-testid="about-description">
{{ _('about.description', current_locale) }}
</p>
<div class="divider" data-testid="about-divider"></div>
<p data-testid="about-user">
{% if user %}
{{ _('about.signed_in', current_locale).format(username=user.username) }}
{% else %}
{{ _('about.browsing_guest', current_locale) }}
{% endif %}
</p>
</div>
<div class="card-footer" data-testid="about-card-footer">
<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>
{{ _('about.back_home', current_locale) }}
</a>
</div>
</div>
{% endblock %}

View File

@@ -1,111 +0,0 @@
{% 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 %}

View File

@@ -1,108 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% 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">{{ _('home.page_title', current_locale) }}</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">{{ _('home.page_subtitle', current_locale) }}</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>
{{ _('home.write_post', current_locale) }}
</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 }}" 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 }}">{{ _('home.status_published', current_locale) }}</span>
{% else %}
<span class="badge" data-testid="post-status-{{ post.id }}">{{ _('home.status_draft', current_locale) }}</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>
<span class="post-card-meta-item" data-testid="like-count-{{ post.id }}">
👍 {{ post.like_count }}
</span>
<span class="post-card-meta-item" data-testid="comment-count-{{ post.id }}">
💬 {{ post.comment_count }}
</span>
</div>
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
{{ post.content[:200] }}{% if post.content|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 }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
{{ _('home.read_more', current_locale) }}
<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="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</span>
{% endif %}
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
{% if has_next %}
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</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">{{ _('home.empty_title', current_locale) }}</h3>
<p data-testid="empty-state-description">{{ _('home.empty_description', current_locale) }}</p>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">{{ _('home.empty_action', current_locale) }}</a>
</div>
{% endif %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More