Compare commits
48 Commits
main
...
99acd9d287
| Author | SHA1 | Date | |
|---|---|---|---|
| 99acd9d287 | |||
| 96ecad0c6f | |||
| ca84bd7fac | |||
| 9124aa17d5 | |||
| 0e46a5f41b | |||
| 7bf9cce337 | |||
| 8ca36cdb44 | |||
| 7ff3fa0992 | |||
| 63da25174e | |||
| 30d9e287a7 | |||
| c8e19e3ce5 | |||
| 3cf6c94da2 | |||
| 4497f452a1 | |||
| 391ecaa4b0 | |||
| de92f73f58 | |||
| d32ad29abc | |||
| 4e6505c598 | |||
| c9b380c601 | |||
| 448da0396a | |||
| c790b6edc6 | |||
| 9cc2f6284d | |||
| 5ee1decca2 | |||
| 6eddde5c70 | |||
| 7270d544a5 | |||
| cf4982c0e5 | |||
| 714342f5ac | |||
| 4dede58d8f | |||
| 46cc06b596 | |||
| 41f2a3d98e | |||
| 1f6e13fbd5 | |||
| 981f26794d | |||
| d62c799a28 | |||
| ce2c052684 | |||
| 41b6698c55 | |||
| b37ec1390d | |||
| b1878e470f | |||
| 4eee261107 | |||
| 0cb706e54b | |||
| 2aed9f5c8a | |||
| e2802d83f2 | |||
| ca4e8877a5 | |||
| 6a528bcbb9 | |||
| 14adcaa3e6 | |||
| 1dbedf0f52 | |||
| 184b95969c | |||
| ddab62a883 | |||
| 87b094220d | |||
| b8334efa5a |
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 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
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,30 +0,0 @@
|
|||||||
## 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 -->
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -8,14 +8,6 @@ site/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|
||||||
# opencode skills (agent-only)
|
|
||||||
.opencode/
|
|
||||||
AGENTS.md
|
|
||||||
.github/
|
|
||||||
|
|
||||||
# Scripts (except hooks)
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -36,13 +28,9 @@ htmlcov/
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.example
|
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
# uv cache
|
# uv cache
|
||||||
.uv/
|
.uv/
|
||||||
|
blog.db
|
||||||
# Scripts cache
|
|
||||||
scripts/__pycache__/
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: comment
|
|
||||||
image: mcs94/gitea-comment
|
|
||||||
settings:
|
|
||||||
gitea_address: https://git.pyaqa.ru
|
|
||||||
gitea_token:
|
|
||||||
from_secret: gitea_token
|
|
||||||
comment: >
|
|
||||||
✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`.
|
|
||||||
|
|
||||||
📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`:
|
|
||||||
|
|
||||||
`${CI_COMMIT_MESSAGE}`
|
|
||||||
|
|
||||||
🌐 ${CI_BUILD_LINK}
|
|
||||||
|
|
||||||
depends_on:
|
|
||||||
- lint
|
|
||||||
- type
|
|
||||||
- test
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: [push, pull_request]
|
|
||||||
branch: dev
|
|
||||||
|
|
||||||
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 .
|
|
||||||
|
|
||||||
147
.woodpecker/pipeline.yml
Normal file
147
.woodpecker/pipeline.yml
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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"
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: [push, pull_request]
|
|
||||||
branch: dev
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test
|
|
||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: [push, pull_request]
|
|
||||||
branch: dev
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: type
|
|
||||||
image: python:3.11
|
|
||||||
commands:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --no-dev --only-group types
|
|
||||||
- uv run mypy .
|
|
||||||
545
AGENTS.md
Normal file
545
AGENTS.md
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
# 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>`
|
||||||
149
alembic.ini
Normal file
149
alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 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
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
64
alembic/env.py
Normal file
64
alembic/env.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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()
|
||||||
30
alembic/script.py.mako
Normal file
30
alembic/script.py.mako
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""${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"}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""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")
|
||||||
@@ -1 +1,5 @@
|
|||||||
"""Application package."""
|
"""Blog API Application.
|
||||||
|
|
||||||
|
This package provides a complete blog API implementation following
|
||||||
|
Domain-Driven Design principles with FastAPI and SQLAlchemy.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""API module - HTTP routes and endpoints."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API version 1 endpoints."""
|
|
||||||
47
app/application/__init__.py
Normal file
47
app/application/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
16
app/application/dtos/__init__.py
Normal file
16
app/application/dtos/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
55
app/application/dtos/comment.py
Normal file
55
app/application/dtos/comment.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""DTOs for comment use cases.
|
||||||
|
|
||||||
|
This module defines Data Transfer Objects used for communication between
|
||||||
|
application layer comment use cases and presentation layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CreateCommentDTO:
|
||||||
|
"""DTO for creating a comment.
|
||||||
|
|
||||||
|
Carries comment creation data from API to use case.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
post_id: UUID of the post to comment on.
|
||||||
|
author_id: Identifier of the comment author.
|
||||||
|
content: Comment content string (Markdown supported).
|
||||||
|
parent_id: Optional UUID of parent comment for replies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
post_id: UUID
|
||||||
|
author_id: str
|
||||||
|
content: str
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommentResponseDTO:
|
||||||
|
"""DTO for comment response.
|
||||||
|
|
||||||
|
Carries complete comment data for API responses.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique comment identifier.
|
||||||
|
post_id: UUID of the parent post.
|
||||||
|
author_id: Comment author identifier.
|
||||||
|
content: Comment content string.
|
||||||
|
parent_id: Optional UUID of parent comment.
|
||||||
|
like_count: Number of likes on this comment.
|
||||||
|
created_at: Creation timestamp.
|
||||||
|
updated_at: Last update timestamp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
post_id: UUID
|
||||||
|
author_id: str
|
||||||
|
content: str
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
like_count: int = 0
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
104
app/application/dtos/post.py
Normal file
104
app/application/dtos/post.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""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
|
||||||
9
app/application/interfaces/__init__.py
Normal file
9
app/application/interfaces/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""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"]
|
||||||
38
app/application/interfaces/transaction_manager.py
Normal file
38
app/application/interfaces/transaction_manager.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
31
app/application/use_cases/__init__.py
Normal file
31
app/application/use_cases/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
100
app/application/use_cases/create_comment.py
Normal file
100
app/application/use_cases/create_comment.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Create comment use case.
|
||||||
|
|
||||||
|
This module implements the use case for creating comments on blog posts.
|
||||||
|
Supports both top-level comments and nested replies via parent_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.application.dtos.comment import CommentResponseDTO
|
||||||
|
from app.application.interfaces import TransactionManager
|
||||||
|
from app.domain.entities.comment import Comment
|
||||||
|
from app.domain.exceptions import NotFoundException
|
||||||
|
from app.domain.repositories import CommentRepository, PostRepository
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCommentUseCase:
|
||||||
|
"""Use case for creating a comment on a blog post.
|
||||||
|
|
||||||
|
Handles top-level comments and replies to existing comments.
|
||||||
|
Validates that the target post exists before creating.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_post_repo: Repository for post data access.
|
||||||
|
_comment_repo: Repository for comment data access.
|
||||||
|
_tx_manager: Transaction manager for commit control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
post_repo: PostRepository,
|
||||||
|
comment_repo: CommentRepository,
|
||||||
|
tx_manager: TransactionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize use case with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_repo: Repository for post operations.
|
||||||
|
comment_repo: Repository for comment operations.
|
||||||
|
tx_manager: Transaction manager instance.
|
||||||
|
"""
|
||||||
|
self._post_repo = post_repo
|
||||||
|
self._comment_repo = comment_repo
|
||||||
|
self._tx_manager = tx_manager
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
post_id: UUID,
|
||||||
|
author_id: str,
|
||||||
|
content: str,
|
||||||
|
parent_id: UUID | None = None,
|
||||||
|
) -> CommentResponseDTO:
|
||||||
|
"""Execute the use case to create a comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post to comment on.
|
||||||
|
author_id: Identifier of the comment author.
|
||||||
|
content: Comment content (Markdown supported).
|
||||||
|
parent_id: Optional UUID of parent comment for replies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentResponseDTO with created comment data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundException: If the target post does not exist.
|
||||||
|
"""
|
||||||
|
post = await self._post_repo.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise NotFoundException(f"Post with id '{post_id}' not found")
|
||||||
|
|
||||||
|
comment = Comment.create(
|
||||||
|
post_id=post_id,
|
||||||
|
author_id=author_id,
|
||||||
|
content_str=content,
|
||||||
|
parent_id=parent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._comment_repo.add(comment)
|
||||||
|
await self._tx_manager.commit()
|
||||||
|
|
||||||
|
return self._map_to_dto(comment)
|
||||||
|
|
||||||
|
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||||
|
"""Map domain entity to response DTO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment: Domain Comment entity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentResponseDTO with all comment attributes.
|
||||||
|
"""
|
||||||
|
return CommentResponseDTO(
|
||||||
|
id=comment.id,
|
||||||
|
post_id=comment.post_id,
|
||||||
|
author_id=comment.author_id,
|
||||||
|
content=comment.content.value,
|
||||||
|
parent_id=comment.parent_id,
|
||||||
|
like_count=comment.like_count,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
updated_at=comment.updated_at,
|
||||||
|
)
|
||||||
98
app/application/use_cases/create_post.py
Normal file
98
app/application/use_cases/create_post.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
60
app/application/use_cases/delete_comment.py
Normal file
60
app/application/use_cases/delete_comment.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Delete comment use case.
|
||||||
|
|
||||||
|
This module implements the use case for deleting comments.
|
||||||
|
Users can delete their own comments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.application.interfaces import TransactionManager
|
||||||
|
from app.domain.exceptions import ForbiddenException, NotFoundException
|
||||||
|
from app.domain.repositories import CommentRepository
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteCommentUseCase:
|
||||||
|
"""Use case for deleting a comment.
|
||||||
|
|
||||||
|
Allows users to delete their own comments.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_comment_repo: Repository for comment data access.
|
||||||
|
_tx_manager: Transaction manager for commit control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
comment_repo: CommentRepository,
|
||||||
|
tx_manager: TransactionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize use case with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_repo: Repository for comment operations.
|
||||||
|
tx_manager: Transaction manager instance.
|
||||||
|
"""
|
||||||
|
self._comment_repo = comment_repo
|
||||||
|
self._tx_manager = tx_manager
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
comment_id: UUID,
|
||||||
|
user_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment to delete.
|
||||||
|
user_id: Identifier of the user requesting deletion.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundException: If the comment does not exist.
|
||||||
|
"""
|
||||||
|
comment = await self._comment_repo.get_by_id(comment_id)
|
||||||
|
if not comment:
|
||||||
|
raise NotFoundException(f"Comment with id '{comment_id}' not found")
|
||||||
|
|
||||||
|
if comment.author_id != user_id:
|
||||||
|
raise ForbiddenException("You are not allowed to delete this comment")
|
||||||
|
|
||||||
|
await self._comment_repo.delete(comment_id)
|
||||||
|
await self._tx_manager.commit()
|
||||||
69
app/application/use_cases/delete_post.py
Normal file
69
app/application/use_cases/delete_post.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""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()
|
||||||
100
app/application/use_cases/get_post.py
Normal file
100
app/application/use_cases/get_post.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
63
app/application/use_cases/list_comments.py
Normal file
63
app/application/use_cases/list_comments.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""List comments use case.
|
||||||
|
|
||||||
|
This module implements the use case for listing comments on a blog post.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.application.dtos.comment import CommentResponseDTO
|
||||||
|
from app.domain.entities.comment import Comment
|
||||||
|
from app.domain.repositories import CommentRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ListCommentsUseCase:
|
||||||
|
"""Use case for listing comments on a blog post.
|
||||||
|
|
||||||
|
Retrieves all comments for a given post ordered by creation time.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_comment_repo: Repository for comment data access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
comment_repo: CommentRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize use case with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_repo: Repository for comment operations.
|
||||||
|
"""
|
||||||
|
self._comment_repo = comment_repo
|
||||||
|
|
||||||
|
async def execute(self, post_id: UUID) -> list[CommentResponseDTO]:
|
||||||
|
"""List all comments for a post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CommentResponseDTO for the post.
|
||||||
|
"""
|
||||||
|
comments = await self._comment_repo.get_by_post(post_id)
|
||||||
|
return [self._map_to_dto(c) for c in comments]
|
||||||
|
|
||||||
|
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||||
|
"""Map domain entity to response DTO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment: Domain Comment entity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentResponseDTO with all comment attributes.
|
||||||
|
"""
|
||||||
|
return CommentResponseDTO(
|
||||||
|
id=comment.id,
|
||||||
|
post_id=comment.post_id,
|
||||||
|
author_id=comment.author_id,
|
||||||
|
content=comment.content.value,
|
||||||
|
parent_id=comment.parent_id,
|
||||||
|
like_count=comment.like_count,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
updated_at=comment.updated_at,
|
||||||
|
)
|
||||||
145
app/application/use_cases/list_posts.py
Normal file
145
app/application/use_cases/list_posts.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
132
app/application/use_cases/publish_post.py
Normal file
132
app/application/use_cases/publish_post.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
96
app/application/use_cases/toggle_comment_like.py
Normal file
96
app/application/use_cases/toggle_comment_like.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Toggle comment like use case.
|
||||||
|
|
||||||
|
This module implements the use case for toggling likes on comments.
|
||||||
|
If the user already liked the comment, the like is removed (unlike).
|
||||||
|
If not, a new like is added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.application.dtos.comment import CommentResponseDTO
|
||||||
|
from app.application.interfaces import TransactionManager
|
||||||
|
from app.domain.entities.comment import Comment
|
||||||
|
from app.domain.entities.comment_like import CommentLike
|
||||||
|
from app.domain.exceptions import NotFoundException
|
||||||
|
from app.domain.repositories import CommentRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleCommentLikeUseCase:
|
||||||
|
"""Use case for toggling a like on a comment.
|
||||||
|
|
||||||
|
Handles like/unlike toggle logic. If the user has already liked
|
||||||
|
the comment, the like is removed. Otherwise, a new like is created.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_comment_repo: Repository for comment and like data access.
|
||||||
|
_tx_manager: Transaction manager for commit control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
comment_repo: CommentRepository,
|
||||||
|
tx_manager: TransactionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize use case with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_repo: Repository for comment and like operations.
|
||||||
|
tx_manager: Transaction manager instance.
|
||||||
|
"""
|
||||||
|
self._comment_repo = comment_repo
|
||||||
|
self._tx_manager = tx_manager
|
||||||
|
|
||||||
|
async def execute(self, comment_id: UUID, liked_by: str) -> CommentResponseDTO:
|
||||||
|
"""Toggle like on a comment.
|
||||||
|
|
||||||
|
If the user already liked the comment, remove the like.
|
||||||
|
Otherwise, add a new like.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment to toggle like on.
|
||||||
|
liked_by: User ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentResponseDTO with updated like_count.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundException: If comment with given ID does not exist.
|
||||||
|
"""
|
||||||
|
comment = await self._comment_repo.get_by_id(comment_id)
|
||||||
|
if not comment:
|
||||||
|
raise NotFoundException(f"Comment with id '{comment_id}' not found")
|
||||||
|
|
||||||
|
existing_like = await self._comment_repo.get_like(comment_id, liked_by)
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
await self._comment_repo.remove_like(comment_id, liked_by)
|
||||||
|
comment.like_count = max(0, comment.like_count - 1)
|
||||||
|
else:
|
||||||
|
new_like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||||
|
await self._comment_repo.add_like(new_like)
|
||||||
|
comment.like_count += 1
|
||||||
|
|
||||||
|
await self._comment_repo.update(comment)
|
||||||
|
await self._tx_manager.commit()
|
||||||
|
|
||||||
|
return self._map_to_dto(comment)
|
||||||
|
|
||||||
|
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||||
|
"""Map domain entity to response DTO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment: Domain Comment entity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentResponseDTO with all comment attributes including like_count.
|
||||||
|
"""
|
||||||
|
return CommentResponseDTO(
|
||||||
|
id=comment.id,
|
||||||
|
post_id=comment.post_id,
|
||||||
|
author_id=comment.author_id,
|
||||||
|
content=comment.content.value,
|
||||||
|
parent_id=comment.parent_id,
|
||||||
|
like_count=comment.like_count,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
updated_at=comment.updated_at,
|
||||||
|
)
|
||||||
102
app/application/use_cases/toggle_like.py
Normal file
102
app/application/use_cases/toggle_like.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Toggle post like use case.
|
||||||
|
|
||||||
|
This module implements the use case for toggling likes on blog posts.
|
||||||
|
If the user already liked the post, the like is removed (unlike).
|
||||||
|
If not, a new like is added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.application.dtos.post import PostResponseDTO
|
||||||
|
from app.application.interfaces import TransactionManager
|
||||||
|
from app.domain.entities import Post
|
||||||
|
from app.domain.entities.like import PostLike
|
||||||
|
from app.domain.exceptions import NotFoundException
|
||||||
|
from app.domain.repositories import PostRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TogglePostLikeUseCase:
|
||||||
|
"""Use case for toggling a like on a blog post.
|
||||||
|
|
||||||
|
Handles like/unlike toggle logic. If the user or device has already
|
||||||
|
liked the post, the like is removed. Otherwise, a new like is created.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_post_repo: Repository for post and like data access.
|
||||||
|
_tx_manager: Transaction manager for commit control.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> use_case = TogglePostLikeUseCase(post_repo, tx_manager)
|
||||||
|
>>> result = await use_case.execute("my-post-slug", "user-123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
post_repo: PostRepository,
|
||||||
|
tx_manager: TransactionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize use case with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_repo: Repository for post and like operations.
|
||||||
|
tx_manager: Transaction manager instance.
|
||||||
|
"""
|
||||||
|
self._post_repo = post_repo
|
||||||
|
self._tx_manager = tx_manager
|
||||||
|
|
||||||
|
async def execute(self, post_id: UUID, liked_by: str) -> PostResponseDTO:
|
||||||
|
"""Toggle like on a post.
|
||||||
|
|
||||||
|
If the user/device already liked the post, remove the like.
|
||||||
|
Otherwise, add a new like.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post to toggle like on.
|
||||||
|
liked_by: User ID or device identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PostResponseDTO with updated like_count.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundException: If post with given ID does not exist.
|
||||||
|
"""
|
||||||
|
post = await self._post_repo.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise NotFoundException(f"Post with id '{post_id}' not found")
|
||||||
|
|
||||||
|
existing_like = await self._post_repo.get_like(post_id, liked_by)
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
await self._post_repo.remove_like(post_id, liked_by)
|
||||||
|
post.like_count = max(0, post.like_count - 1)
|
||||||
|
else:
|
||||||
|
new_like = PostLike(post_id=post_id, liked_by=liked_by)
|
||||||
|
await self._post_repo.add_like(new_like)
|
||||||
|
post.like_count += 1
|
||||||
|
|
||||||
|
await self._post_repo.update(post)
|
||||||
|
await self._tx_manager.commit()
|
||||||
|
|
||||||
|
return self._map_to_dto(post)
|
||||||
|
|
||||||
|
def _map_to_dto(self, post: Post) -> PostResponseDTO:
|
||||||
|
"""Map domain entity to response DTO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Domain post entity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PostResponseDTO with all post attributes including like_count.
|
||||||
|
"""
|
||||||
|
return PostResponseDTO(
|
||||||
|
id=post.id,
|
||||||
|
title=post.title.value,
|
||||||
|
content=post.content.value,
|
||||||
|
slug=post.slug.value,
|
||||||
|
author_id=post.author_id,
|
||||||
|
published=post.published,
|
||||||
|
like_count=post.like_count,
|
||||||
|
tags=post.tags.copy(),
|
||||||
|
created_at=post.created_at,
|
||||||
|
updated_at=post.updated_at,
|
||||||
|
)
|
||||||
115
app/application/use_cases/update_post.py
Normal file
115
app/application/use_cases/update_post.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""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 +0,0 @@
|
|||||||
"""Common utilities and shared components."""
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
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[str, str] | 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.now(timezone.utc).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.now(timezone.utc).isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI) -> None:
|
|
||||||
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 +0,0 @@
|
|||||||
"""Core module - shared functionality and configuration."""
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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)
|
|
||||||
38
app/domain/__init__.py
Normal file
38
app/domain/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
13
app/domain/entities/__init__.py
Normal file
13
app/domain/entities/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""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"]
|
||||||
75
app/domain/entities/base.py
Normal file
75
app/domain/entities/base.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
79
app/domain/entities/comment.py
Normal file
79
app/domain/entities/comment.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Domain entity for Comment.
|
||||||
|
|
||||||
|
This module defines the Comment entity that represents a comment on a blog
|
||||||
|
post. Comments can be top-level (parent_id=None) or replies to other
|
||||||
|
comments (parent_id set).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.domain.entities.base import BaseEntity
|
||||||
|
from app.domain.value_objects.comment_content import CommentContent
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class Comment(BaseEntity):
|
||||||
|
"""Comment domain entity.
|
||||||
|
|
||||||
|
Represents a comment on a blog post with optional parent reference
|
||||||
|
for nested replies. Supports Markdown content and like tracking.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
post_id: UUID of the post this comment belongs to.
|
||||||
|
author_id: Identifier of the comment author.
|
||||||
|
content: CommentContent value object with Markdown text.
|
||||||
|
parent_id: UUID of parent comment for replies, or None.
|
||||||
|
like_count: Number of likes on this comment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
post_id: UUID
|
||||||
|
author_id: str
|
||||||
|
content: CommentContent
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
like_count: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert entity to dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary representation with all comment attributes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"post_id": str(self.post_id),
|
||||||
|
"author_id": self.author_id,
|
||||||
|
"content": self.content.value,
|
||||||
|
"parent_id": str(self.parent_id) if self.parent_id else None,
|
||||||
|
"like_count": self.like_count,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
post_id: UUID,
|
||||||
|
author_id: str,
|
||||||
|
content_str: str,
|
||||||
|
parent_id: UUID | None = None,
|
||||||
|
) -> "Comment":
|
||||||
|
"""Factory method to create a new comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post to comment on.
|
||||||
|
author_id: Identifier of the comment author.
|
||||||
|
content_str: Comment content string (Markdown supported).
|
||||||
|
parent_id: Optional UUID of parent comment for replies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New Comment instance with validated content.
|
||||||
|
"""
|
||||||
|
content = CommentContent(content_str)
|
||||||
|
return cls(
|
||||||
|
post_id=post_id,
|
||||||
|
author_id=author_id,
|
||||||
|
content=content,
|
||||||
|
parent_id=parent_id,
|
||||||
|
)
|
||||||
40
app/domain/entities/comment_like.py
Normal file
40
app/domain/entities/comment_like.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Domain entity for CommentLike.
|
||||||
|
|
||||||
|
This module defines the CommentLike entity that tracks which users
|
||||||
|
have liked which comments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.domain.entities.base import BaseEntity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class CommentLike(BaseEntity):
|
||||||
|
"""Comment like domain entity.
|
||||||
|
|
||||||
|
Tracks a like on a comment by a user. Each like is uniquely
|
||||||
|
identified by its entity ID.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
comment_id: UUID of the liked comment.
|
||||||
|
liked_by: Identifier of the user who liked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
comment_id: UUID
|
||||||
|
liked_by: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert entity to dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with all CommentLike attributes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"comment_id": str(self.comment_id),
|
||||||
|
"liked_by": self.liked_by,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
}
|
||||||
40
app/domain/entities/like.py
Normal file
40
app/domain/entities/like.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Domain entity for PostLike.
|
||||||
|
|
||||||
|
This module defines the PostLike entity that tracks which users
|
||||||
|
or devices have liked which posts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.domain.entities.base import BaseEntity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class PostLike(BaseEntity):
|
||||||
|
"""Post like domain entity.
|
||||||
|
|
||||||
|
Tracks a like on a blog post by a user or device.
|
||||||
|
Each like is uniquely identified by its entity ID.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
post_id: UUID of the liked post.
|
||||||
|
liked_by: Identifier of the user or device that liked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
post_id: UUID
|
||||||
|
liked_by: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert entity to dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with all PostLike attributes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"post_id": str(self.post_id),
|
||||||
|
"liked_by": self.liked_by,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
}
|
||||||
152
app/domain/entities/post.py
Normal file
152
app/domain/entities/post.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""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 [],
|
||||||
|
)
|
||||||
78
app/domain/exceptions.py
Normal file
78
app/domain/exceptions.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""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")
|
||||||
|
"""
|
||||||
11
app/domain/repositories/__init__.py
Normal file
11
app/domain/repositories/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""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"]
|
||||||
89
app/domain/repositories/base.py
Normal file
89
app/domain/repositories/base.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
80
app/domain/repositories/comment.py
Normal file
80
app/domain/repositories/comment.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Comment repository interface.
|
||||||
|
|
||||||
|
This module defines the repository interface for Comment entities
|
||||||
|
including nested comment queries and like management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.domain.entities.comment import Comment
|
||||||
|
from app.domain.entities.comment_like import CommentLike
|
||||||
|
from app.domain.repositories.base import Repository
|
||||||
|
|
||||||
|
|
||||||
|
class CommentRepository(Repository[Comment]):
|
||||||
|
"""Repository interface for Comments.
|
||||||
|
|
||||||
|
Extends the generic repository with comment-specific operations
|
||||||
|
including post-based listing and like management.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> comments = await repo.get_by_post(post_id)
|
||||||
|
>>> like = await repo.get_like(comment_id, "user-123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_post(self, post_id: UUID) -> list[Comment]:
|
||||||
|
"""Get all comments for a post, ordered by creation time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Comment entities for the post.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
|
||||||
|
"""Get a like by comment and user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment.
|
||||||
|
liked_by: User ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentLike if found, None otherwise.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_like(self, like: CommentLike) -> None:
|
||||||
|
"""Add a new like to a comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
like: CommentLike entity to add.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_by_post(self, post_id: UUID) -> int:
|
||||||
|
"""Get comment count for a post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of comments on the post.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
|
||||||
|
"""Remove a like from a comment by user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment.
|
||||||
|
liked_by: User ID.
|
||||||
|
"""
|
||||||
|
...
|
||||||
156
app/domain/repositories/post.py
Normal file
156
app/domain/repositories/post.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
165
app/domain/roles.py
Normal file
165
app/domain/roles.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""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
|
||||||
13
app/domain/value_objects/__init__.py
Normal file
13
app/domain/value_objects/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""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"]
|
||||||
91
app/domain/value_objects/base.py
Normal file
91
app/domain/value_objects/base.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""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
|
||||||
47
app/domain/value_objects/comment_content.py
Normal file
47
app/domain/value_objects/comment_content.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Value object for comment content.
|
||||||
|
|
||||||
|
This module defines the CommentContent value object that validates
|
||||||
|
and encapsulates comment text content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.domain.value_objects.base import ValueObject
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CommentContent(ValueObject[str]):
|
||||||
|
"""Comment content value object.
|
||||||
|
|
||||||
|
Wraps and validates comment content ensuring it meets length
|
||||||
|
requirements and is not empty.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
value: The comment content string.
|
||||||
|
MAX_LENGTH: Maximum allowed content length (5000 characters).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If content is empty or too long.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> content = CommentContent("This is a **bold** comment.")
|
||||||
|
>>> content.value
|
||||||
|
'This is a **bold** comment.'
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_LENGTH: int = 5000
|
||||||
|
|
||||||
|
def _validate(self) -> None:
|
||||||
|
"""Validate comment content.
|
||||||
|
|
||||||
|
Checks that content is a non-empty string within length bounds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If content fails validation criteria.
|
||||||
|
"""
|
||||||
|
if not isinstance(self.value, str):
|
||||||
|
raise ValueError("Comment content must be a string")
|
||||||
|
if not self.value.strip():
|
||||||
|
raise ValueError("Comment content cannot be empty")
|
||||||
|
if len(self.value) > self.MAX_LENGTH:
|
||||||
|
raise ValueError(f"Comment content must be at most {self.MAX_LENGTH} characters")
|
||||||
50
app/domain/value_objects/content.py
Normal file
50
app/domain/value_objects/content.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""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")
|
||||||
73
app/domain/value_objects/slug.py
Normal file
73
app/domain/value_objects/slug.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""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)
|
||||||
50
app/domain/value_objects/title.py
Normal file
50
app/domain/value_objects/title.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""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")
|
||||||
34
app/infrastructure/__init__.py
Normal file
34
app/infrastructure/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
11
app/infrastructure/auth/__init__.py
Normal file
11
app/infrastructure/auth/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""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"]
|
||||||
185
app/infrastructure/auth/client.py
Normal file
185
app/infrastructure/auth/client.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""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 [],
|
||||||
|
)
|
||||||
75
app/infrastructure/auth/mock_client.py
Normal file
75
app/infrastructure/auth/mock_client.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""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)
|
||||||
74
app/infrastructure/auth/models.py
Normal file
74
app/infrastructure/auth/models.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""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
|
||||||
25
app/infrastructure/config/__init__.py
Normal file
25
app/infrastructure/config/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
285
app/infrastructure/config/settings.py
Normal file
285
app/infrastructure/config/settings.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"""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()
|
||||||
26
app/infrastructure/database/__init__.py
Normal file
26
app/infrastructure/database/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
100
app/infrastructure/database/connection.py
Normal file
100
app/infrastructure/database/connection.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""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()
|
||||||
178
app/infrastructure/database/models.py
Normal file
178
app/infrastructure/database/models.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
11
app/infrastructure/di/__init__.py
Normal file
11
app/infrastructure/di/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
20
app/infrastructure/di/container.py
Normal file
20
app/infrastructure/di/container.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""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(),
|
||||||
|
)
|
||||||
380
app/infrastructure/di/providers.py
Normal file
380
app/infrastructure/di/providers.py
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
"""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)
|
||||||
48
app/infrastructure/di/transaction_manager.py
Normal file
48
app/infrastructure/di/transaction_manager.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""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()
|
||||||
9
app/infrastructure/i18n/__init__.py
Normal file
9
app/infrastructure/i18n/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""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", "_"]
|
||||||
377
app/infrastructure/i18n/translations.py
Normal file
377
app/infrastructure/i18n/translations.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""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",
|
||||||
|
},
|
||||||
|
}
|
||||||
78
app/infrastructure/i18n/translator.py
Normal file
78
app/infrastructure/i18n/translator.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""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)
|
||||||
19
app/infrastructure/middleware/__init__.py
Normal file
19
app/infrastructure/middleware/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
132
app/infrastructure/middleware/error_handler.py
Normal file
132
app/infrastructure/middleware/error_handler.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""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)
|
||||||
9
app/infrastructure/repositories/__init__.py
Normal file
9
app/infrastructure/repositories/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Repository implementations.
|
||||||
|
|
||||||
|
This module re-exports concrete repository implementations
|
||||||
|
for data access using SQLAlchemy ORM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
|
||||||
|
|
||||||
|
__all__ = ["SQLAlchemyPostRepository"]
|
||||||
240
app/infrastructure/repositories/comment.py
Normal file
240
app/infrastructure/repositories/comment.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""SQLAlchemy implementation of CommentRepository.
|
||||||
|
|
||||||
|
This module provides the concrete implementation of CommentRepository
|
||||||
|
using SQLAlchemy ORM for data persistence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.entities.comment import Comment
|
||||||
|
from app.domain.entities.comment_like import CommentLike
|
||||||
|
from app.domain.repositories import CommentRepository
|
||||||
|
from app.domain.value_objects.comment_content import CommentContent
|
||||||
|
from app.infrastructure.database.models import CommentLikeORM, CommentORM
|
||||||
|
|
||||||
|
|
||||||
|
class SQLAlchemyCommentRepository(CommentRepository):
|
||||||
|
"""SQLAlchemy implementation of Comment repository.
|
||||||
|
|
||||||
|
Provides data access methods for Comment entities using SQLAlchemy ORM.
|
||||||
|
Handles conversion between domain entities and ORM models.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_session: SQLAlchemy async session for database operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
"""Initialize repository with session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy async session instance.
|
||||||
|
"""
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def _to_domain(self, orm: CommentORM) -> Comment:
|
||||||
|
"""Convert ORM model to domain entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
orm: SQLAlchemy ORM model instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Domain Comment entity with validated value objects.
|
||||||
|
"""
|
||||||
|
return Comment(
|
||||||
|
id=UUID(orm.id),
|
||||||
|
post_id=UUID(orm.post_id),
|
||||||
|
author_id=orm.author_id,
|
||||||
|
content=CommentContent(orm.content),
|
||||||
|
parent_id=UUID(orm.parent_id) if orm.parent_id else None,
|
||||||
|
like_count=orm.like_count,
|
||||||
|
created_at=orm.created_at,
|
||||||
|
updated_at=orm.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _to_orm(self, comment: Comment) -> CommentORM:
|
||||||
|
"""Convert domain entity to ORM model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment: Domain Comment entity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SQLAlchemy ORM model instance.
|
||||||
|
"""
|
||||||
|
return CommentORM(
|
||||||
|
id=str(comment.id),
|
||||||
|
post_id=str(comment.post_id),
|
||||||
|
author_id=comment.author_id,
|
||||||
|
content=comment.content.value,
|
||||||
|
parent_id=str(comment.parent_id) if comment.parent_id else None,
|
||||||
|
like_count=comment.like_count,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
updated_at=comment.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_by_id(self, entity_id: UUID) -> Comment | None:
|
||||||
|
"""Get comment by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Unique identifier of the comment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Comment entity if found, None otherwise.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentORM).where(CommentORM.id == str(entity_id))
|
||||||
|
)
|
||||||
|
orm = result.scalar_one_or_none()
|
||||||
|
return self._to_domain(orm) if orm else None
|
||||||
|
|
||||||
|
async def get_all(self) -> list[Comment]:
|
||||||
|
"""Get all comments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all Comment entities.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(select(CommentORM))
|
||||||
|
orms = result.scalars().all()
|
||||||
|
return [self._to_domain(orm) for orm in orms]
|
||||||
|
|
||||||
|
async def add(self, entity: Comment) -> None:
|
||||||
|
"""Add new comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: Comment entity to add.
|
||||||
|
"""
|
||||||
|
orm = self._to_orm(entity)
|
||||||
|
self._session.add(orm)
|
||||||
|
|
||||||
|
async def update(self, entity: Comment) -> None:
|
||||||
|
"""Update existing comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: Comment entity with updated data.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentORM).where(CommentORM.id == str(entity.id))
|
||||||
|
)
|
||||||
|
orm = result.scalar_one()
|
||||||
|
|
||||||
|
orm.content = entity.content.value
|
||||||
|
orm.like_count = entity.like_count
|
||||||
|
orm.updated_at = entity.updated_at
|
||||||
|
|
||||||
|
async def delete(self, entity_id: UUID) -> None:
|
||||||
|
"""Delete comment by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Unique identifier of the comment to delete.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentORM).where(CommentORM.id == str(entity_id))
|
||||||
|
)
|
||||||
|
orm = result.scalar_one_or_none()
|
||||||
|
if orm:
|
||||||
|
await self._session.delete(orm)
|
||||||
|
|
||||||
|
async def exists(self, entity_id: UUID) -> bool:
|
||||||
|
"""Check if comment exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Unique identifier of the comment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if comment exists, False otherwise.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentORM).where(CommentORM.id == str(entity_id))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
async def get_by_post(self, post_id: UUID) -> list[Comment]:
|
||||||
|
"""Get all comments for a post, ordered by creation time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Comment entities for the post.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentORM)
|
||||||
|
.where(CommentORM.post_id == str(post_id))
|
||||||
|
.order_by(CommentORM.created_at.asc())
|
||||||
|
)
|
||||||
|
orms = result.scalars().all()
|
||||||
|
return [self._to_domain(orm) for orm in orms]
|
||||||
|
|
||||||
|
async def count_by_post(self, post_id: UUID) -> int:
|
||||||
|
"""Get comment count for a post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of comments on the post.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(func.count()).select_from(CommentORM).where(CommentORM.post_id == str(post_id))
|
||||||
|
)
|
||||||
|
count: int = result.scalar() or 0
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
|
||||||
|
"""Get a like by comment and user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment.
|
||||||
|
liked_by: User ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentLike if found, None otherwise.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentLikeORM).where(
|
||||||
|
CommentLikeORM.comment_id == str(comment_id),
|
||||||
|
CommentLikeORM.liked_by == liked_by,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
orm = result.scalar_one_or_none()
|
||||||
|
if not orm:
|
||||||
|
return None
|
||||||
|
return CommentLike(
|
||||||
|
id=UUID(orm.id),
|
||||||
|
comment_id=UUID(orm.comment_id),
|
||||||
|
liked_by=orm.liked_by,
|
||||||
|
created_at=orm.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_like(self, like: CommentLike) -> None:
|
||||||
|
"""Add a new like to a comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
like: CommentLike entity to add.
|
||||||
|
"""
|
||||||
|
orm = CommentLikeORM(
|
||||||
|
id=str(like.id),
|
||||||
|
comment_id=str(like.comment_id),
|
||||||
|
liked_by=like.liked_by,
|
||||||
|
created_at=like.created_at,
|
||||||
|
)
|
||||||
|
self._session.add(orm)
|
||||||
|
|
||||||
|
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
|
||||||
|
"""Remove a like from a comment by user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment.
|
||||||
|
liked_by: User ID.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(CommentLikeORM).where(
|
||||||
|
CommentLikeORM.comment_id == str(comment_id),
|
||||||
|
CommentLikeORM.liked_by == liked_by,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
orm = result.scalar_one_or_none()
|
||||||
|
if orm:
|
||||||
|
await self._session.delete(orm)
|
||||||
347
app/infrastructure/repositories/post.py
Normal file
347
app/infrastructure/repositories/post.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"""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)
|
||||||
140
app/main.py
140
app/main.py
@@ -1,22 +1,152 @@
|
|||||||
|
"""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
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
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
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
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()
|
||||||
yield
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
def app_factory() -> FastAPI:
|
def app_factory() -> FastAPI:
|
||||||
app = FastAPI(lifespan=lifespan)
|
"""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,
|
||||||
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
|
"""Run the application.
|
||||||
|
|
||||||
|
Starts uvicorn server with application factory.
|
||||||
|
"""
|
||||||
|
uvicorn.run(
|
||||||
|
app_factory,
|
||||||
|
factory=True,
|
||||||
|
host=settings.app.host,
|
||||||
|
port=settings.app.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Feature modules - business logic organized by domain."""
|
|
||||||
21
app/presentation/__init__.py
Normal file
21
app/presentation/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
12
app/presentation/api/__init__.py
Normal file
12
app/presentation/api/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""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)
|
||||||
200
app/presentation/api/deps.py
Normal file
200
app/presentation/api/deps.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""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])
|
||||||
14
app/presentation/api/v1/__init__.py
Normal file
14
app/presentation/api/v1/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""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)
|
||||||
131
app/presentation/api/v1/comments.py
Normal file
131
app/presentation/api/v1/comments.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Comments API routes.
|
||||||
|
|
||||||
|
This module defines FastAPI routes for comment operations including
|
||||||
|
CRUD and like/unlike toggle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.presentation.api.deps import (
|
||||||
|
CreateCommentDep,
|
||||||
|
CurrentRoleDep,
|
||||||
|
CurrentUserDep,
|
||||||
|
DeleteCommentDep,
|
||||||
|
ListCommentsDep,
|
||||||
|
ToggleCommentLikeDep,
|
||||||
|
)
|
||||||
|
from app.presentation.schemas import (
|
||||||
|
CommentCreateSchema,
|
||||||
|
CommentLikeResponseSchema,
|
||||||
|
CommentResponseSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["comments"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/posts/{post_id}/comments",
|
||||||
|
response_model=CommentResponseSchema,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a comment on a post",
|
||||||
|
)
|
||||||
|
async def create_comment(
|
||||||
|
post_id: UUID,
|
||||||
|
schema: CommentCreateSchema,
|
||||||
|
use_case: CreateCommentDep,
|
||||||
|
current_user_id: CurrentUserDep,
|
||||||
|
) -> CommentResponseSchema:
|
||||||
|
"""Create a comment on a blog post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post to comment on.
|
||||||
|
schema: Comment creation data.
|
||||||
|
use_case: CreateCommentUseCase dependency.
|
||||||
|
current_user_id: Authenticated user ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentResponseSchema with created comment data.
|
||||||
|
"""
|
||||||
|
result = await use_case.execute(
|
||||||
|
post_id=post_id,
|
||||||
|
author_id=current_user_id,
|
||||||
|
content=schema.content,
|
||||||
|
parent_id=schema.parent_id,
|
||||||
|
)
|
||||||
|
return CommentResponseSchema(**result.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/posts/{post_id}/comments",
|
||||||
|
response_model=list[CommentResponseSchema],
|
||||||
|
summary="List comments for a post",
|
||||||
|
)
|
||||||
|
async def list_comments(
|
||||||
|
post_id: UUID,
|
||||||
|
use_case: ListCommentsDep,
|
||||||
|
) -> list[CommentResponseSchema]:
|
||||||
|
"""Get all comments for a blog post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: UUID of the post.
|
||||||
|
use_case: ListCommentsUseCase dependency.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CommentResponseSchema for the post.
|
||||||
|
"""
|
||||||
|
results = await use_case.execute(post_id=post_id)
|
||||||
|
return [CommentResponseSchema(**r.__dict__) for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/comments/{comment_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a comment",
|
||||||
|
)
|
||||||
|
async def delete_comment(
|
||||||
|
comment_id: UUID,
|
||||||
|
use_case: DeleteCommentDep,
|
||||||
|
current_user_id: CurrentUserDep,
|
||||||
|
role: CurrentRoleDep,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a comment.
|
||||||
|
|
||||||
|
Users can delete their own comments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment to delete.
|
||||||
|
use_case: DeleteCommentUseCase dependency.
|
||||||
|
current_user_id: Authenticated user ID.
|
||||||
|
role: Current user role.
|
||||||
|
"""
|
||||||
|
await use_case.execute(comment_id=comment_id, user_id=current_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/comments/{comment_id}/like",
|
||||||
|
response_model=CommentLikeResponseSchema,
|
||||||
|
summary="Toggle like on a comment",
|
||||||
|
)
|
||||||
|
async def toggle_comment_like(
|
||||||
|
comment_id: UUID,
|
||||||
|
use_case: ToggleCommentLikeDep,
|
||||||
|
current_user_id: CurrentUserDep,
|
||||||
|
) -> CommentLikeResponseSchema:
|
||||||
|
"""Toggle like/unlike on a comment.
|
||||||
|
|
||||||
|
If the user already liked the comment, the like is removed (unlike).
|
||||||
|
Otherwise, a new like is added.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comment_id: UUID of the comment.
|
||||||
|
use_case: ToggleCommentLikeUseCase dependency.
|
||||||
|
current_user_id: Authenticated user ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommentLikeResponseSchema with updated like_count.
|
||||||
|
"""
|
||||||
|
result = await use_case.execute(comment_id, current_user_id)
|
||||||
|
return CommentLikeResponseSchema(id=result.id, like_count=result.like_count)
|
||||||
374
app/presentation/api/v1/posts.py
Normal file
374
app/presentation/api/v1/posts.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
"""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__)
|
||||||
33
app/presentation/schemas/__init__.py
Normal file
33
app/presentation/schemas/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
58
app/presentation/schemas/comment.py
Normal file
58
app/presentation/schemas/comment.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Pydantic schemas for comments.
|
||||||
|
|
||||||
|
This module defines Pydantic models for comment request/response
|
||||||
|
validation in the API layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreateSchema(BaseModel):
|
||||||
|
"""Schema for creating a comment.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
content: Comment text content (Markdown supported).
|
||||||
|
parent_id: Optional parent comment ID for replies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
content: str = Field(..., min_length=1, max_length=5000, description="Comment content")
|
||||||
|
parent_id: UUID | None = Field(default=None, description="Parent comment ID for replies")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentResponseSchema(BaseModel):
|
||||||
|
"""Schema for comment response.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique comment identifier.
|
||||||
|
post_id: UUID of the parent post.
|
||||||
|
author_id: Comment author identifier.
|
||||||
|
content: Comment content text.
|
||||||
|
parent_id: Optional parent comment ID.
|
||||||
|
like_count: Number of likes on this comment.
|
||||||
|
created_at: Creation timestamp.
|
||||||
|
updated_at: Last update timestamp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
post_id: UUID
|
||||||
|
author_id: str
|
||||||
|
content: str
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
like_count: int = 0
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentLikeResponseSchema(BaseModel):
|
||||||
|
"""Schema for comment like response.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Comment identifier.
|
||||||
|
like_count: Updated like count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
like_count: int
|
||||||
125
app/presentation/schemas/post.py
Normal file
125
app/presentation/schemas/post.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""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
|
||||||
184
app/presentation/templates/base.html
Normal file
184
app/presentation/templates/base.html
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<!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) }}">×</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>
|
||||||
37
app/presentation/templates/pages/about.html
Normal file
37
app/presentation/templates/pages/about.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% 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 %}
|
||||||
111
app/presentation/templates/pages/error.html
Normal file
111
app/presentation/templates/pages/error.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ error_code }} - {{ error_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ error_message }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="error-page" data-testid="error-page">
|
||||||
|
<div class="error-content" data-testid="error-content">
|
||||||
|
<div class="error-icon" data-testid="error-icon">
|
||||||
|
{% if error_code == 404 %}
|
||||||
|
🔍
|
||||||
|
{% elif error_code == 403 %}
|
||||||
|
🚫
|
||||||
|
{% elif error_code == 500 %}
|
||||||
|
⚠️
|
||||||
|
{% else %}
|
||||||
|
❌
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="error-code" data-testid="error-code">{{ error_code }}</h1>
|
||||||
|
<h2 class="error-title" data-testid="error-title">{{ error_title }}</h2>
|
||||||
|
<p class="error-message" data-testid="error-message">{{ error_message }}</p>
|
||||||
|
|
||||||
|
<div class="error-actions" data-testid="error-actions">
|
||||||
|
<a href="/" class="btn btn-primary" data-testid="btn-error-home">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M2 8L8 2L14 8M4 6V13H12V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Go Home
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if error_code == 403 %}
|
||||||
|
<a href="/auth/login" class="btn" data-testid="btn-error-login">
|
||||||
|
Sign In
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button onclick="window.history.back()" class="btn btn-ghost" data-testid="btn-error-back">
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-size: 6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.error-code {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
108
app/presentation/templates/pages/index.html
Normal file
108
app/presentation/templates/pages/index.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{% 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 %}
|
||||||
360
app/presentation/templates/pages/post_detail.html
Normal file
360
app/presentation/templates/pages/post_detail.html
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ post.title }} - Blog{% endblock %}
|
||||||
|
{% block meta_description %}{{ post.content[:160] }}{% endblock %}
|
||||||
|
{% block meta_keywords %}{{ post.tags|join(', ') }}{% endblock %}
|
||||||
|
{% block meta_author %}{{ post.author_id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block canonical_url %}{{ request.base_url }}web/posts/{{ post.slug }}{% endblock %}
|
||||||
|
|
||||||
|
{% block og_type %}article{% endblock %}
|
||||||
|
{% block og_url %}{{ request.base_url }}web/posts/{{ post.slug }}{% endblock %}
|
||||||
|
{% block og_title %}{{ post.title }}{% endblock %}
|
||||||
|
{% block og_description %}{{ post.content[:160] }}{% endblock %}
|
||||||
|
|
||||||
|
{% block twitter_title %}{{ post.title }}{% endblock %}
|
||||||
|
{% block twitter_description %}{{ post.content[:160] }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article class="post-detail" data-testid="post-detail">
|
||||||
|
<header class="post-detail-header" data-testid="post-detail-header">
|
||||||
|
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
|
||||||
|
|
||||||
|
<div class="post-detail-meta" data-testid="post-detail-meta">
|
||||||
|
<span class="post-card-meta-item" data-testid="post-detail-author">
|
||||||
|
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
|
||||||
|
<span data-testid="post-detail-author-name">{{ post.author_id }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="post-card-meta-item" data-testid="post-detail-date">
|
||||||
|
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||||
|
</span>
|
||||||
|
{% if post.published %}
|
||||||
|
<span class="badge badge-success" data-testid="post-detail-status">{{ _('post.status_published', current_locale) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="post-card-meta-item" data-testid="post-detail-like-count">
|
||||||
|
<button id="like-button" class="btn-like" data-testid="like-button"
|
||||||
|
data-post-slug="{{ post.slug }}"
|
||||||
|
data-liked="false">
|
||||||
|
👍 <span id="like-count">{{ post.like_count }}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="post-card-meta-item" data-testid="post-detail-comment-count">
|
||||||
|
💬 {{ post.comment_count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="post-detail-content markdown-body" data-testid="post-detail-content">
|
||||||
|
{{ post.content|markdown|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="post-detail-footer" data-testid="post-detail-footer">
|
||||||
|
<div class="post-detail-tags" data-testid="post-detail-tags">
|
||||||
|
{% for tag in post.tags %}
|
||||||
|
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider" data-testid="post-detail-divider"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center" data-testid="post-detail-actions">
|
||||||
|
<a href="/" class="btn" data-testid="btn-back-to-posts">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('post.back_to_posts', current_locale) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if can_edit or can_delete %}
|
||||||
|
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||||
|
{% if can_edit %}
|
||||||
|
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('post.edit', current_locale) }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_delete %}
|
||||||
|
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
|
||||||
|
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('{{ _('post.delete_confirm', current_locale) }}');">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('post.delete', current_locale) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="comments-section" data-testid="comments-section">
|
||||||
|
<div class="comments-header" data-testid="comments-header">
|
||||||
|
<h2 class="comments-title" data-testid="comments-title">
|
||||||
|
💬 {{ _('post.comments', current_locale) }}
|
||||||
|
<span class="comments-count" data-testid="comments-count">({{ post.comment_count }})</span>
|
||||||
|
</h2>
|
||||||
|
{% if user %}
|
||||||
|
<button id="btn-show-comment-form" class="btn btn-primary" data-testid="btn-show-comment-form">
|
||||||
|
{{ _('post.write_comment', current_locale) }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user %}
|
||||||
|
<div id="comment-form-wrapper" class="comment-form-wrapper" data-testid="comment-form-wrapper" style="display: none;">
|
||||||
|
<form id="comment-form" class="comment-form" data-testid="form-create-comment" data-post-slug="{{ post.slug }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea id="comment-content" class="form-textarea" data-testid="input-comment-content"
|
||||||
|
rows="4" placeholder="{{ _('post.comment_placeholder', current_locale) }}"
|
||||||
|
required minlength="1" maxlength="5000"></textarea>
|
||||||
|
<input type="hidden" id="comment-parent-id" name="parent_id" value="">
|
||||||
|
<p class="form-help" data-testid="comment-form-help" id="reply-info" style="display: none;">
|
||||||
|
{{ _('post.replying_to', current_locale) }}
|
||||||
|
<button type="button" class="btn-cancel-reply" data-testid="btn-cancel-reply">{{ _('post.cancel_reply', current_locale) }}</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" data-testid="submit-comment">
|
||||||
|
{{ _('post.submit_comment', current_locale) }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-cancel-comment" data-testid="btn-cancel-comment" style="display: none;">
|
||||||
|
{{ _('post.cancel', current_locale) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="comment-error" class="comment-error" data-testid="comment-error" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% macro render_comment(comment, depth) %}
|
||||||
|
<div class="comment{% if depth > 0 %} comment-reply{% endif %}" data-testid="comment-{{ comment.id }}" data-comment-id="{{ comment.id }}">
|
||||||
|
<div class="comment-avatar" data-testid="comment-avatar-{{ comment.id }}">
|
||||||
|
<span class="avatar avatar-sm">{{ comment.author_id[0]|upper }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body" data-testid="comment-body-{{ comment.id }}">
|
||||||
|
<div class="comment-meta" data-testid="comment-meta-{{ comment.id }}">
|
||||||
|
<span class="comment-author" data-testid="comment-author-{{ comment.id }}">{{ comment.author_id }}</span>
|
||||||
|
<span class="comment-date" data-testid="comment-date-{{ comment.id }}">
|
||||||
|
{% if comment.created_at %}{{ comment.created_at.strftime('%B %d, %Y') }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content" data-testid="comment-content-{{ comment.id }}">{{ comment.content }}</div>
|
||||||
|
<div class="comment-actions" data-testid="comment-actions-{{ comment.id }}">
|
||||||
|
{% if user %}
|
||||||
|
<button class="btn-comment-reply btn btn-sm" data-testid="btn-comment-reply-{{ comment.id }}"
|
||||||
|
data-comment-id="{{ comment.id }}" data-comment-author="{{ comment.author_id }}">
|
||||||
|
{{ _('post.reply', current_locale) }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn-comment-like btn btn-sm" data-testid="btn-comment-like-{{ comment.id }}"
|
||||||
|
data-comment-id="{{ comment.id }}">
|
||||||
|
👍 <span class="comment-like-count" data-testid="comment-like-count-{{ comment.id }}">{{ comment.like_count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set key = comment.id|string %}
|
||||||
|
{% if key in reply_comments %}
|
||||||
|
<div class="comment-replies" data-testid="comment-replies-{{ comment.id }}">
|
||||||
|
{% for child in reply_comments[key] %}
|
||||||
|
{{ render_comment(child, depth + 1) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="comments-list" data-testid="comments-list">
|
||||||
|
{% if top_level_comments %}
|
||||||
|
{% for comment in top_level_comments %}
|
||||||
|
{{ render_comment(comment, 0) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="comments-empty" data-testid="comments-empty">
|
||||||
|
<p>{{ _('post.no_comments', current_locale) }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script data-testid="comment-script">
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var likeButton = document.getElementById('like-button');
|
||||||
|
if (likeButton) {
|
||||||
|
likeButton.addEventListener('click', function() {
|
||||||
|
var slug = this.getAttribute('data-post-slug');
|
||||||
|
var countSpan = document.getElementById('like-count');
|
||||||
|
|
||||||
|
fetch('/web/posts/' + slug + '/like', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/auth/dev-login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Like request failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data && data.like_count !== undefined) {
|
||||||
|
countSpan.textContent = data.like_count;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Like error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var showFormBtn = document.getElementById('btn-show-comment-form');
|
||||||
|
var formWrapper = document.getElementById('comment-form-wrapper');
|
||||||
|
var cancelBtn = document.querySelector('.btn-cancel-comment');
|
||||||
|
var commentForm = document.getElementById('comment-form');
|
||||||
|
var commentContent = document.getElementById('comment-content');
|
||||||
|
var commentParentId = document.getElementById('comment-parent-id');
|
||||||
|
var replyInfo = document.getElementById('reply-info');
|
||||||
|
var commentError = document.getElementById('comment-error');
|
||||||
|
|
||||||
|
function showCommentForm(parentId, authorName) {
|
||||||
|
commentParentId.value = parentId || '';
|
||||||
|
if (parentId && authorName) {
|
||||||
|
replyInfo.style.display = 'block';
|
||||||
|
replyInfo.innerHTML = '{{ _("post.replying_to", current_locale) }} <strong>' + authorName + '</strong> — <button type="button" class="btn-cancel-reply" id="btn-cancel-reply">{{ _("post.cancel_reply", current_locale) }}</button>';
|
||||||
|
document.getElementById('btn-cancel-reply').addEventListener('click', function() {
|
||||||
|
commentParentId.value = '';
|
||||||
|
replyInfo.style.display = 'none';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
replyInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
formWrapper.style.display = 'block';
|
||||||
|
if (showFormBtn) showFormBtn.style.display = 'none';
|
||||||
|
if (cancelBtn) cancelBtn.style.display = 'inline-flex';
|
||||||
|
commentContent.focus();
|
||||||
|
commentError.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCommentForm() {
|
||||||
|
formWrapper.style.display = 'none';
|
||||||
|
if (showFormBtn) showFormBtn.style.display = 'inline-flex';
|
||||||
|
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||||
|
commentContent.value = '';
|
||||||
|
commentParentId.value = '';
|
||||||
|
replyInfo.style.display = 'none';
|
||||||
|
commentError.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFormBtn) {
|
||||||
|
showFormBtn.addEventListener('click', function() {
|
||||||
|
showCommentForm(null, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', hideCommentForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commentForm) {
|
||||||
|
commentForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var content = commentContent.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
var slug = this.getAttribute('data-post-slug');
|
||||||
|
var parentId = commentParentId.value || null;
|
||||||
|
|
||||||
|
var payload = {content: content};
|
||||||
|
if (parentId) {
|
||||||
|
payload.parent_id = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/web/posts/' + slug + '/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/auth/dev-login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Comment creation failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
commentError.textContent = '{{ _("post.comment_error", current_locale) }}';
|
||||||
|
commentError.style.display = 'block';
|
||||||
|
console.error('Comment error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyButtons = document.querySelectorAll('.btn-comment-reply');
|
||||||
|
replyButtons.forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var commentId = this.getAttribute('data-comment-id');
|
||||||
|
var author = this.getAttribute('data-comment-author');
|
||||||
|
showCommentForm(commentId, author);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var commentLikeButtons = document.querySelectorAll('.btn-comment-like');
|
||||||
|
commentLikeButtons.forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var commentId = this.getAttribute('data-comment-id');
|
||||||
|
var countSpan = this.querySelector('.comment-like-count');
|
||||||
|
|
||||||
|
fetch('/web/comments/' + commentId + '/like', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/auth/dev-login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Comment like failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data && data.like_count !== undefined) {
|
||||||
|
countSpan.textContent = data.like_count;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Comment like error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
119
app/presentation/templates/pages/post_form.html
Normal file
119
app/presentation/templates/pages/post_form.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if is_edit %}{{ _('post_form.title_edit', current_locale) }}{% else %}{{ _('post_form.title_new', current_locale) }}{% endif %} - {{ _('base.default_title', current_locale) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header" data-testid="page-header-form">
|
||||||
|
<h1 class="page-title" data-testid="page-title-form">
|
||||||
|
{% if is_edit %}{{ _('post_form.page_title_edit', current_locale) }}{% else %}{{ _('post_form.page_title_new', current_locale) }}{% endif %}
|
||||||
|
</h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{% if is_edit %}/web/posts/{{ post.slug }}/edit{% else %}/web/posts/new{% endif %}"
|
||||||
|
class="card"
|
||||||
|
data-testid="form-post"
|
||||||
|
>
|
||||||
|
<div class="card-body" data-testid="form-post-body">
|
||||||
|
<div class="form-group" data-testid="form-group-title">
|
||||||
|
<label for="title" class="form-label form-label-required" data-testid="label-title">
|
||||||
|
{{ _('post_form.label_title', current_locale) }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
class="input input-lg"
|
||||||
|
value="{% if post %}{{ post.title }}{% endif %}"
|
||||||
|
placeholder="{{ _('post_form.placeholder_title', current_locale) }}"
|
||||||
|
required
|
||||||
|
data-testid="input-title"
|
||||||
|
>
|
||||||
|
<span class="form-hint" data-testid="hint-title">{{ _('post_form.hint_title', current_locale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" data-testid="form-group-content">
|
||||||
|
<label for="content" class="form-label form-label-required" data-testid="label-content">
|
||||||
|
{{ _('post_form.label_content', current_locale) }}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
rows="12"
|
||||||
|
placeholder="{{ _('post_form.placeholder_content', current_locale) }}"
|
||||||
|
required
|
||||||
|
data-testid="textarea-content"
|
||||||
|
>{% if post %}{{ post.content }}{% endif %}</textarea>
|
||||||
|
<span class="form-hint" data-testid="hint-content">{{ _('post_form.hint_content', current_locale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" data-testid="form-group-tags">
|
||||||
|
<label for="tags" class="form-label" data-testid="label-tags">
|
||||||
|
{{ _('post_form.label_tags', current_locale) }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tags"
|
||||||
|
name="tags"
|
||||||
|
class="input"
|
||||||
|
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
|
||||||
|
placeholder="{{ _('post_form.placeholder_tags', current_locale) }}"
|
||||||
|
data-testid="input-tags"
|
||||||
|
>
|
||||||
|
<span class="form-hint" data-testid="hint-tags">{{ _('post_form.hint_tags', current_locale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer" data-testid="form-post-footer">
|
||||||
|
<div class="flex justify-between items-center" data-testid="form-actions">
|
||||||
|
<a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
|
||||||
|
{{ _('post_form.cancel', current_locale) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex gap-2" data-testid="form-submit-actions">
|
||||||
|
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
|
||||||
|
{{ _('post_form.save_draft', current_locale) }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
|
||||||
|
{% if is_edit %}{{ _('post_form.update_post', current_locale) }}{% else %}{{ _('post_form.publish_post', current_locale) }}{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="/static/js/easymde.min.js" data-testid="easymde-script"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
var easyMDE = new EasyMDE({
|
||||||
|
element: document.getElementById('content'),
|
||||||
|
spellChecker: false,
|
||||||
|
status: false,
|
||||||
|
minHeight: '300px',
|
||||||
|
placeholder: '{{ _('post_form.placeholder_content', current_locale) }}',
|
||||||
|
toolbar: [
|
||||||
|
'bold', 'italic', 'heading', '|',
|
||||||
|
'code', 'quote', 'unordered-list', 'ordered-list', '|',
|
||||||
|
'link', 'image', 'table', 'horizontal-rule', '|',
|
||||||
|
'preview', 'side-by-side', 'fullscreen', '|',
|
||||||
|
'guide'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
var form = document.querySelector('form[data-testid="form-post"]');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
easyMDE.toTextArea();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
125
app/presentation/templates/pages/profile.html
Normal file
125
app/presentation/templates/pages/profile.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Profile - {{ user.username }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header" data-testid="page-header-profile">
|
||||||
|
<h1 class="page-title" data-testid="page-title-profile">{{ _('profile.title', current_locale) }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-testid="profile-card">
|
||||||
|
<div class="card-body" data-testid="profile-card-body">
|
||||||
|
<div class="profile-header" data-testid="profile-header">
|
||||||
|
<div class="avatar avatar-lg" data-testid="profile-avatar">
|
||||||
|
{{ user.username[0]|upper }}
|
||||||
|
</div>
|
||||||
|
<div class="profile-info" data-testid="profile-info">
|
||||||
|
<h2 class="profile-username" data-testid="profile-username">{{ user.username }}</h2>
|
||||||
|
<span class="badge {% if user_role == 'admin' %}badge-primary{% else %}badge-success{% endif %}" data-testid="profile-role">
|
||||||
|
{{ user_role|upper }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider" data-testid="profile-divider"></div>
|
||||||
|
|
||||||
|
<div class="profile-details" data-testid="profile-details">
|
||||||
|
<div class="profile-field" data-testid="profile-field-email">
|
||||||
|
<span class="profile-label" data-testid="profile-label-email">{{ _('profile.email', current_locale) }}</span>
|
||||||
|
<span class="profile-value" data-testid="profile-value-email">{{ user.email or _('profile.not_provided', current_locale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-field" data-testid="profile-field-userid">
|
||||||
|
<span class="profile-label" data-testid="profile-label-userid">{{ _('profile.user_id', current_locale) }}</span>
|
||||||
|
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.first_name or user.last_name %}
|
||||||
|
<div class="profile-field" data-testid="profile-field-name">
|
||||||
|
<span class="profile-label" data-testid="profile-label-name">{{ _('profile.name', current_locale) }}</span>
|
||||||
|
<span class="profile-value" data-testid="profile-value-name">
|
||||||
|
{{ user.first_name or '' }} {{ user.last_name or '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer" data-testid="profile-card-footer">
|
||||||
|
<div class="flex justify-between items-center" data-testid="profile-actions">
|
||||||
|
<a href="/web/" class="btn" data-testid="btn-back-home">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('profile.back_home', current_locale) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if can_create %}
|
||||||
|
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-profile">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('profile.new_post', current_locale) }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-username {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-value {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.profile-header {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-field {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
14
app/presentation/templates/partials/footer.html
Normal file
14
app/presentation/templates/partials/footer.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<footer class="site-footer" data-testid="site-footer">
|
||||||
|
<div class="container" data-testid="footer-container">
|
||||||
|
<div class="footer-copyright" data-testid="footer-copyright">
|
||||||
|
<span data-testid="copyright-text">{{ _('footer.copyright', current_locale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
|
||||||
|
<a href="/about" class="footer-link" data-testid="footer-link-about">{{ _('footer.about', current_locale) }}</a>
|
||||||
|
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">{{ _('footer.privacy', current_locale) }}</a>
|
||||||
|
<a href="/terms" class="footer-link" data-testid="footer-link-terms">{{ _('footer.terms', current_locale) }}</a>
|
||||||
|
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">{{ _('footer.api', current_locale) }}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user