- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com - Remove same-origin guards from ~menu-row and ~nav-link htmx attrs - Convert ~app-layout from string-concatenated HTML to pure sexp tree - Extract ~app-head component, replace ~app-shell with inline structure - Convert hamburger SVG from Python HTML constant to ~hamburger sexp component - Fix cross-domain fragment URLs (events_url, market_url) - Fix starts-with? primitive to handle nil values - Fix duplicate admin menu rows on OOB swaps - Add calendar admin nav links (slots, description) - Convert slots page from Jinja to sexp rendering - Disable page caching in development mode - Backfill migration to clean orphaned container_relations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
7.8 KiB
Python
245 lines
7.8 KiB
Python
from __future__ import annotations
|
|
from datetime import datetime, timezone
|
|
|
|
from quart import (
|
|
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
|
)
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
from models.calendars import Calendar
|
|
|
|
from sqlalchemy.orm import selectinload, with_loader_criteria
|
|
from shared.browser.app.authz import require_admin
|
|
|
|
from .admin.routes import register as register_admin
|
|
from .services import get_visible_entries_for_period
|
|
from .services.calendar_view import (
|
|
parse_int_arg,
|
|
add_months,
|
|
build_calendar_weeks,
|
|
get_calendar_by_post_and_slug,
|
|
get_calendar_by_slug,
|
|
update_calendar_description,
|
|
)
|
|
from shared.browser.app.utils.htmx import is_htmx_request
|
|
|
|
from ..slots.routes import register as register_slots
|
|
|
|
from models.calendars import CalendarSlot
|
|
|
|
from bp.calendars.services.calendars import soft_delete
|
|
|
|
from bp.day.routes import register as register_day
|
|
|
|
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
|
|
|
from sqlalchemy import select
|
|
|
|
import calendar as pycalendar
|
|
|
|
|
|
def register():
|
|
bp = Blueprint("calendar", __name__, url_prefix='/<calendar_slug>')
|
|
|
|
bp.register_blueprint(
|
|
register_admin(),
|
|
)
|
|
bp.register_blueprint(
|
|
register_slots(),
|
|
)
|
|
bp.register_blueprint(
|
|
register_day()
|
|
)
|
|
|
|
@bp.url_value_preprocessor
|
|
def pull(endpoint, values):
|
|
g.calendar_slug = values.get("calendar_slug")
|
|
|
|
@bp.before_request
|
|
async def hydrate_calendar_data():
|
|
calendar_slug = getattr(g, "calendar_slug", None)
|
|
|
|
# Standalone mode (events app): no post context
|
|
post_data = getattr(g, "post_data", None)
|
|
if post_data:
|
|
post_id = (post_data.get("post") or {}).get("id")
|
|
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
|
|
else:
|
|
cal = await get_calendar_by_slug(g.s, calendar_slug)
|
|
|
|
if not cal:
|
|
abort(404)
|
|
return
|
|
|
|
g.calendar = cal
|
|
|
|
@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 ----------
|
|
|
|
|
|
# ---------- Pages ----------
|
|
|
|
@bp.get("/")
|
|
@cache_page(tag="calendars")
|
|
async def get(calendar_slug: str, **kwargs):
|
|
"""
|
|
Show a month-view calendar for this calendar.
|
|
|
|
- One month at a time
|
|
- Outer arrows: +/- 1 year
|
|
- Inner arrows: +/- 1 month
|
|
"""
|
|
|
|
# --- Determine year & month from query params ---
|
|
today = datetime.now(timezone.utc).date()
|
|
|
|
month = parse_int_arg("month")
|
|
year = parse_int_arg("year")
|
|
|
|
if year is None:
|
|
year = today.year
|
|
if month is None or not (1 <= month <= 12):
|
|
month = today.month
|
|
|
|
# --- Helpers to move between months ---
|
|
prev_month_year, prev_month = add_months(year, month, -1)
|
|
next_month_year, next_month = add_months(year, month, +1)
|
|
prev_year = year - 1
|
|
next_year = year + 1
|
|
|
|
# --- Build weeks grid (list of weeks, each week = 7 days) ---
|
|
weeks = build_calendar_weeks(year, month)
|
|
month_name = pycalendar.month_name[month]
|
|
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
|
|
|
|
# --- Period boundaries for this calendar view ---
|
|
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
|
|
next_y, next_m = add_months(year, month, +1)
|
|
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
|
|
|
|
# --- Identity & admin flag ---
|
|
user = getattr(g, "user", None)
|
|
session_id = qsession.get("calendar_sid")
|
|
|
|
visible = await get_visible_entries_for_period(
|
|
sess=g.s,
|
|
calendar_id=g.calendar.id,
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
user=user,
|
|
session_id=session_id,
|
|
)
|
|
|
|
month_entries = visible.merged_entries
|
|
user_entries = visible.user_entries
|
|
confirmed_entries = visible.confirmed_entries
|
|
|
|
from shared.sexp.page import get_template_context
|
|
from sexp.sexp_components import render_calendar_page, render_calendar_oob
|
|
|
|
tctx = await get_template_context()
|
|
tctx.update(dict(
|
|
qsession=qsession,
|
|
year=year, month=month, month_name=month_name,
|
|
weekday_names=weekday_names, weeks=weeks,
|
|
prev_month=prev_month, prev_month_year=prev_month_year,
|
|
next_month=next_month, next_month_year=next_month_year,
|
|
prev_year=prev_year, next_year=next_year,
|
|
user_entries=user_entries, confirmed_entries=confirmed_entries,
|
|
month_entries=month_entries,
|
|
))
|
|
if not is_htmx_request():
|
|
html = await render_calendar_page(tctx)
|
|
else:
|
|
html = await render_calendar_oob(tctx)
|
|
|
|
return await make_response(html)
|
|
|
|
|
|
@bp.put("/")
|
|
@require_admin
|
|
@clear_cache(tag="calendars", tag_scope="all")
|
|
async def put(calendar_slug: str, **kwargs):
|
|
"""
|
|
Idempotent update for calendar configuration.
|
|
Accepts HTMX form (POST/PUT) and optional JSON.
|
|
"""
|
|
# Try JSON first
|
|
data = await request.get_json(silent=True)
|
|
description = None
|
|
|
|
if data and isinstance(data, dict):
|
|
description = (data.get("description") or "").strip()
|
|
else:
|
|
form = await request.form
|
|
description = (form.get("description") or "").strip()
|
|
|
|
await update_calendar_description(g.calendar, description)
|
|
from shared.sexp.page import get_template_context
|
|
from sexp.sexp_components import _calendar_admin_main_panel_html
|
|
ctx = await get_template_context()
|
|
html = _calendar_admin_main_panel_html(ctx)
|
|
return await make_response(html, 200)
|
|
|
|
|
|
@bp.delete("/")
|
|
@require_admin
|
|
@clear_cache(tag="calendars", tag_scope="all")
|
|
async def delete(calendar_slug: str, **kwargs):
|
|
from shared.browser.app.utils.htmx import is_htmx_request
|
|
|
|
cal = g.calendar
|
|
cal.deleted_at = datetime.now(timezone.utc)
|
|
await g.s.flush()
|
|
|
|
# If we have post context (blog-embedded mode), update nav
|
|
post_data = getattr(g, "post_data", None)
|
|
from shared.sexp.page import get_template_context
|
|
from sexp.sexp_components import render_calendars_list_panel
|
|
ctx = await get_template_context()
|
|
html = render_calendars_list_panel(ctx)
|
|
|
|
if post_data:
|
|
from shared.services.entry_associations import get_associated_entries
|
|
from sexp.sexp_components import render_post_nav_entries_oob
|
|
|
|
post_id = (post_data.get("post") or {}).get("id")
|
|
cals = (
|
|
await g.s.execute(
|
|
select(Calendar)
|
|
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
|
|
.order_by(Calendar.name.asc())
|
|
)
|
|
).scalars().all()
|
|
|
|
associated_entries = await get_associated_entries(g.s, post_id)
|
|
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
|
html = html + nav_oob
|
|
|
|
return await make_response(html, 200)
|
|
|
|
|
|
return bp
|