from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from quart import ( request, render_template, make_response, Blueprint, g, redirect, url_for, jsonify, ) from .services.entries import ( add_entry as svc_add_entry, ) from shared.browser.app.authz import require_admin from shared.browser.app.redis_cacher import clear_cache from shared.sx.helpers import sx_response from bp.calendar_entry.routes import register as register_calendar_entry from models.calendars import CalendarSlot from sqlalchemy import select def calculate_entry_cost(slot: CalendarSlot, start_at: datetime, end_at: datetime) -> Decimal: """ Calculate cost for an entry based on slot and time range. - Fixed slot: use slot cost - Flexible slot: prorate based on actual time vs slot time range """ if not slot.cost: return Decimal('0') if not slot.flexible: # Fixed slot: full cost return Decimal(str(slot.cost)) # Flexible slot: calculate ratio if not slot.time_end or not start_at or not end_at: return Decimal('0') # Calculate durations in minutes slot_start_minutes = slot.time_start.hour * 60 + slot.time_start.minute slot_end_minutes = slot.time_end.hour * 60 + slot.time_end.minute slot_duration = slot_end_minutes - slot_start_minutes actual_start_minutes = start_at.hour * 60 + start_at.minute actual_end_minutes = end_at.hour * 60 + end_at.minute actual_duration = actual_end_minutes - actual_start_minutes if slot_duration <= 0 or actual_duration <= 0: return Decimal('0') ratio = Decimal(actual_duration) / Decimal(slot_duration) return Decimal(str(slot.cost)) * ratio def register(): bp = Blueprint("calendar_entries", __name__, url_prefix='/entries') bp.register_blueprint( register_calendar_entry() ) @bp.post("/") @clear_cache(tag="calendars", tag_scope="all") async def add_entry(year: int, month: int, day: int, **kwargs): form = await request.form def parse_time_to_dt(value: str | None, year: int, month: int, day: int): 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_time"), year, month, day) end_at = parse_time_to_dt(form.get("end_time"), 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: ticket_price = Decimal(ticket_price_str) except Exception: pass 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 field_errors: dict[str, list[str]] = {} # Basic checks if not name: field_errors.setdefault("name", []).append("Please enter a name for the entry.") # Check slot first before validating times slot = None cost = Decimal('10') # default cost 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_time", []).append("Please select a start time.") if end_at is None: field_errors.setdefault("end_time", []).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_time", []).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_time", []).append( f"End time must be at or before {slot_end.strftime('%H:%M')}." ) # Calculate cost based on slot and times if start_at and end_at: cost = calculate_entry_cost(slot, start_at, end_at) else: field_errors.setdefault("slot_id", []).append( "Please select a slot." ) # Time ordering check (only if we have times) if start_at and end_at and end_at < start_at: field_errors.setdefault("end_time", []).append("End time must be after the start time.") if field_errors: return jsonify( { "message": "Please fix the highlighted fields.", "errors": field_errors, } ), 422 # Pass slot_id and calculated cost to the service entry = await svc_add_entry( g.s, calendar_id=g.calendar.id, name=name, start_at=start_at, end_at=end_at, user_id=getattr(g, "user", None).id if getattr(g, "user", None) else None, session_id=None, slot_id=slot_id, cost=cost, # Pass calculated cost ) # Set ticket configuration entry.ticket_price = ticket_price entry.ticket_count = ticket_count # Commit so cross-service calls see the new entry await g.tx.commit() g.tx = await g.s.begin() from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.fragments import fetch_fragment ident = current_cart_identity() frag_params = {"oob": "1"} if ident["user_id"] is not None: frag_params["user_id"] = str(ident["user_id"]) if ident["session_id"] is not None: frag_params["session_id"] = ident["session_id"] # Re-query day entries for the sx component from datetime import date as date_cls, timedelta from bp.calendar.services import get_visible_entries_for_period from quart import session as qsession period_start = datetime(year, month, day, tzinfo=timezone.utc) period_end = period_start + timedelta(days=1) 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, ) # Query day slots for this weekday day_date = date_cls(year, month, day) weekday_attr = ["mon","tue","wed","thu","fri","sat","sun"][day_date.weekday()] stmt = select(CalendarSlot).where( CalendarSlot.calendar_id == g.calendar.id, getattr(CalendarSlot, weekday_attr) == True, CalendarSlot.deleted_at.is_(None), ).order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc()) result = await g.s.execute(stmt) day_slots = list(result.scalars()) styles = getattr(g, "styles", None) or {} ctx = { "calendar": g.calendar, "day_entries": visible.merged_entries, "day": day, "month": month, "year": year, "hx_select_search": "#main-panel", "styles": styles, } from sx.sx_components import render_day_main_panel html = render_day_main_panel(ctx) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return sx_response(html + (mini_html or "")) @bp.get("/add/") async def add_form(day: int, month: int, year: int, **kwargs): html = await render_template( "_types/day/_add.html", ) return await make_response(html) @bp.get("/add-button/") async def add_button(day: int, month: int, year: int, **kwargs): html = await render_template( "_types/day/_add_button.html", ) return await make_response(html) return bp