Files
blog.pyaqa.ru/app/presentation/web/routes.py
Sergey Vanyushkin 0cb706e54b feat(auth): implement web authentication with Keycloak OAuth2
- Add auth routes: /auth/login, /auth/callback, /auth/logout
- Add OAuth2 flow with Keycloak using HTTP-only cookies
- Add web auth dependencies with role checking
- Add profile page (read-only) at /web/profile
- Update header with user menu (sign in/out, profile)
- Filter posts based on user permissions (hide drafts from guests)
- Conditionally show/hide create/edit/delete buttons
- Add authorization rules documentation to AGENTS.md
- Secure post editing/deletion endpoints with auth checks
- Add can_edit, can_delete flags to templates
2026-05-02 15:39:49 +03:00

516 lines
14 KiB
Python

"""Web UI routes for blog application with authentication.
This module provides HTML endpoints for the blog web interface
with role-based access control and user authentication.
"""
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
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,
get_user_role,
)
router = APIRouter(prefix="/web", tags=["web"])
templates = Jinja2Templates(directory="app/presentation/templates")
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]:
"""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),
}
def filter_visible_posts(posts: list[MockPost], user: TokenInfo | None) -> list[MockPost]:
"""Filter posts based on user permissions.
Args:
posts: List of all posts.
user: Current user or None for guest.
Returns:
Filtered list of posts visible to the user.
"""
visible_posts = []
for post in posts:
if post.published or can_see_draft(user, post.author_id):
visible_posts.append(post)
return visible_posts
@router.get("/", response_class=HTMLResponse)
async def home(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the home page with list of posts.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
Returns:
HTMLResponse with rendered posts list template.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
"has_next": False,
},
)
@router.get("/posts", response_class=HTMLResponse)
async def list_posts(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the posts listing page.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
Returns:
HTMLResponse with rendered posts list template.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
**context,
"posts": visible_posts,
"active_page": "posts",
"current_page": 1,
"has_prev": False,
"has_next": True,
},
)
@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", response_class=HTMLResponse)
async def create_post(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle new post creation form submission.
Args:
request: The HTTP request object containing form data.
user: Current user (required).
Returns:
HTMLResponse redirecting to the home page.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
"has_next": False,
},
)
@router.get("/posts/{post_id}", response_class=HTMLResponse)
async def post_detail(
request: Request,
post_id: str,
user: OptionalUserDep,
) -> 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.
user: Current user from dependency.
Returns:
HTMLResponse with rendered post detail template.
Raises:
HTTPException: If post not found or not visible to user.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
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_id}/edit", response_class=HTMLResponse)
async def edit_post_form(
request: Request,
post_id: str,
user: RequireUserDep,
) -> 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.
user: Current user (required).
Returns:
HTMLResponse with rendered post form template.
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
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_id}/edit", response_class=HTMLResponse)
async def update_post(
request: Request,
post_id: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle post update form submission.
Args:
request: The HTTP request object containing form data.
post_id: The unique identifier of the post to update.
user: Current user (required).
Returns:
HTMLResponse with rendered post detail template.
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
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_detail.html",
{
**context,
"post": post,
"active_page": "posts",
"can_edit": True,
"can_delete": can_delete_post(user, post.author_id),
},
)
@router.post("/posts/{post_id}/delete", response_class=HTMLResponse)
async def delete_post(
request: Request,
post_id: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle post deletion.
Args:
request: The HTTP request object.
post_id: The unique identifier of the post to delete.
user: Current user (required).
Returns:
HTMLResponse redirecting to the home page.
Raises:
HTTPException: If post not found or user cannot delete it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
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)
return templates.TemplateResponse(
request,
"pages/index.html",
{
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
"has_next": False,
},
)
@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.
"""
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>
"""
)