"""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", }, )