Merge branch 'fix/post-detail-route' into dev: add missing post detail route
This commit is contained in:
@@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.entities import Page
|
||||
from app.models.entities import Page, Post
|
||||
from app.models.posts import PostSummary
|
||||
from app.services.contact import ContactService
|
||||
from app.services.hcaptcha import HCaptchaService
|
||||
@@ -117,6 +117,33 @@ def home(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/posts/{slug}",
|
||||
response_class=HTMLResponse,
|
||||
summary="Single blog post",
|
||||
)
|
||||
def post_detail(
|
||||
slug: str,
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
posts: PostService = Depends(get_post_service),
|
||||
) -> HTMLResponse:
|
||||
"""Render a single published post by slug.
|
||||
|
||||
Drafts and unknown slugs return 404 (same response so a mistyped
|
||||
URL cannot be used to enumerate unpublished titles).
|
||||
"""
|
||||
post: Post | None = posts.get_published_by_slug(slug)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/post.html",
|
||||
{"active_nav": "home", "post": post},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
|
||||
def about(
|
||||
request: Request,
|
||||
|
||||
@@ -21,9 +21,9 @@ from typing import Optional
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import PostStatus
|
||||
from app.models.entities import Post, PostStatus
|
||||
from app.models.posts import PostSummary
|
||||
from app.models.mappers import _parse_datetime
|
||||
from app.models.mappers import _parse_datetime, row_to_post
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
@@ -155,6 +155,36 @@ class PostService:
|
||||
self._cache.set(safe_limit, summaries)
|
||||
return summaries
|
||||
|
||||
def get_published_by_slug(self, slug: str) -> Optional[Post]:
|
||||
"""Return the published :class:`Post` for ``slug`` or ``None``.
|
||||
|
||||
Drafts are invisible on the public path: the status filter
|
||||
belongs in SQL so a mistyped slug and a draft slug are
|
||||
indistinguishable to the caller (same 404 upstream).
|
||||
|
||||
SQL safety: ``slug`` and ``status`` are bound parameters; no
|
||||
string interpolation.
|
||||
"""
|
||||
with self._engine.connect() as conn:
|
||||
row = (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts"
|
||||
" WHERE slug = :slug AND status = :status"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{
|
||||
"slug": slug,
|
||||
"status": PostStatus.PUBLISHED.value,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
return row_to_post(row) if row is not None else None
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached post-list entry.
|
||||
|
||||
|
||||
@@ -348,6 +348,17 @@ a:focus-visible {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.page-article__date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-sky-deep);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.page-article__back {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
/* Post list + card. */
|
||||
.post-list {
|
||||
display: grid;
|
||||
|
||||
36
app/templates/public/post.html
Normal file
36
app/templates/public/post.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
Single blog post detail page.
|
||||
|
||||
Receives:
|
||||
- post : app.models.entities.Post
|
||||
- active_nav : str "home"
|
||||
|
||||
``post.body_html_cached`` is the bleach-sanitized output of the
|
||||
Markdown pipeline (allowlisted tags/attrs/protocols only), so
|
||||
rendering with ``| safe`` does not reintroduce XSS risk. Same
|
||||
rationale as ``public/about.html``.
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}{{ post.title }} — a post from Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">{{ post.title }}</h1>
|
||||
{% if post.published_at %}
|
||||
<time class="page-article__date"
|
||||
datetime="{{ post.published_at.isoformat() }}">
|
||||
{{ post.published_at.strftime("%B %-d, %Y") }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{{ post.body_html_cached | safe }}
|
||||
</article>
|
||||
|
||||
<p class="page-article__back">
|
||||
<a href="/">← Back to all posts</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user