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 suma_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 suma_browser.app.utils.htmx import is_htmx_request from ..slots.routes import register as register_slots from models.calendars import CalendarSlot from suma_browser.app.bp.calendars.services.calendars import soft_delete from suma_browser.app.bp.day.routes import register as register_day from suma_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(): return { "calendar": getattr(g, "calendar", None), } # ---------- 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 if not is_htmx_request(): # Normal browser request: full page with layout html = await render_template( "_types/calendar/index.html", 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, ) else: html = await render_template( "_types/calendar/_oob_elements.html", 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, ) 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) html = await render_template("_types/calendar/admin/index.html") 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 suma_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) html = await render_template("_types/calendars/index.html") if post_data: from ..post.services.entry_associations import get_associated_entries post_id = (post_data.get("post") or {}).get("id") cals = ( await g.s.execute( select(Calendar) .where(Calendar.post_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 = await render_template( "_types/post/admin/_nav_entries_oob.html", associated_entries=associated_entries, calendars=cals, post=post_data["post"], ) html = html + nav_oob return await make_response(html, 200) return bp