from __future__ import annotations from quart import ( render_template, make_response, g, Blueprint, abort, url_for, ) from .services.post_data import post_data from .services.post_operations import toggle_post_like from models.calendars import Calendar from sqlalchemy import select from suma_browser.app.redis_cacher import cache_page, clear_cache from .admin.routes import register as register_admin from config import config from suma_browser.app.utils.htmx import is_htmx_request def register(): bp = Blueprint("post", __name__, url_prefix='/') bp.register_blueprint( register_admin() ) # Calendar blueprints now live in the events service. # Post pages link to events_url() instead of embedding calendars. @bp.url_value_preprocessor def pull_blog(endpoint, values): g.post_slug = values.get("slug") @bp.before_request async def hydrate_post_data(): slug = getattr(g, "post_slug", None) if not slug: return # not a blog route or no slug in this URL is_admin = bool((g.get("rights") or {}).get("admin")) # Always include drafts so we can check ownership below p_data = await post_data(slug, g.s, include_drafts=True) if not p_data: abort(404) return # Access control for draft posts if p_data["post"].get("status") != "published": if is_admin: pass # admin can see all drafts elif g.user and p_data["post"].get("user_id") == g.user.id: pass # author can see their own drafts else: abort(404) return g.post_data = p_data @bp.context_processor async def context(): p_data = getattr(g, "post_data", None) if p_data: from .services.entry_associations import get_associated_entries db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer calendars = ( await g.s.execute( select(Calendar) .where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None)) .order_by(Calendar.name.asc()) ) ).scalars().all() # Fetch associated entries for nav display associated_entries = await get_associated_entries(g.s, db_post_id) return { **p_data, "base_title": f"{config()['title']} {p_data['post']['title']}", "calendars": calendars, "associated_entries": associated_entries, } else: return {} @bp.get("/") @cache_page(tag="post.post_detail") async def post_detail(slug: str): # Determine which template to use based on request type if not is_htmx_request(): # Normal browser request: full page with layout html = await render_template("_types/post/index.html") else: # HTMX request: main panel + OOB elements html = await render_template("_types/post/_oob_elements.html") return await make_response(html) @bp.post("/like/toggle/") @clear_cache(tag="post.post_detail", tag_scope="user") async def like_toggle(slug: str): from utils import host_url # Get post_id from g.post_data if not g.user: html = await render_template( "_types/browse/like/button.html", slug=slug, liked=False, like_url=host_url(url_for('blog.post.like_toggle', slug=slug)), item_type='post', ) resp = make_response(html, 403) return resp post_id = g.post_data["post"]["id"] user_id = g.user.id liked, error = await toggle_post_like(g.s, user_id, post_id) if error: resp = make_response(error, 404) return resp html = await render_template( "_types/browse/like/button.html", slug=slug, liked=liked, like_url=host_url(url_for('blog.post.like_toggle', slug=slug)), item_type='post', ) return html @bp.get("/entries/") async def get_entries(slug: str): """Get paginated associated entries for infinite scroll in nav""" from .services.entry_associations import get_associated_entries from quart import request page = int(request.args.get("page", 1)) post_id = g.post_data["post"]["id"] result = await get_associated_entries(g.s, post_id, page=page, per_page=10) html = await render_template( "_types/post/_entry_items.html", entries=result["entries"], page=result["page"], has_more=result["has_more"], ) return await make_response(html) return bp