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 sqlalchemy import update from models.calendars import CalendarEntry 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 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 html = await render_template("_types/day/_main_panel.html") return await make_response(html, 200) @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