diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml deleted file mode 100644 index a99960e..0000000 --- a/.woodpecker/lint.yaml +++ /dev/null @@ -1,14 +0,0 @@ -when: - - event: [push, pull_request] - branch: dev - -steps: - - name: lint - image: python:3.13 - commands: - - pip install uv - - uv sync --no-dev --only-group lints - - uv run ruff check . - - uv run ruff format --check . - - uv run isort --check-only . - diff --git a/.woodpecker/pipeline.yml b/.woodpecker/pipeline.yml new file mode 100644 index 0000000..0dda2c6 --- /dev/null +++ b/.woodpecker/pipeline.yml @@ -0,0 +1,89 @@ +when: + event: [push, pull_request] + branch: [dev, main, master] + +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 + - cd .. + - | + cat > pyproject.toml << 'EOF' + [project] + name = "pyaqa" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = [] + + [tool.uv.workspace] + members = ["blog.pyaqa.ru", "pytfm"] + EOF + - git clone https://git.pyaqa.ru/pi3c/pytfm.git + - cd $CI_WORKSPACE + - rm -rf .venv + - 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" + depends_on: [deps] + commands: + - pip install uv + - uv run --no-sync pytest tests/unit/ + + - 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" + depends_on: [deps] + commands: + - pip install uv + - 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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml deleted file mode 100644 index 71557b6..0000000 --- a/.woodpecker/test.yaml +++ /dev/null @@ -1,11 +0,0 @@ -when: - - event: [push, pull_request] - branch: dev - -steps: - - name: test - image: python:3.13 - commands: - - pip install uv - - uv sync --no-dev --group tests - - uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing diff --git a/.woodpecker/type.yaml b/.woodpecker/type.yaml deleted file mode 100644 index c230690..0000000 --- a/.woodpecker/type.yaml +++ /dev/null @@ -1,11 +0,0 @@ -when: - - event: [push, pull_request] - branch: dev - -steps: - - name: type - image: python:3.13 - commands: - - pip install uv - - uv sync --no-dev --only-group types - - uv run mypy . diff --git a/AGENTS.md b/AGENTS.md index 83565bf..62c3db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # 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 @@ -89,6 +93,34 @@ 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 @@ -99,9 +131,6 @@ tests/ ### Testing - **Unit tests**: Test domain logic without DB/external services -- **Integration tests**: Test repository implementations with real DB -- **API tests**: Test endpoints with mocked use cases -- **E2E tests**: Full workflow testing ### Code Patterns - Use **dataclasses** for entities and value objects @@ -110,6 +139,17 @@ tests/ - 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 @@ -313,3 +353,172 @@ response.set_cookie( - 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) +``` + +### 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 (во все затронутые проекты) +``` + +### 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 (во все затронутые проекты) +``` + +### 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 ` diff --git a/app/application/use_cases/delete_post.py b/app/application/use_cases/delete_post.py index 968d5b6..f437c6e 100644 --- a/app/application/use_cases/delete_post.py +++ b/app/application/use_cases/delete_post.py @@ -9,6 +9,7 @@ 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: @@ -40,22 +41,28 @@ class DeletePostUseCase: self._post_repo = post_repo self._tx_manager = tx_manager - async def execute(self, post_id: UUID, current_user_id: str) -> None: + 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. + 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 post.author_id != current_user_id: + 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) diff --git a/app/application/use_cases/publish_post.py b/app/application/use_cases/publish_post.py index a585d6c..258ec45 100644 --- a/app/application/use_cases/publish_post.py +++ b/app/application/use_cases/publish_post.py @@ -11,6 +11,7 @@ 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: @@ -42,25 +43,31 @@ class PublishPostUseCase: self._post_repo = post_repo self._tx_manager = tx_manager - async def publish(self, post_id: UUID, current_user_id: str) -> PostResponseDTO: + 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. + 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 post.author_id != current_user_id: + if current_role != Role.ADMIN and post.author_id != current_user_id: raise ForbiddenException("You can only publish your own posts") post.publish() @@ -69,25 +76,31 @@ class PublishPostUseCase: return self._map_to_dto(post) - async def unpublish(self, post_id: UUID, current_user_id: str) -> PostResponseDTO: + 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. + 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 post.author_id != current_user_id: + if current_role != Role.ADMIN and post.author_id != current_user_id: raise ForbiddenException("You can only unpublish your own posts") post.unpublish() diff --git a/app/application/use_cases/update_post.py b/app/application/use_cases/update_post.py index bb240c6..70ca509 100644 --- a/app/application/use_cases/update_post.py +++ b/app/application/use_cases/update_post.py @@ -11,6 +11,7 @@ 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 @@ -50,6 +51,7 @@ class UpdatePostUseCase: post_id: UUID, dto: UpdatePostDTO, current_user_id: str, + current_role: Role = Role.USER, ) -> PostResponseDTO: """Execute the use case to update a post. @@ -57,19 +59,20 @@ class UpdatePostUseCase: 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. + 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 post.author_id != current_user_id: + 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: diff --git a/app/infrastructure/auth/__init__.py b/app/infrastructure/auth/__init__.py index cf077c9..c3ae09c 100644 --- a/app/infrastructure/auth/__init__.py +++ b/app/infrastructure/auth/__init__.py @@ -5,6 +5,7 @@ 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", "TokenInfo"] +__all__ = ["KeycloakAuthClient", "KeycloakUser", "MockKeycloakClient", "TokenInfo"] diff --git a/app/infrastructure/auth/mock_client.py b/app/infrastructure/auth/mock_client.py new file mode 100644 index 0000000..e82e17d --- /dev/null +++ b/app/infrastructure/auth/mock_client.py @@ -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) diff --git a/app/infrastructure/di/providers.py b/app/infrastructure/di/providers.py index 0844e51..844a762 100644 --- a/app/infrastructure/di/providers.py +++ b/app/infrastructure/di/providers.py @@ -19,7 +19,7 @@ from app.application import ( ) from app.application.interfaces import TransactionManager from app.domain.repositories import PostRepository -from app.infrastructure.auth import KeycloakAuthClient +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.post import SQLAlchemyPostRepository @@ -241,7 +241,7 @@ class KeycloakProvider(Provider): """Provider for Keycloak authentication client. Provides Keycloak client as application-scoped singleton. - Client is stateless and can be shared across requests. + In development mode uses MockKeycloakClient for local testing. Example: >>> provider = KeycloakProvider() @@ -249,9 +249,14 @@ class KeycloakProvider(Provider): @provide(scope=Scope.APP) def get_keycloak_client(self) -> KeycloakAuthClient: - """Provide KeycloakAuthClient singleton. + """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) diff --git a/app/infrastructure/di/transaction_manager.py b/app/infrastructure/di/transaction_manager.py index e437c18..61a5172 100644 --- a/app/infrastructure/di/transaction_manager.py +++ b/app/infrastructure/di/transaction_manager.py @@ -37,17 +37,12 @@ class SessionTransactionManager(TransactionManager): """Commit the current transaction. Persists all pending changes to the database. - Only commits once - subsequent calls are no-ops. """ - if not self._committed: - await self._session.commit() - self._committed = True + await self._session.commit() async def rollback(self) -> None: """Rollback the current transaction. Discards all pending changes. - Only rolls back if not already committed. """ - if not self._committed: - await self._session.rollback() + await self._session.rollback() diff --git a/app/infrastructure/repositories/post.py b/app/infrastructure/repositories/post.py index 8e86ef6..8b0df02 100644 --- a/app/infrastructure/repositories/post.py +++ b/app/infrastructure/repositories/post.py @@ -179,7 +179,11 @@ class SQLAlchemyPostRepository(PostRepository): Returns: List of Post entities by the author. """ - query = select(PostORM).where(PostORM.author_id == author_id) + 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: @@ -202,7 +206,9 @@ class SQLAlchemyPostRepository(PostRepository): Returns: List of published Post entities. """ - query = select(PostORM).where(PostORM.published.is_(True)) + 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: diff --git a/app/main.py b/app/main.py index be370d7..fb50e65 100644 --- a/app/main.py +++ b/app/main.py @@ -82,6 +82,8 @@ def app_factory() -> FastAPI: """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.add_middleware( diff --git a/app/presentation/api/v1/posts.py b/app/presentation/api/v1/posts.py index 01dd63c..fde8190 100644 --- a/app/presentation/api/v1/posts.py +++ b/app/presentation/api/v1/posts.py @@ -249,6 +249,7 @@ async def update_post( schema: PostUpdateSchema, use_case: UpdatePostDep, current_user_id: CurrentUserDep, + role: CurrentRoleDep, ) -> PostResponseSchema: """Update a post. @@ -257,6 +258,7 @@ async def update_post( schema: Update data. use_case: UpdatePostUseCase dependency. current_user_id: Authenticated user ID. + role: Current user role. Returns: PostResponseSchema with updated post data. @@ -266,7 +268,7 @@ async def update_post( content=schema.content, tags=schema.tags, ) - result = await use_case.execute(post_id, dto, current_user_id) + result = await use_case.execute(post_id, dto, current_user_id, role) return PostResponseSchema(**result.__dict__) @@ -279,6 +281,7 @@ async def delete_post( post_id: UUID, use_case: DeletePostDep, current_user_id: CurrentUserDep, + role: CurrentRoleDep, ) -> None: """Delete a post. @@ -286,8 +289,9 @@ async def delete_post( 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) + await use_case.execute(post_id, current_user_id, role) @router.post( @@ -299,6 +303,7 @@ async def publish_post( post_id: UUID, use_case: PublishPostDep, current_user_id: CurrentUserDep, + role: CurrentRoleDep, ) -> PostResponseSchema: """Publish a post. @@ -306,11 +311,12 @@ async def publish_post( 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) + result = await use_case.publish(post_id, current_user_id, role) return PostResponseSchema(**result.__dict__) @@ -323,6 +329,7 @@ async def unpublish_post( post_id: UUID, use_case: PublishPostDep, current_user_id: CurrentUserDep, + role: CurrentRoleDep, ) -> PostResponseSchema: """Unpublish a post. @@ -330,9 +337,10 @@ async def unpublish_post( 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) + result = await use_case.unpublish(post_id, current_user_id, role) return PostResponseSchema(**result.__dict__) diff --git a/app/presentation/templates/base.html b/app/presentation/templates/base.html index ce9aaa9..9ccf1ed 100644 --- a/app/presentation/templates/base.html +++ b/app/presentation/templates/base.html @@ -37,6 +37,8 @@ + + {% block extra_css %}{% endblock %} diff --git a/app/presentation/templates/pages/about.html b/app/presentation/templates/pages/about.html new file mode 100644 index 0000000..7ddd8f3 --- /dev/null +++ b/app/presentation/templates/pages/about.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}About - Blog{% endblock %} +{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %} + +{% block content %} + + +
+
+

+ A modern blog built with FastAPI and Domain-Driven Design architecture. +

+ +
+ +

+ {% if user %} + Signed in as {{ user.username }}. + {% else %} + You are browsing as a guest. + {% endif %} +

+
+ + +
+{% endblock %} diff --git a/app/presentation/templates/pages/index.html b/app/presentation/templates/pages/index.html index cbe67cd..5caf0f8 100644 --- a/app/presentation/templates/pages/index.html +++ b/app/presentation/templates/pages/index.html @@ -35,7 +35,7 @@

- {{ post.title }} + {{ post.title }}

{% if post.published %} Published @@ -55,7 +55,7 @@
- {{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %} + {{ post.content[:200] }}{% if post.content|length > 200 %}...{% endif %}
@@ -64,7 +64,7 @@ {{ tag }} {% endfor %}
- + Read more @@ -77,7 +77,7 @@ Previous {% endif %} @@ -85,7 +85,7 @@ {{ current_page }} {% if has_next %} - Next + Next {% else %} Next {% endif %} @@ -96,7 +96,7 @@
📝

No posts yet

Be the first to write a post!

- Create your first post + Create your first post {% endif %} {% endblock %} diff --git a/app/presentation/templates/pages/post_detail.html b/app/presentation/templates/pages/post_detail.html index 1b32b7e..e4a7752 100644 --- a/app/presentation/templates/pages/post_detail.html +++ b/app/presentation/templates/pages/post_detail.html @@ -1,19 +1,19 @@ {% extends "base.html" %} {% block title %}{{ post.title }} - Blog{% endblock %} -{% block meta_description %}{{ post.content.value[:160] }}{% 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.value }}{% 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.value }}{% endblock %} +{% block og_url %}{{ request.base_url }}web/posts/{{ post.slug }}{% endblock %} {% block og_title %}{{ post.title }}{% endblock %} -{% block og_description %}{{ post.content.value[:160] }}{% endblock %} +{% block og_description %}{{ post.content[:160] }}{% endblock %} {% block twitter_title %}{{ post.title }}{% endblock %} -{% block twitter_description %}{{ post.content.value[:160] }}{% endblock %} +{% block twitter_description %}{{ post.content[:160] }}{% endblock %} {% block content %}
@@ -36,8 +36,8 @@ -
- {{ post.content.value|nl2br }} +
+ {{ post.content|markdown|safe }}
@@ -60,7 +60,7 @@ {% if can_edit or can_delete %}
{% if can_edit %} - + @@ -68,7 +68,7 @@ {% endif %} {% if can_delete %} -
+
- +
- + >{% if post %}{{ post.content }}{% endif %} The main content of your post. Markdown is supported.
@@ -65,23 +68,11 @@ Comma-separated list of tags
-
- -