from __future__ import annotations from quart import ( make_response, g, Blueprint, abort, url_for, request, ) from .services.post_data import post_data from shared.infrastructure.data_client import fetch_data from shared.infrastructure.actions import call_action from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.infrastructure.fragments import fetch_fragment from shared.browser.app.redis_cacher import cache_page, clear_cache from .admin.routes import register as register_admin from shared.config import config from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response 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 shared.infrastructure.cart_identity import current_cart_identity db_post_id = (g.post_data.get("post") or {}).get("id") post_slug = (g.post_data.get("post") or {}).get("slug", "") # Fetch container nav from relations service container_nav = await fetch_fragment("relations", "container-nav", params={ "container_type": "page", "container_id": str(db_post_id), "post_slug": post_slug, }) ctx = { **p_data, "base_title": config()["title"], "container_nav": container_nav, } # Page cart badge via HTTP post_dict = p_data.get("post") or {} if post_dict.get("is_page"): ident = current_cart_identity() summary_params = {"page_slug": post_dict["slug"]} if ident["user_id"] is not None: summary_params["user_id"] = ident["user_id"] if ident["session_id"] is not None: summary_params["session_id"] = ident["session_id"] raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total) return ctx else: return {} @bp.get("/") @cache_page(tag="post.post_detail") async def post_detail(slug: str): from shared.sx.page import get_template_context from shared.sx.helpers import ( sx_call, root_header_sx, full_page_sx, oob_page_sx, post_header_sx, oob_header_sx, mobile_menu_sx, post_mobile_nav_sx, mobile_root_nav_sx, ) from shared.services.registry import services from shared.browser.app.csrf import generate_csrf_token from shared.utils import host_url tctx = await get_template_context() # Render post content via .sx defcomp post = tctx.get("post") or {} user = getattr(g, "user", None) rights = tctx.get("rights") or {} blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/") csrf = generate_csrf_token() svc = services.blog_page detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base) content = sx_call("blog-post-detail-content", **detail_data) meta_data = svc.post_meta_data(post, tctx.get("base_title", "")) meta = sx_call("blog-meta", **meta_data) if not is_htmx_request(): root_hdr = await root_header_sx(tctx) post_hdr = await post_header_sx(tctx) header_rows = "(<> " + root_hdr + " " + post_hdr + ")" menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)) html = await full_page_sx(tctx, header_rows=header_rows, content=content, meta=meta, menu=menu) return await make_response(html) else: root_hdr = await root_header_sx(tctx) post_hdr = await post_header_sx(tctx) rows = "(<> " + root_hdr + " " + post_hdr + ")" header_oob = await oob_header_sx("root-header-child", "post-header-child", rows) sx_src = await oob_page_sx(oobs=header_oob, content=content, menu= mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))) return sx_response(sx_src) @bp.post("/like/toggle/") @clear_cache(tag="post.post_detail", tag_scope="user") async def like_toggle(slug: str): from shared.utils import host_url from shared.sx.helpers import sx_call from shared.browser.app.csrf import generate_csrf_token like_url = host_url(url_for('blog.post.like_toggle', slug=slug)) csrf = generate_csrf_token() def _like_btn(liked): return sx_call("blog-like-toggle", like_url=like_url, hx_headers={"X-CSRFToken": csrf}, heart="\u2764\ufe0f" if liked else "\U0001f90d") if not g.user: return sx_response(_like_btn(False), status=403) post_id = g.post_data["post"]["id"] user_id = g.user.id result = await call_action("likes", "toggle", payload={ "user_id": user_id, "target_type": "post", "target_id": post_id, }) return sx_response(_like_btn(result["liked"])) @bp.get("/w//") async def widget_paginate(slug: str, widget_domain: str): """Proxies paginated widget requests to the appropriate fragment provider.""" page = int(request.args.get("page", 1)) post_id = g.post_data["post"]["id"] if widget_domain == "calendar": html = await fetch_fragment("events", "container-nav", params={ "container_type": "page", "container_id": str(post_id), "post_slug": slug, "page": str(page), "paginate_url": url_for( 'blog.post.widget_paginate', slug=slug, widget_domain='calendar', ), }) return await make_response(html or "") abort(404) return bp