Files
blog.pyaqa.ru/app/presentation/web/routes.py
Sergey Vanyushkin 46cc06b596 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 — гайд по тестированию
2026-05-07 19:55:15 +03:00

545 lines
16 KiB
Python

"""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, user authentication, and full
integration with the application's use cases and domain layer.
"""
from typing import Any
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,
RequireUserDep,
can_create_post,
can_delete_post,
can_edit_post,
can_see_draft,
)
from app.presentation.web.flash import flash
router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
templates = Jinja2Templates(directory="app/presentation/templates")
_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:
user: User token info or None for guest.
Returns:
Effective role for the user.
"""
if not user:
return Role.GUEST
return get_effective_role(user.roles)
def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
"""Get base template context with user info and permissions.
Args:
user: Current user or None for guest.
Returns:
Dictionary with user, user_role, and can_create flags.
"""
user_role = _get_user_role(user)
return {
"user": user,
"user_role": user_role.value if user_role else None,
"can_create": can_create_post(user),
}
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:
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:
Tuple of (visible posts, has_next flag).
"""
user_role = _get_user_role(user)
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
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.
"""
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",
{
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": page,
"has_prev": page > 1,
"has_next": has_next,
},
)
@router.get("/posts", response_class=HTMLResponse)
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.
"""
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",
{
**context,
"posts": visible_posts,
"active_page": "posts",
"current_page": page,
"has_prev": page > 1,
"has_next": has_next,
},
)
@router.get("/posts/new", response_class=HTMLResponse)
async def new_post_form(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
"""Render the new post creation form.
Args:
request: The HTTP request object for template context.
user: Current user (required).
Returns:
HTMLResponse with rendered post form template.
"""
context = _get_base_context(user)
return templates.TemplateResponse(
request,
"pages/post_form.html",
{
**context,
"is_edit": False,
"post": None,
"active_page": "posts",
},
)
@router.post("/posts/new")
async def create_post(
request: Request,
user: RequireUserDep,
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 form page.
"""
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)
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_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.
Raises:
HTTPException: If post not found or not visible to user.
"""
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)
return templates.TemplateResponse(
request,
"pages/post_detail.html",
{
**context,
"post": post,
"active_page": "posts",
"can_edit": can_edit_post(user, post.author_id),
"can_delete": can_delete_post(user, post.author_id),
},
)
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
async def edit_post_form(
request: Request,
post_slug: str,
user: RequireUserDep,
get_use_case: FromDishka[GetPostUseCase],
) -> HTMLResponse:
"""Render the post edit form.
Args:
request: The HTTP request object for template context.
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.
Raises:
HTTPException: If post not found or user cannot edit it.
"""
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)
return templates.TemplateResponse(
request,
"pages/post_form.html",
{
**context,
"is_edit": True,
"post": post,
"active_page": "posts",
},
)
@router.post("/posts/{post_slug}/edit")
async def update_post(
request: Request,
post_slug: str,
user: RequireUserDep,
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_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:
RedirectResponse to the updated post or form page.
"""
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:
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")
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)
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")
async def delete_post(
request: Request,
post_slug: str,
user: RequireUserDep,
get_use_case: FromDishka[GetPostUseCase],
delete_use_case: FromDishka[DeletePostUseCase],
) -> RedirectResponse:
"""Handle post deletion.
Args:
request: The HTTP request object.
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:
RedirectResponse redirecting to the home page.
"""
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")
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 RedirectResponse(url="/web/", status_code=303)
@router.get("/profile", response_class=HTMLResponse)
async def profile(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
"""Render user profile page.
Args:
request: The HTTP request object for template context.
user: Current user (required).
Returns:
HTMLResponse with rendered profile template.
"""
context = _get_base_context(user)
return templates.TemplateResponse(
request,
"pages/profile.html",
{
**context,
"active_page": "profile",
},
)
@router.get("/about", response_class=HTMLResponse)
async def about(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the about page.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
Returns:
HTMLResponse with rendered about page template.
"""
context = _get_base_context(user)
return templates.TemplateResponse(
request,
"pages/about.html",
{
**context,
"active_page": "about",
},
)