diff --git a/blog/sexp/sexp_components.py b/blog/sexp/sexp_components.py index 2a85485..8bbaa58 100644 --- a/blog/sexp/sexp_components.py +++ b/blog/sexp/sexp_components.py @@ -86,19 +86,21 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: ) # Admin link - from quart import url_for as qurl, g + from quart import url_for as qurl, g, request rights = ctx.get("rights") or {} has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) if has_admin: - hx_select = ctx.get("hx_select_search", "#main-panel") select_colours = ctx.get("select_colours", "") styles = ctx.get("styles") or {} nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "") admin_href = qurl("blog.post.admin.admin", slug=slug) + is_admin_page = "/admin" in request.path + sel_attr = ' aria-selected="true"' if is_admin_page else '' nav_parts.append( f'
' ) diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index d686ec2..1fc1fb8 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -77,9 +77,23 @@ def register(): @bp.context_processor async def inject_root(): + from shared.infrastructure.fragments import fetch_fragment + + container_nav_html = "" + post_data = getattr(g, "post_data", None) + if post_data: + post_id = post_data["post"]["id"] + post_slug = post_data["post"]["slug"] + container_nav_html = await fetch_fragment("relations", "container-nav", params={ + "container_type": "page", + "container_id": str(post_id), + "post_slug": post_slug, + "exclude": "page->calendar", + }) return { "calendar": getattr(g, "calendar", None), + "container_nav_html": container_nav_html, } # ---------- Pages ---------- diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index 3c64958..a9c0e51 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -44,16 +44,14 @@ def register(): @bp.get("/") async def get(**kwargs): + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_slots_page, render_slots_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/slots/index.html", - ) + html = await render_slots_page(tctx) else: - - html = await render_template( - "_types/slots/_oob_elements.html", - ) + html = await render_slots_oob(tctx) return await make_response(html) diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py index 949dcf0..e9ca07a 100644 --- a/events/sexp/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -79,34 +79,31 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: def _post_nav_html(ctx: dict) -> str: - """Post desktop nav: calendar links + admin gear.""" - from quart import url_for + """Post desktop nav: calendar links + container nav (markets, etc.).""" + from quart import url_for, g calendars = ctx.get("calendars") or [] - rights = ctx.get("rights") or {} - is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) - hx_select = ctx.get("hx_select_search", "#main-panel") select_colours = ctx.get("select_colours", "") - post = ctx.get("post") or {} - slug = post.get("slug", "") + current_cal_slug = getattr(g, "calendar_slug", None) parts = [] for cal in calendars: cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "") cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "") href = url_for("calendars.calendar.get", calendar_slug=cal_slug) + is_sel = (cal_slug == current_cal_slug) parts.append(sexp( - '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc)', + '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc :is-selected sel)', h=href, l=cal_name, sc=select_colours, + sel=is_sel, )) - if is_admin: - admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") - parts.append( - f'' - f'' - ) + # Container nav fragments (markets, etc.) + container_nav = ctx.get("container_nav_html", "") + if container_nav: + parts.append(container_nav) + return "".join(parts) @@ -114,21 +111,6 @@ def _post_nav_html(ctx: dict) -> str: # Post admin header # --------------------------------------------------------------------------- -def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: - """Build the post-admin-level header row.""" - post = ctx.get("post") or {} - slug = post.get("slug", "") - link_href = call_url(ctx, "blog_url", f"/{slug}/admin/") - - return sexp( - '(~menu-row :id "post-admin-row" :level 2' - ' :link-href lh :link-label "admin" :icon "fa fa-cog"' - ' :child-id "post-admin-header-child" :oob oob)', - lh=link_href, - oob=oob, - ) - - # --------------------------------------------------------------------------- # Calendars header # --------------------------------------------------------------------------- @@ -347,11 +329,30 @@ def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str: # --------------------------------------------------------------------------- def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str: - """Build calendar admin header row.""" + """Build calendar admin header row with nav links.""" + from quart import url_for + calendar = ctx.get("calendar") + cal_slug = getattr(calendar, "slug", "") if calendar else "" + select_colours = ctx.get("select_colours", "") + + nav_parts = [] + if cal_slug: + for endpoint, label in [ + ("calendars.calendar.slots.get", "slots"), + ("calendars.calendar.admin.calendar_description_edit", "description"), + ]: + href = url_for(endpoint, calendar_slug=cal_slug) + nav_parts.append(sexp( + '(~nav-link :href h :label l :select-colours sc)', + h=href, l=label, sc=select_colours, + )) + + nav_html = "".join(nav_parts) return sexp( '(~menu-row :id "calendar-admin-row" :level 4' ' :link-label "admin" :icon "fa fa-cog"' - ' :child-id "calendar-admin-header-child" :oob oob)', + ' :nav-html nh :child-id "calendar-admin-header-child" :oob oob)', + nh=nav_html, oob=oob, ) @@ -1659,7 +1660,7 @@ async def render_calendars_page(ctx: dict) -> str: """Full page: calendars listing.""" content = _calendars_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _calendars_header_html(ctx) + child = _post_header_html(ctx) + _calendars_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1667,8 +1668,8 @@ async def render_calendars_page(ctx: dict) -> str: async def render_calendars_oob(ctx: dict) -> str: """OOB response: calendars listing.""" content = _calendars_main_panel_html(ctx) - oobs = _post_admin_header_html(ctx, oob=True) - oobs += _oob_header_html("post-admin-header-child", "calendars-header-child", + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "calendars-header-child", _calendars_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1681,7 +1682,7 @@ async def render_calendar_page(ctx: dict) -> str: """Full page: calendar month view.""" content = _calendar_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _calendar_header_html(ctx) + child = _post_header_html(ctx) + _calendar_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1703,7 +1704,7 @@ async def render_day_page(ctx: dict) -> str: """Full page: day detail.""" content = _day_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1726,7 +1727,7 @@ async def render_day_admin_page(ctx: dict) -> str: """Full page: day admin.""" content = _day_admin_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx) + _day_admin_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) @@ -1750,7 +1751,7 @@ async def render_calendar_admin_page(ctx: dict) -> str: """Full page: calendar admin.""" content = _calendar_admin_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1765,6 +1766,32 @@ async def render_calendar_admin_oob(ctx: dict) -> str: return oob_page(ctx, oobs_html=oobs, content_html=content) +# --------------------------------------------------------------------------- +# Slots +# --------------------------------------------------------------------------- + +async def render_slots_page(ctx: dict) -> str: + """Full page: slots listing.""" + from quart import g + slots = ctx.get("slots") or [] + calendar = ctx.get("calendar") + content = render_slots_table(slots, calendar) + hdr = root_header_html(ctx) + child = (_post_header_html(ctx) + + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)) + hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_slots_oob(ctx: dict) -> str: + """OOB response: slots listing.""" + slots = ctx.get("slots") or [] + calendar = ctx.get("calendar") + content = render_slots_table(slots, calendar) + oobs = _calendar_admin_header_html(ctx, oob=True) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + # --------------------------------------------------------------------------- # Tickets # --------------------------------------------------------------------------- @@ -1820,7 +1847,7 @@ async def render_markets_page(ctx: dict) -> str: """Full page: markets listing.""" content = _markets_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _markets_header_html(ctx) + child = _post_header_html(ctx) + _markets_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1828,8 +1855,8 @@ async def render_markets_page(ctx: dict) -> str: async def render_markets_oob(ctx: dict) -> str: """OOB response: markets listing.""" content = _markets_main_panel_html(ctx) - oobs = _post_admin_header_html(ctx, oob=True) - oobs += _oob_header_html("post-admin-header-child", "markets-header-child", + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "markets-header-child", _markets_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1842,7 +1869,7 @@ async def render_payments_page(ctx: dict) -> str: """Full page: payments admin.""" content = _payments_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _payments_header_html(ctx) + child = _post_header_html(ctx) + _payments_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1850,8 +1877,8 @@ async def render_payments_page(ctx: dict) -> str: async def render_payments_oob(ctx: dict) -> str: """OOB response: payments admin.""" content = _payments_main_panel_html(ctx) - oobs = _post_admin_header_html(ctx, oob=True) - oobs += _oob_header_html("post-admin-header-child", "payments-header-child", + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "payments-header-child", _payments_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -2322,7 +2349,7 @@ async def render_entry_page(ctx: dict) -> str: """Full page: entry detail.""" content = _entry_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx) + _entry_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) diff --git a/orders/sexp/sexp_components.py b/orders/sexp/sexp_components.py index fc66526..058266f 100644 --- a/orders/sexp/sexp_components.py +++ b/orders/sexp/sexp_components.py @@ -14,7 +14,6 @@ from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) -from shared.sexp.page import HAMBURGER_HTML from shared.infrastructure.urls import market_product_url, cart_url diff --git a/relations/alembic/versions/0003_backfill_calendar_metadata.py b/relations/alembic/versions/0003_backfill_calendar_metadata.py new file mode 100644 index 0000000..c1af99f --- /dev/null +++ b/relations/alembic/versions/0003_backfill_calendar_metadata.py @@ -0,0 +1,30 @@ +"""Backfill label + metadata for calendar relations. + +Removes calendar relations with no metadata slug and no label, +which are either orphaned or were created before metadata was tracked. + +Revision ID: relations_0003 +Revises: relations_0002 +Create Date: 2026-02-28 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "relations_0003" +down_revision = "relations_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + DELETE FROM container_relations + WHERE child_type IN ('calendar', 'market') + AND (metadata IS NULL OR metadata->>'slug' IS NULL) + AND label IS NULL + """) + + +def downgrade() -> None: + pass diff --git a/relations/bp/fragments/routes.py b/relations/bp/fragments/routes.py index 2d2d9f8..d323720 100644 --- a/relations/bp/fragments/routes.py +++ b/relations/bp/fragments/routes.py @@ -43,6 +43,12 @@ def register(): from shared.sexp.jinja_bridge import sexp as render_sexp from shared.sexp.relations import relations_from from shared.services.relationships import get_children + from shared.infrastructure.urls import events_url, market_url + + _SERVICE_URL = { + "calendar": events_url, + "market": market_url, + } container_type = request.args.get("container_type", "page") container_id = int(request.args.get("container_id", 0)) @@ -70,7 +76,17 @@ def register(): ) for child in children: slug = (child.metadata_ or {}).get("slug", "") - href = f"/{post_slug}/{slug}/" if post_slug else f"/{slug}/" + if not slug: + continue + nav_label = defn.nav_label or "" + if post_slug and nav_label: + path = f"/{post_slug}/{nav_label}/{slug}/" + elif post_slug: + path = f"/{post_slug}/{slug}/" + else: + path = f"/{slug}/" + url_fn = _SERVICE_URL.get(defn.to_type) + href = url_fn(path) if url_fn else path parts.append(render_sexp( '(~relation-nav :href href :name name :icon icon :nav-class nav-class :relation-type relation-type)', href=href, diff --git a/shared/browser/app/redis_cacher.py b/shared/browser/app/redis_cacher.py index 154d410..571110d 100644 --- a/shared/browser/app/redis_cacher.py +++ b/shared/browser/app/redis_cacher.py @@ -187,7 +187,7 @@ def cache_page( async def wrapper(*args, **kwargs): r = get_redis() - if not r or request.method != "GET": + if not r or request.method != "GET" or current_app.config.get("NO_PAGE_CACHE"): return await view(*args, **kwargs) uid = get_user_id() diff --git a/shared/browser/templates/_types/root/_head.html b/shared/browser/templates/_types/root/_head.html index 26a487b..a28d178 100644 --- a/shared/browser/templates/_types/root/_head.html +++ b/shared/browser/templates/_types/root/_head.html @@ -6,6 +6,7 @@ + diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index e6ab90a..280122d 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -102,6 +102,10 @@ def create_base_app( # Cache app prefix for key namespacing app.config["CACHE_APP_PREFIX"] = name + # Disable page caching in development + env = os.getenv("ENVIRONMENT", "development") + app.config["NO_PAGE_CACHE"] = env not in ("production", "staging") + # --- infrastructure --- register_middleware(app) register_db(app) @@ -276,6 +280,15 @@ def create_base_app( response.delete_cookie("blog_session", domain=".rose-ash.com", path="/") return response + @app.after_request + async def _cors_for_subdomains(response): + origin = request.headers.get("Origin", "") + if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"): + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Headers"] = "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, HX-Boosted, Content-Type" + return response + @app.after_request async def _add_hx_preserve_search_header(response): value = request.headers.get("X-Search") diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index 66b9216..257a500 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Any from .jinja_bridge import sexp -from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP +from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP def call_url(ctx: dict, key: str, path: str = "/") -> str: @@ -34,14 +34,13 @@ def root_header_html(ctx: dict, *, oob: bool = False) -> str: return sexp( '(~header-row :cart-mini-html cmi :blog-url bu :site-title st' ' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph' - ' :hamburger-html hh :oob oob)', + ' :oob oob)', cmi=ctx.get("cart_mini_html", ""), bu=call_url(ctx, "blog_url", ""), st=ctx.get("base_title", ""), nth=ctx.get("nav_tree_html", ""), amh=ctx.get("auth_menu_html", ""), nph=ctx.get("nav_panel_html", ""), - hh=HAMBURGER_HTML, oob=oob, ) diff --git a/shared/sexp/page.py b/shared/sexp/page.py index fafddfa..a0b0dee 100644 --- a/shared/sexp/page.py +++ b/shared/sexp/page.py @@ -30,19 +30,6 @@ from typing import Any from .jinja_bridge import sexp -# HTML constants used by layout components — kept here to avoid -# s-expression parser issues with embedded quotes in SVG. -HAMBURGER_HTML = ( - '