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 shared.sx.helpers import sx_response 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='/') 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 = "" post_data = getattr(g, "post_data", None) if post_data: post_id = post_data["post"]["id"] post_slug = post_data["post"]["slug"] container_nav = 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": container_nav, } # ---------- 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.sx.page import get_template_context from sx.sx_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) return await make_response(html) else: sx_src = await render_calendar_oob(tctx) return sx_response(sx_src) @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.sx.page import get_template_context from sx.sx_components import _calendar_admin_main_panel_html ctx = await get_template_context() html = _calendar_admin_main_panel_html(ctx) return sx_response(html) @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.sx.page import get_template_context from sx.sx_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 sx.sx_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 sx_response(html) return bp