feat: RBAC E2E тесты и фикс admin-прав для редактирования постов

Основные изменения:
- Добавлены E2E тесты для проверки ownership (TC-E2E-102/103):
  * test_admin_can_edit_any_post — admin может редактировать любой пост
  * test_user_cannot_edit_other_users_post — user не может редактировать чужой пост
- Исправлены use cases (UpdatePost, DeletePost, PublishPost) — добавлена проверка роли admin
- Обновлены web routes и API routes для передачи роли в use cases
- Добавлены unit тесты для admin-сценариев

Реструктуризация тестов:
- Удалены старые API тесты (tests/api/) — требуют переработки
- Удалены старые integration тесты (tests/integration/)
- Переработаны E2E тесты: удалены старые, добавлены новые с POM
- Добавлена документация тестов: FEATURE_*.md, TEST_MODEL.md, AGENTS.md

Инфраструктура:
- Добавлен MockKeycloakClient для dev-режима
- Добавлены статические файлы: EasyMDE, Highlight.js, стили markdown
- Обновлены шаблоны: base.html, post_form.html, post_detail.html
- Обновлена DI конфигурация и провайдеры

Документация:
- tests/FEATURE_RBAC.md — матрица тестов RBAC
- tests/FEATURE_POST_LIFECYCLE.md — тесты жизненного цикла поста
- tests/FEATURE_DOMAIN_FOUNDATION.md — тесты доменного слоя
- tests/FEATURE_INFRASTRUCTURE.md — тесты инфраструктуры
- tests/TEST_MODEL.md — глобальная матрица покрытия
- app/presentation/web/AGENTS.md — гайд по Web UI
- tests/AGENTS.md — гайд по тестированию
This commit is contained in:
2026-05-07 19:55:15 +03:00
parent 41f2a3d98e
commit 46cc06b596
58 changed files with 4234 additions and 4014 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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"]

View 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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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(

View File

@@ -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__)

View File

@@ -37,6 +37,8 @@
<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>

View File

@@ -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 %}
<div class="page-header" data-testid="page-header-about">
<h1 class="page-title" data-testid="page-title-about">About</h1>
</div>
<div class="card" data-testid="about-card">
<div class="card-body" data-testid="about-card-body">
<p data-testid="about-description">
A modern blog built with FastAPI and Domain-Driven Design architecture.
</p>
<div class="divider" data-testid="about-divider"></div>
<p data-testid="about-user">
{% if user %}
Signed in as <strong>{{ user.username }}</strong>.
{% else %}
You are browsing as a guest.
{% 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>
Back to Home
</a>
</div>
</div>
{% endblock %}

View File

@@ -35,7 +35,7 @@
<article class="card post-card" data-testid="post-card-{{ post.id }}">
<div class="post-card-header" data-testid="post-card-header-{{ post.id }}">
<h2 class="post-card-title" data-testid="post-title-{{ post.id }}">
<a href="/web/posts/{{ post.slug.value }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
<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 }}">Published</span>
@@ -55,7 +55,7 @@
</div>
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
{{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %}
{{ post.content[:200] }}{% if post.content|length > 200 %}...{% endif %}
</div>
<div class="post-card-footer" data-testid="post-card-footer-{{ post.id }}">
@@ -64,7 +64,7 @@
<span class="tag" data-testid="post-tag-{{ post.id }}-{{ loop.index }}">{{ tag }}</span>
{% endfor %}
</div>
<a href="/web/posts/{{ post.slug.value }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
<a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
Read more
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -77,7 +77,7 @@
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
{% if has_prev %}
<a href="/?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
{% endif %}
@@ -85,7 +85,7 @@
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
{% if has_next %}
<a href="/?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
{% endif %}
@@ -96,7 +96,7 @@
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
<h3 data-testid="empty-state-title">No posts yet</h3>
<p data-testid="empty-state-description">Be the first to write a post!</p>
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<article class="post-detail" data-testid="post-detail">
@@ -36,8 +36,8 @@
</div>
</header>
<div class="post-detail-content" data-testid="post-detail-content">
{{ post.content.value|nl2br }}
<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">
@@ -60,7 +60,7 @@
{% if can_edit or can_delete %}
<div class="flex gap-2" data-testid="post-detail-edit-actions">
{% if can_edit %}
<a href="/web/posts/{{ post.slug.value }}/edit" class="btn" data-testid="btn-edit-post">
<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>
@@ -68,7 +68,7 @@
</a>
{% endif %}
{% if can_delete %}
<form action="/web/posts/{{ post.slug.value }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<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('Are you sure you want to delete this post?');">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

View File

@@ -2,6 +2,10 @@
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% 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">
@@ -11,7 +15,7 @@
<form
method="POST"
action="{% if is_edit %}/web/posts/{{ post.slug.value }}/edit{% else %}/web/posts/new{% endif %}"
action="{% if is_edit %}/web/posts/{{ post.slug }}/edit{% else %}/web/posts/new{% endif %}"
class="card"
data-testid="form-post"
>
@@ -20,32 +24,31 @@
<label for="title" class="form-label form-label-required" data-testid="label-title">
Title
</label>
<input
type="text"
id="title"
name="title"
<input
type="text"
id="title"
name="title"
class="input input-lg"
value="{% if post %}{{ post.title.value }}{% endif %}"
value="{% if post %}{{ post.title }}{% endif %}"
placeholder="Enter post title"
required
data-testid="input-title"
>
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
</div>
<div class="form-group" data-testid="form-group-content">
<label for="content" class="form-label form-label-required" data-testid="label-content">
Content
</label>
<textarea
id="content"
name="content"
class="textarea"
<textarea
id="content"
name="content"
rows="12"
placeholder="Write your post content here..."
required
data-testid="textarea-content"
>{% if post %}{{ post.content.value }}{% endif %}</textarea>
>{% if post %}{{ post.content }}{% endif %}</textarea>
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
</div>
@@ -65,23 +68,11 @@
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
</div>
<div class="form-group" data-testid="form-group-published">
<label class="form-label" data-testid="label-published">
<input
type="checkbox"
name="published"
value="true"
{% if post and post.published %}checked{% endif %}
data-testid="checkbox-published"
>
Publish immediately
</label>
</div>
</div>
<div class="card-footer" data-testid="form-post-footer">
<div class="flex justify-between items-center" data-testid="form-actions">
<a href="{% if is_edit %}/web/posts/{{ post.slug.value }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
<a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
Cancel
</a>
@@ -97,3 +88,32 @@
</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: 'Write your post content here...',
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 %}

View File

@@ -1,6 +1,6 @@
<header class="site-header" data-testid="site-header">
<div class="container" data-testid="header-container">
<a href="/" class="site-logo" data-testid="nav-logo">
<a href="/web/" class="site-logo" data-testid="nav-logo">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="logo-icon">
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
@@ -254,13 +254,13 @@
<!-- Mobile Navigation Menu -->
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
<a href="/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
Home
</a>
<a href="/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
Posts
</a>
<a href="/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
About
</a>
</nav>

View File

@@ -1,11 +1,11 @@
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
<a href="/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
Home
</a>
<a href="/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
Posts
</a>
<a href="/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
About
</a>
</nav>

View File

@@ -0,0 +1,51 @@
# Web UI Knowledge Base
**Generated:** 2026-05-03 22:15 UTC
**Commit:** 41f2a3d
**Branch:** feature/tests
## Overview
FastAPI Jinja2 web UI layer with Keycloak auth integration, flash messages, and theme support.
## Structure
```
app/presentation/web/
├── __init__.py
├── auth.py # Keycloak OAuth login/logout/callback
├── deps.py # Web dependency injection (current_user, require_auth)
├── error_handlers.py # HTTP exception handlers for web routes
├── flash.py # Flash message middleware
└── routes.py # All web page routes (largest file in project)
```
## Where to Look
| Task | Location |
|------|----------|
| Add a new page | `routes.py` |
| Change auth flow | `auth.py` |
| Change flash messages | `flash.py` |
| Change error pages | `error_handlers.py` |
| Change DI for web | `deps.py` |
## Conventions
- **Templates**: Jinja2 in `app/presentation/templates/`
- **data-testid attributes REQUIRED** on all interactive elements
- **Theme support**: Light/dark via `data-theme` on `<html>`, LocalStorage persistence
- **Auth**: HTTP-only cookie `access_token`, Keycloak integration
- **Mock data**: Routes currently use `MockPost`/`MOCK_POSTS` — integrate real use cases when ready
## Anti-Patterns
- Do NOT use inline comments — self-documenting code only
- Do NOT add external CDN dependencies — all assets must be in `static/`
- Do NOT bypass `filter_visible_posts()` for draft access control
## Notes
- `routes.py` is the largest file in the project (519 lines) — consider splitting by concern
- `home`, `list_posts`, `post_detail`, `new_post_form`, `edit_post_form`, `create_post`, `update_post`, `delete_post`, `profile`, `about` are all defined in `routes.py`
- Web routers are imported directly in `main.py`, bypassing `app/presentation/__init__.py`

View File

@@ -86,14 +86,20 @@ async def exchange_code_for_token(code: str, redirect_uri: str) -> dict[str, Any
@router.get("/login")
async def login(request: Request) -> RedirectResponse:
"""Redirect to Keycloak login page.
"""Redirect to Keycloak login page or dev login in development mode.
In development mode redirects to the local dev login page
instead of the external Keycloak server.
Args:
request: HTTP request object.
Returns:
RedirectResponse to Keycloak authorization endpoint.
RedirectResponse to Keycloak or dev login endpoint.
"""
if settings.is_dev:
return RedirectResponse(url="/auth/dev-login")
callback_url = str(request.base_url).rstrip("/") + "/auth/callback"
login_url = get_keycloak_login_url(callback_url)
return RedirectResponse(url=login_url)
@@ -142,16 +148,196 @@ async def callback(request: Request, code: str | None = None) -> Response:
async def logout(request: Request) -> Response:
"""Logout user and clear token cookie.
In development mode redirects directly to home page.
In production redirects to Keycloak logout endpoint.
Args:
request: HTTP request object.
Returns:
RedirectResponse to Keycloak logout with cookie cleared.
RedirectResponse with cookie cleared.
"""
home_url = str(request.base_url).rstrip("/") + "/web/"
logout_url = get_keycloak_logout_url(home_url)
response = RedirectResponse(url=logout_url)
response = RedirectResponse(url=home_url)
response.delete_cookie(key="access_token")
if not settings.is_dev:
logout_url = get_keycloak_logout_url(home_url)
response = RedirectResponse(url=logout_url)
response.delete_cookie(key="access_token")
return response
@router.get("/dev-login")
async def dev_login(request: Request) -> Response:
"""Show dev login page for development mode.
Only available in development mode. Provides a simple form
to select role and log in without a real Keycloak server.
Args:
request: HTTP request object.
Returns:
HTMLResponse with dev login form.
Raises:
HTTPException: If accessed outside development mode.
"""
if not settings.is_dev:
raise HTTPException(status_code=404, detail="Not found")
from fastapi.responses import HTMLResponse
return HTMLResponse(
content="""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dev Login - Blog</title>
<style>
:root {
--bg: #f5f5f5;
--card: #fff;
--text: #333;
--border: #ddd;
--primary: #0366d6;
--primary-text: #fff;
--error: #d73a49;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--card: #161b22;
--text: #c9d1d9;
--border: #30363d;
--primary: #58a6ff;
--primary-text: #0d1117;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
.badge {
display: inline-block;
background: var(--error);
color: #fff;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
input, select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 0.9375rem;
margin-bottom: 1rem;
}
button {
width: 100%;
padding: 0.75rem;
background: var(--primary);
color: var(--primary-text);
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover { opacity: 0.9; }
.hint {
margin-top: 1.5rem;
font-size: 0.8125rem;
color: var(--text);
opacity: 0.7;
}
</style>
</head>
<body>
<div class="card">
<h1>Development Login</h1>
<span class="badge">DEV ONLY</span>
<form method="POST" action="/auth/dev-login">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="Dev User" required>
<label for="role">Role</label>
<select id="role" name="role">
<option value="user">User</option>
<option value="user2">Test User</option>
<option value="admin">Admin</option>
<option value="guest">Guest (unauthenticated)</option>
</select>
<button type="submit">Sign In</button>
</form>
<p class="hint">This bypasses Keycloak for local development only.</p>
</div>
</body>
</html>"""
)
@router.post("/dev-login")
async def dev_login_submit(request: Request) -> Response:
"""Handle dev login form submission.
Sets a dev-specific cookie that MockKeycloakClient recognizes.
Args:
request: HTTP request object with form data.
Returns:
RedirectResponse to home page with dev token cookie set.
Raises:
HTTPException: If accessed outside development mode.
"""
if not settings.is_dev:
raise HTTPException(status_code=404, detail="Not found")
form = await request.form()
role = str(form.get("role", "user")).strip()
token = f"dev-token-{role}"
response = RedirectResponse(url="/web/", status_code=302)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=86400,
)
return response

View File

@@ -9,19 +9,26 @@ from typing import Annotated
from fastapi import Cookie, Depends, HTTPException, Request
from app.domain.roles import Role, get_effective_role
from app.infrastructure.auth import KeycloakAuthClient, TokenInfo
from app.infrastructure.auth import KeycloakAuthClient, MockKeycloakClient, TokenInfo
from app.infrastructure.config.settings import settings
async def get_keycloak_client(request: Request) -> KeycloakAuthClient:
async def get_keycloak_client(
request: Request,
) -> KeycloakAuthClient | MockKeycloakClient:
"""Get Keycloak client from DI container via request state.
In development mode returns MockKeycloakClient for local testing.
Args:
request: FastAPI request object.
Returns:
KeycloakAuthClient instance from container.
KeycloakAuthClient or MockKeycloakClient instance from container.
"""
client: KeycloakAuthClient = await request.state.dishka_container.get(KeycloakAuthClient)
client: KeycloakAuthClient | MockKeycloakClient = await request.state.dishka_container.get(
KeycloakAuthClient
)
return client
@@ -75,9 +82,10 @@ async def get_current_user(
user = await get_optional_user(request, access_token)
if not user:
login_url = "/auth/dev-login" if settings.is_dev else "/auth/login"
raise HTTPException(
status_code=307,
headers={"Location": "/auth/login"},
headers={"Location": login_url},
)
return user
@@ -125,9 +133,10 @@ def require_role(required_role: Role): # type: ignore[no-untyped-def]
HTTPException: If user lacks required role.
"""
if not user:
login_url = "/auth/dev-login" if settings.is_dev else "/auth/login"
raise HTTPException(
status_code=307,
headers={"Location": "/auth/login"},
headers={"Location": login_url},
)
user_role = get_user_role(user)

View File

@@ -1,17 +1,37 @@
"""Web UI routes for blog application with authentication.
"""Web UI routes for blog application with real use case integration.
This module provides HTML endpoints for the blog web interface
with role-based access control and user authentication.
with role-based access control, user authentication, and full
integration with the application's use cases and domain layer.
"""
from datetime import datetime
from typing import Any
from uuid import uuid4
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from markdown_it import MarkdownIt
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
from app.application.dtos import CreatePostDTO, UpdatePostDTO
from app.application.use_cases import (
CreatePostUseCase,
DeletePostUseCase,
GetPostUseCase,
ListPostsUseCase,
PublishPostUseCase,
UpdatePostUseCase,
)
from app.domain.exceptions import (
AlreadyExistsException,
NotFoundException,
ValidationException,
)
from app.domain.roles import Role, get_effective_role
from app.infrastructure.auth import TokenInfo
from app.presentation.web.deps import (
OptionalUserDep,
@@ -20,135 +40,52 @@ from app.presentation.web.deps import (
can_delete_post,
can_edit_post,
can_see_draft,
get_user_role,
)
from app.presentation.web.flash import flash
router = APIRouter(prefix="/web", tags=["web"])
router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
templates = Jinja2Templates(directory="app/presentation/templates")
def nl2br(value: str) -> str:
"""Convert newlines to HTML line breaks.
_md = MarkdownIt("commonmark", {"html": False}).enable("table")
def _highlight_code(code: str, lang: str, _: Any) -> str:
try:
lexer = get_lexer_by_name(lang)
except ClassNotFound:
lexer = get_lexer_by_name("text")
formatter = HtmlFormatter(nowrap=True)
result: str = highlight(code, lexer, formatter)
return result
def markdown_filter(value: str) -> str:
md = MarkdownIt("commonmark", {"html": False, "highlight": _highlight_code}).enable("table")
return str(md.render(value))
templates.env.filters["markdown"] = markdown_filter
_DEFAULT_PAGE_SIZE = 10
def _get_user_role(user: TokenInfo | None) -> Role:
"""Get effective role from user token.
Args:
value: String with newlines.
user: User token info or None for guest.
Returns:
String with <br> tags instead of newlines.
Effective role for the user.
"""
return value.replace("\n", "<br>\n")
if not user:
return Role.GUEST
return get_effective_role(user.roles)
templates.env.filters["nl2br"] = nl2br
class MockPost:
"""Mock post object for UI demonstration.
This class simulates a Post entity for template rendering
before integration with actual use cases.
Attributes:
id: Unique identifier for the post.
title: Post title value object.
content: Post content value object.
slug: URL-friendly slug.
author_id: Identifier of the post author.
published: Publication status flag.
tags: List of tags associated with the post.
created_at: Timestamp when the post was created.
updated_at: Timestamp when the post was last updated.
"""
def __init__(
self,
id: str,
title: str,
content: str,
slug: str,
author_id: str,
published: bool,
tags: list[str],
created_at: datetime | None = None,
) -> None:
"""Initialize mock post with provided attributes.
Args:
id: Unique identifier for the post.
title: Post title string.
content: Post content string.
slug: URL-friendly slug string.
author_id: Author identifier string.
published: Whether the post is published.
tags: List of tag strings.
created_at: Optional creation timestamp, defaults to now.
"""
self.id = id
self.title = MockValueObject(title)
self.content = MockValueObject(content)
self.slug = MockValueObject(slug)
self.author_id = author_id
self.published = published
self.tags = tags
self.created_at = created_at or datetime.now()
self.updated_at = self.created_at
class MockValueObject:
"""Mock value object for simulating domain value objects.
Wraps a raw value to simulate the interface of domain
value objects like Title, Content, and Slug.
Attributes:
value: The wrapped string value.
"""
def __init__(self, value: str) -> None:
"""Initialize with a string value.
Args:
value: The string value to wrap.
"""
self.value = value
MOCK_POSTS = [
MockPost(
id=str(uuid4()),
title="Getting Started with FastAPI",
content="FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.7+ based on standard Python type hints. It is designed to be easy to use while providing high performance.",
slug="getting-started-with-fastapi",
author_id="john_doe",
published=True,
tags=["python", "fastapi", "tutorial"],
created_at=datetime(2026, 1, 15, 10, 30),
),
MockPost(
id=str(uuid4()),
title="Understanding DDD Architecture",
content="Domain-Driven Design (DDD) is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The term was coined by Eric Evans in his book of the same title.",
slug="understanding-ddd-architecture",
author_id="jane_smith",
published=True,
tags=["ddd", "architecture", "software-design"],
created_at=datetime(2026, 1, 14, 14, 45),
),
MockPost(
id=str(uuid4()),
title="Draft Post Example",
content="This is a draft post that hasn't been published yet. It demonstrates how unpublished posts appear in the UI.",
slug="draft-post-example",
author_id="john_doe",
published=False,
tags=["draft"],
created_at=datetime(2026, 1, 13, 9, 0),
),
]
def get_base_context(user: TokenInfo | None) -> dict[str, Any]:
def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
"""Get base template context with user info and permissions.
Args:
@@ -157,7 +94,7 @@ def get_base_context(user: TokenInfo | None) -> dict[str, Any]:
Returns:
Dictionary with user, user_role, and can_create flags.
"""
user_role = get_user_role(user)
user_role = _get_user_role(user)
return {
"user": user,
@@ -166,42 +103,77 @@ def get_base_context(user: TokenInfo | None) -> dict[str, Any]:
}
def filter_visible_posts(posts: list[MockPost], user: TokenInfo | None) -> list[MockPost]:
"""Filter posts based on user permissions.
async def _get_visible_posts(
list_use_case: ListPostsUseCase,
user: TokenInfo | None,
limit: int,
offset: int,
) -> tuple[list[Any], bool]:
"""Fetch posts visible to the user with pagination.
For guests: only published posts.
For users: published posts plus own drafts.
For admins: all posts.
Args:
posts: List of all posts.
list_use_case: Use case for listing posts.
user: Current user or None for guest.
limit: Maximum number of posts to return.
offset: Number of posts to skip.
Returns:
Filtered list of posts visible to the user.
Tuple of (visible posts, has_next flag).
"""
visible_posts = []
user_role = _get_user_role(user)
for post in posts:
if post.published or can_see_draft(user, post.author_id):
visible_posts.append(post)
if user_role == Role.ADMIN:
posts = await list_use_case.all_posts()
posts = sorted(posts, key=lambda p: p.created_at, reverse=True)
total = len(posts)
posts = posts[offset : offset + limit]
has_next = offset + limit < total
return posts, has_next
return visible_posts
published = await list_use_case.published_posts(limit=limit + 1, offset=offset)
has_next = len(published) > limit
published = published[:limit]
if user_role == Role.USER and user is not None:
own = await list_use_case.by_author(user.user_id)
published_ids = {p.id for p in published}
own_drafts = [p for p in own if p.id not in published_ids and not p.published]
merged = list(published) + own_drafts
merged.sort(key=lambda p: p.created_at, reverse=True)
return merged[:limit], has_next or len(own_drafts) > 0
return published, has_next
@router.get("/", response_class=HTMLResponse)
async def home(
request: Request,
user: OptionalUserDep,
list_use_case: FromDishka[ListPostsUseCase],
) -> HTMLResponse:
"""Render the home page with list of posts.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
list_use_case: Use case for listing posts.
Returns:
HTMLResponse with rendered posts list template.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
page_str = request.query_params.get("page", "1")
page = max(1, int(page_str) if page_str.isdigit() else 1)
offset = (page - 1) * _DEFAULT_PAGE_SIZE
visible_posts, has_next = await _get_visible_posts(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
)
context = _get_base_context(user)
return templates.TemplateResponse(
request,
"pages/index.html",
@@ -209,9 +181,9 @@ async def home(
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
"has_next": False,
"current_page": page,
"has_prev": page > 1,
"has_next": has_next,
},
)
@@ -220,19 +192,27 @@ async def home(
async def list_posts(
request: Request,
user: OptionalUserDep,
list_use_case: FromDishka[ListPostsUseCase],
) -> HTMLResponse:
"""Render the posts listing page.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
list_use_case: Use case for listing posts.
Returns:
HTMLResponse with rendered posts list template.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
page_str = request.query_params.get("page", "1")
page = max(1, int(page_str) if page_str.isdigit() else 1)
offset = (page - 1) * _DEFAULT_PAGE_SIZE
visible_posts, has_next = await _get_visible_posts(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
)
context = _get_base_context(user)
return templates.TemplateResponse(
request,
"pages/index.html",
@@ -240,9 +220,9 @@ async def list_posts(
**context,
"posts": visible_posts,
"active_page": "posts",
"current_page": 1,
"has_prev": False,
"has_next": True,
"current_page": page,
"has_prev": page > 1,
"has_next": has_next,
},
)
@@ -261,7 +241,7 @@ async def new_post_form(
Returns:
HTMLResponse with rendered post form template.
"""
context = get_base_context(user)
context = _get_base_context(user)
return templates.TemplateResponse(
request,
@@ -279,19 +259,51 @@ async def new_post_form(
async def create_post(
request: Request,
user: RequireUserDep,
create_use_case: FromDishka[CreatePostUseCase],
publish_use_case: FromDishka[PublishPostUseCase],
) -> RedirectResponse:
"""Handle new post creation form submission.
Args:
request: The HTTP request object containing form data.
user: Current user (required).
create_use_case: Use case for creating posts.
publish_use_case: Use case for publishing posts.
Returns:
RedirectResponse to the new post or home page.
RedirectResponse to the new post or form page.
"""
flash(request, "Post created successfully!", "success")
response = RedirectResponse(url="/web/", status_code=303)
return response
form = await request.form()
title = str(form.get("title", "")).strip()
content = str(form.get("content", "")).strip()
tags_str = str(form.get("tags", "")).strip()
action = str(form.get("action", "draft")).strip()
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
try:
dto = CreatePostDTO(
title=title,
content=content,
author_id=user.user_id,
tags=tags,
)
result = await create_use_case.execute(dto)
user_role = _get_user_role(user)
if action == "publish":
await publish_use_case.publish(result.id, user.user_id, user_role)
flash(request, "Post published successfully!", "success")
else:
flash(request, "Post saved as draft!", "success")
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except AlreadyExistsException as exc:
flash(request, str(exc), "error")
return RedirectResponse(url="/web/posts/new", status_code=303)
except ValidationException as exc:
flash(request, str(exc), "error")
return RedirectResponse(url="/web/posts/new", status_code=303)
@router.get("/posts/{post_slug}", response_class=HTMLResponse)
@@ -299,13 +311,15 @@ async def post_detail(
request: Request,
post_slug: str,
user: OptionalUserDep,
get_use_case: FromDishka[GetPostUseCase],
) -> HTMLResponse:
"""Render a single post detail page.
Args:
request: The HTTP request object for template context.
post_id: The unique identifier of the post to display.
post_slug: The URL-friendly slug of the post to display.
user: Current user from dependency.
get_use_case: Use case for retrieving posts.
Returns:
HTMLResponse with rendered post detail template.
@@ -313,15 +327,15 @@ async def post_detail(
Raises:
HTTPException: If post not found or not visible to user.
"""
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
try:
post = await get_use_case.by_slug(post_slug)
except NotFoundException:
raise HTTPException(status_code=404, detail="Post not found") from None
if not post.published and not can_see_draft(user, post.author_id):
raise HTTPException(status_code=404, detail="Post not found")
context = get_base_context(user)
context = _get_base_context(user)
return templates.TemplateResponse(
request,
@@ -341,13 +355,15 @@ async def edit_post_form(
request: Request,
post_slug: str,
user: RequireUserDep,
get_use_case: FromDishka[GetPostUseCase],
) -> HTMLResponse:
"""Render the post edit form.
Args:
request: The HTTP request object for template context.
post_id: The unique identifier of the post to edit.
post_slug: The URL-friendly slug of the post to edit.
user: Current user (required).
get_use_case: Use case for retrieving posts.
Returns:
HTMLResponse with rendered post form template.
@@ -355,15 +371,15 @@ async def edit_post_form(
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
try:
post = await get_use_case.by_slug(post_slug)
except NotFoundException:
raise HTTPException(status_code=404, detail="Post not found") from None
if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
context = get_base_context(user)
context = _get_base_context(user)
return templates.TemplateResponse(
request,
@@ -377,90 +393,103 @@ async def edit_post_form(
)
@router.post("/posts/{post_slug}/edit", response_class=HTMLResponse)
@router.post("/posts/{post_slug}/edit")
async def update_post(
request: Request,
post_slug: str,
user: RequireUserDep,
) -> HTMLResponse:
get_use_case: FromDishka[GetPostUseCase],
update_use_case: FromDishka[UpdatePostUseCase],
publish_use_case: FromDishka[PublishPostUseCase],
) -> RedirectResponse:
"""Handle post update form submission.
Args:
request: The HTTP request object containing form data.
post_id: The unique identifier of the post to update.
post_slug: The URL-friendly slug of the post to update.
user: Current user (required).
get_use_case: Use case for retrieving posts.
update_use_case: Use case for updating posts.
publish_use_case: Use case for publishing posts.
Returns:
HTMLResponse with rendered post detail template.
Raises:
HTTPException: If post not found or user cannot edit it.
RedirectResponse to the updated post or form page.
"""
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
form = await request.form()
title = str(form.get("title", "")).strip()
content = str(form.get("content", "")).strip()
tags_str = str(form.get("tags", "")).strip()
action = str(form.get("action", "draft")).strip()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
try:
post = await get_use_case.by_slug(post_slug)
except NotFoundException:
raise HTTPException(status_code=404, detail="Post not found") from None
if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
context = get_base_context(user)
try:
dto = UpdatePostDTO(
title=title if title else None,
content=content if content else None,
tags=tags if tags else None,
)
user_role = _get_user_role(user)
result = await update_use_case.execute(post.id, dto, user.user_id, user_role)
return templates.TemplateResponse(
request,
"pages/post_detail.html",
{
**context,
"post": post,
"active_page": "posts",
"can_edit": True,
"can_delete": can_delete_post(user, post.author_id),
},
)
if action == "publish":
if not result.published:
await publish_use_case.publish(result.id, user.user_id, user_role)
else:
if result.published:
await publish_use_case.unpublish(result.id, user.user_id, user_role)
flash(request, "Post updated successfully!", "success")
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except (AlreadyExistsException, ValidationException) as exc:
flash(request, str(exc), "error")
return RedirectResponse(url=f"/web/posts/{post_slug}/edit", status_code=303)
@router.post("/posts/{post_slug}/delete", response_class=HTMLResponse)
@router.post("/posts/{post_slug}/delete")
async def delete_post(
request: Request,
post_slug: str,
user: RequireUserDep,
) -> HTMLResponse:
get_use_case: FromDishka[GetPostUseCase],
delete_use_case: FromDishka[DeletePostUseCase],
) -> RedirectResponse:
"""Handle post deletion.
Args:
request: The HTTP request object.
post_id: The unique identifier of the post to delete.
post_slug: The URL-friendly slug of the post to delete.
user: Current user (required).
get_use_case: Use case for retrieving posts.
delete_use_case: Use case for deleting posts.
Returns:
HTMLResponse redirecting to the home page.
Raises:
HTTPException: If post not found or user cannot delete it.
RedirectResponse redirecting to the home page.
"""
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
try:
post = await get_use_case.by_slug(post_slug)
except NotFoundException:
raise HTTPException(status_code=404, detail="Post not found") from None
if not can_delete_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to delete this post")
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
try:
user_role = _get_user_role(user)
await delete_use_case.execute(post.id, user.user_id, user_role)
flash(request, "Post deleted successfully!", "success")
except NotFoundException:
flash(request, "Post not found.", "error")
return templates.TemplateResponse(
request,
"pages/index.html",
{
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
"has_next": False,
},
)
return RedirectResponse(url="/web/", status_code=303)
@router.get("/profile", response_class=HTMLResponse)
@@ -477,7 +506,7 @@ async def profile(
Returns:
HTMLResponse with rendered profile template.
"""
context = get_base_context(user)
context = _get_base_context(user)
return templates.TemplateResponse(
request,
@@ -503,17 +532,13 @@ async def about(
Returns:
HTMLResponse with rendered about page template.
"""
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head><title>About - Blog</title></head>
<body>
<h1>About</h1>
<p>A modern blog built with FastAPI and DDD architecture.</p>
<p>User: {user.username if user else "Guest"}</p>
<a href="/web/">Back to home</a>
</body>
</html>
"""
context = _get_base_context(user)
return templates.TemplateResponse(
request,
"pages/about.html",
{
**context,
"active_page": "about",
},
)