from __future__ import annotations from sqlalchemy import select, update from models.calendars import CalendarEntry, CalendarSlot from shared.browser.app.authz import require_admin from shared.browser.app.redis_cacher import clear_cache from sqlalchemy import select from quart import ( request, render_template, make_response, Blueprint, g, jsonify ) from ..calendar_entries.services.entries import ( svc_update_entry, CalendarError, # <-- add this if you want to catch it explicitly ) from .services.post_associations import ( add_post_to_entry, remove_post_from_entry, get_entry_posts, search_posts as svc_search_posts, ) from datetime import datetime, timezone import math import logging from shared.infrastructure.fragments import fetch_fragment from shared.sx.helpers import sx_response from ..ticket_types.routes import register as register_ticket_types from .admin.routes import register as register_admin logger = logging.getLogger(__name__) def register(): bp = Blueprint("calendar_entry", __name__, url_prefix='/') # Register tickets blueprint bp.register_blueprint( register_ticket_types() ) bp.register_blueprint( register_admin() ) @bp.before_request async def load_entry(): """Load the calendar entry from the URL parameter.""" from quart import abort entry_id = request.view_args.get("entry_id") if entry_id: result = await g.s.execute( select(CalendarEntry) .where( CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None) ) ) g.entry = result.scalar_one_or_none() if g.entry is None: abort(404) @bp.context_processor async def inject_entry(): """Make entry and date parameters available to all templates in this blueprint.""" return { "entry": getattr(g, "entry", None), "year": request.view_args.get("year"), "month": request.view_args.get("month"), "day": request.view_args.get("day"), } async def get_day_nav_oob(year: int, month: int, day: int): """Helper to generate OOB update for day entries nav""" from datetime import datetime, timezone, date, timedelta from ..calendar.services import get_visible_entries_for_period from quart import session as qsession # Get the calendar from g calendar = getattr(g, "calendar", None) if not calendar: return "" # Build day date try: day_date = date(year, month, day) except (ValueError, TypeError): return "" # Period: this day only period_start = datetime(year, month, day, tzinfo=timezone.utc) period_end = period_start + timedelta(days=1) # Identity user = getattr(g, "user", None) session_id = qsession.get("calendar_sid") # Get confirmed entries for this day visible = await get_visible_entries_for_period( sess=g.s, calendar_id=calendar.id, period_start=period_start, period_end=period_end, user=user, session_id=session_id, ) # Render OOB nav from sx.sx_components import render_day_entries_nav_oob return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) async def get_post_nav_oob(entry_id: int): """Helper to generate OOB update for post entries nav when entry state changes""" # Get the entry to find associated posts entry = await g.s.scalar( select(CalendarEntry).where( CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None) ) ) if not entry: return "" # Get all posts associated with this entry from .services.post_associations import get_entry_posts entry_posts = await get_entry_posts(g.s, entry_id) # Generate OOB updates for each post's nav nav_oobs = [] for post in entry_posts: # Get associated entries for this post from shared.services.entry_associations import get_associated_entries associated_entries = await get_associated_entries(g.s, post.id) # Load calendars for this post from models.calendars import Calendar calendars = ( 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() # Render OOB nav for this post from sx.sx_components import render_post_nav_entries_oob nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post) nav_oobs.append(nav_oob) return "".join(nav_oobs) @bp.context_processor async def inject_root(): from ..tickets.services.tickets import ( get_available_ticket_count, get_sold_ticket_count, get_user_reserved_count, ) from shared.infrastructure.cart_identity import current_cart_identity from sqlalchemy.orm import selectinload view_args = getattr(request, "view_args", {}) or {} entry_id = view_args.get("entry_id") calendar_entry = None entry_posts = [] ticket_remaining = None ticket_sold_count = 0 user_ticket_count = 0 user_ticket_counts_by_type = {} stmt = ( select(CalendarEntry) .where( CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None), ) .options(selectinload(CalendarEntry.ticket_types)) ) result = await g.s.execute(stmt) calendar_entry = result.scalar_one_or_none() # Optional: also ensure it belongs to the current calendar, if g.calendar is set if calendar_entry is not None and getattr(g, "calendar", None): if calendar_entry.calendar_id != g.calendar.id: calendar_entry = None # Refresh slot relationship if we have a valid entry if calendar_entry is not None: await g.s.refresh(calendar_entry, ['slot']) # Fetch associated posts entry_posts = await get_entry_posts(g.s, calendar_entry.id) # Get ticket availability ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id) # Get sold count ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id) # Get current user's reserved count ident = current_cart_identity() user_ticket_count = await get_user_reserved_count( g.s, calendar_entry.id, user_id=ident["user_id"], session_id=ident["session_id"], ) # Per-type counts for multi-type entries if calendar_entry.ticket_types: for tt in calendar_entry.ticket_types: if tt.deleted_at is None: user_ticket_counts_by_type[tt.id] = await get_user_reserved_count( g.s, calendar_entry.id, user_id=ident["user_id"], session_id=ident["session_id"], ticket_type_id=tt.id, ) # Fetch container nav from relations (exclude calendar — we're on a calendar page) 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 { "entry": calendar_entry, "entry_posts": entry_posts, "ticket_remaining": ticket_remaining, "ticket_sold_count": ticket_sold_count, "user_ticket_count": user_ticket_count, "user_ticket_counts_by_type": user_ticket_counts_by_type, "container_nav": container_nav, } @bp.get("/") @require_admin async def get(entry_id: int, **rest): from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.page import get_template_context from sx.sx_components import render_entry_page, render_entry_oob tctx = await get_template_context() if not is_htmx_request(): html = await render_entry_page(tctx) return await make_response(html, 200) else: sx_src = await render_entry_oob(tctx) return sx_response(sx_src) @bp.get("/edit/") @require_admin async def get_edit(entry_id: int, **rest): html = await render_template("_types/entry/_edit.html") return await make_response(html, 200) @bp.put("/") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def put(year: int, month: int, day: int, entry_id: int, **rest): form = await request.form def parse_time_to_dt(value: str | None, year: int, month: int, day: int): """ 'HH:MM' + (year, month, day) -> aware datetime in UTC. Returns None if empty/invalid. """ if not value: return None try: hour_str, minute_str = value.split(":", 1) hour = int(hour_str) minute = int(minute_str) return datetime(year, month, day, hour, minute, tzinfo=timezone.utc) except Exception: return None name = (form.get("name") or "").strip() start_at = parse_time_to_dt(form.get("start_at"), year, month, day) end_at = parse_time_to_dt(form.get("end_at"), year, month, day) # NEW: slot_id slot_id_raw = (form.get("slot_id") or "").strip() slot_id = int(slot_id_raw) if slot_id_raw else None # Ticket configuration ticket_price_str = (form.get("ticket_price") or "").strip() ticket_price = None if ticket_price_str: try: from decimal import Decimal ticket_price = Decimal(ticket_price_str) except Exception: pass # Will be validated below if needed ticket_count_str = (form.get("ticket_count") or "").strip() ticket_count = None if ticket_count_str: try: ticket_count = int(ticket_count_str) except Exception: pass # Will be validated below if needed field_errors: dict[str, list[str]] = {} # --- Basic validation (slot-style) ------------------------- if not name: field_errors.setdefault("name", []).append( "Please enter a name for the entry." ) # Check slot first before validating times slot = None if slot_id is not None: result = await g.s.execute( select(CalendarSlot).where( CalendarSlot.id == slot_id, CalendarSlot.calendar_id == g.calendar.id, CalendarSlot.deleted_at.is_(None), ) ) slot = result.scalar_one_or_none() if slot is None: field_errors.setdefault("slot_id", []).append( "Selected slot is no longer available." ) else: # For inflexible slots, override the times with slot times if not slot.flexible: # Replace start/end with slot times start_at = datetime(year, month, day, slot.time_start.hour, slot.time_start.minute, tzinfo=timezone.utc) if slot.time_end: end_at = datetime(year, month, day, slot.time_end.hour, slot.time_end.minute, tzinfo=timezone.utc) else: # Flexible: validate times are within slot band # Only validate if times were provided if not start_at: field_errors.setdefault("start_at", []).append( "Please select a start time." ) if not end_at: field_errors.setdefault("end_at", []).append( "Please select an end time." ) if start_at and end_at: s_time = start_at.timetz() e_time = end_at.timetz() slot_start = slot.time_start slot_end = slot.time_end if s_time.replace(tzinfo=None) < slot_start: field_errors.setdefault("start_at", []).append( f"Start time must be at or after {slot_start.strftime('%H:%M')}." ) if slot_end is not None and e_time.replace(tzinfo=None) > slot_end: field_errors.setdefault("end_at", []).append( f"End time must be at or before {slot_end.strftime('%H:%M')}." ) else: field_errors.setdefault("slot_id", []).append( "Please select a slot." ) # Time ordering check (only if we have times and no slot override) if start_at and end_at and end_at < start_at: field_errors.setdefault("end_at", []).append( "End time must be after the start time." ) if field_errors: return jsonify( { "message": "Please fix the highlighted fields.", "errors": field_errors, } ), 422 # --- Service call & safety net for extra validation ------- try: entry = await svc_update_entry( g.s, entry_id, name=name, start_at=start_at, end_at=end_at, slot_id=slot_id, # Pass slot_id to service ) # Update ticket configuration entry.ticket_price = ticket_price entry.ticket_count = ticket_count except CalendarError as e: # If the service still finds something wrong, surface it nicely. msg = str(e) return jsonify( { "message": "There was a problem updating the entry.", "errors": {"__all__": [msg]}, } ), 422 # --- Success: re-render the entry block ------------------- # Get nav OOB update nav_oob = await get_day_nav_oob(year, month, day) from shared.sx.page import get_template_context from sx.sx_components import render_entry_page tctx = await get_template_context() html = await render_entry_page(tctx) return sx_response(html + nav_oob) @bp.post("/confirm/") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def confirm_entry(entry_id: int, year: int, month: int, day: int, **rest): await g.s.execute( update(CalendarEntry) .where( CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "provisional", ) .values(state="confirmed") ) await g.s.flush() # Get nav OOB updates (both day and post navs) day_nav_oob = await get_day_nav_oob(year, month, day) post_nav_oob = await get_post_nav_oob(entry_id) # Re-read entry to get updated state await g.s.refresh(g.entry) from sx.sx_components import render_entry_optioned html = render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @bp.post("/decline/") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def decline_entry(entry_id: int, year: int, month: int, day: int, **rest): await g.s.execute( update(CalendarEntry) .where( CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "provisional", ) .values(state="declined") ) await g.s.flush() # Get nav OOB updates (both day and post navs) day_nav_oob = await get_day_nav_oob(year, month, day) post_nav_oob = await get_post_nav_oob(entry_id) # Re-read entry to get updated state await g.s.refresh(g.entry) from sx.sx_components import render_entry_optioned html = render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @bp.post("/provisional/") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def provisional_entry(entry_id: int, year: int, month: int, day: int, **rest): await g.s.execute( update(CalendarEntry) .where( CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "confirmed", ) .values(state="provisional") ) await g.s.flush() # Get nav OOB updates (both day and post navs) day_nav_oob = await get_day_nav_oob(year, month, day) post_nav_oob = await get_post_nav_oob(entry_id) # Re-read entry to get updated state await g.s.refresh(g.entry) from sx.sx_components import render_entry_optioned html = render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @bp.post("/tickets/") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def update_tickets(entry_id: int, **rest): """Update ticket configuration for a calendar entry""" from .services.ticket_operations import update_ticket_config from decimal import Decimal form = await request.form # Parse ticket price ticket_price_str = (form.get("ticket_price") or "").strip() ticket_price = None if ticket_price_str: try: ticket_price = Decimal(ticket_price_str) except Exception: return await make_response("Invalid ticket price", 400) # Parse ticket count ticket_count_str = (form.get("ticket_count") or "").strip() ticket_count = None if ticket_count_str: try: ticket_count = int(ticket_count_str) except Exception: return await make_response("Invalid ticket count", 400) # Update ticket configuration success, error = await update_ticket_config( g.s, entry_id, ticket_price, ticket_count ) if not success: return await make_response(error, 400) await g.s.flush() # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...") await g.s.refresh(g.entry) from sx.sx_components import render_entry_tickets_config html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year")) return sx_response(html) @bp.get("/posts/search/") @require_admin async def search_posts(entry_id: int, **rest): """Search for posts to associate with this entry""" query = request.args.get("q", "").strip() page = int(request.args.get("page", 1)) per_page = 10 search_posts, total = await svc_search_posts(g.s, query, page, per_page) total_pages = math.ceil(total / per_page) if total > 0 else 0 html = await render_template( "_types/entry/_post_search_results.html", search_posts=search_posts, search_query=query, page=page, total_pages=total_pages, ) return await make_response(html, 200) @bp.post("/posts/") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def add_post(entry_id: int, **rest): """Add a post association to this entry""" form = await request.form post_id = form.get("post_id") if not post_id: return await make_response("Post ID is required", 400) try: post_id = int(post_id) except ValueError: return await make_response("Invalid post ID", 400) success, error = await add_post_to_entry(g.s, entry_id, post_id) if not success: return await make_response(error, 400) await g.s.flush() # Reload entry_posts for nav update entry_posts = await get_entry_posts(g.s, entry_id) # Return updated posts list + OOB nav update from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob va = request.view_args or {} html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) nav_oob = render_entry_posts_nav_oob(entry_posts) return sx_response(html + nav_oob) @bp.delete("/posts//") @require_admin @clear_cache(tag="calendars", tag_scope="all") async def remove_post(entry_id: int, post_id: int, **rest): """Remove a post association from this entry""" success, error = await remove_post_from_entry(g.s, entry_id, post_id) if not success: return await make_response(error or "Association not found", 404) await g.s.flush() # Reload entry_posts for nav update entry_posts = await get_entry_posts(g.s, entry_id) # Return updated posts list + OOB nav update from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob va = request.view_args or {} html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) nav_oob = render_entry_posts_nav_oob(entry_posts) return sx_response(html + nav_oob) return bp