Files
rose-ash/events/bp/calendar_entries/routes.py
giles 7419ecf3c0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Delete events sx_components.py — move all rendering to sxc/pages
Phase 7 of the zero-Python-rendering plan. All 100 rendering functions
move from events/sx/sx_components.py into events/sxc/pages/__init__.py.
Route handlers (15 files) import from sxc.pages instead.
load_service_components call moves into _load_events_page_files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:19:38 +00:00

293 lines
11 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from quart import (
request, 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 sxc.pages import render_day_main_panel
html = await 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):
from datetime import date as date_cls
from sqlalchemy import select as sa_select
from models.calendars import CalendarSlot as _CS
day_date = date_cls(year, month, day)
weekday_attr = ["mon","tue","wed","thu","fri","sat","sun"][day_date.weekday()]
stmt = sa_select(_CS).where(
_CS.calendar_id == g.calendar.id,
getattr(_CS, weekday_attr) == True,
_CS.deleted_at.is_(None),
).order_by(_CS.time_start.asc(), _CS.id.asc())
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
from sxc.pages import render_entry_add_form
return sx_response(await render_entry_add_form(g.calendar, day, month, year, day_slots))
@bp.get("/add-button/")
async def add_button(day: int, month: int, year: int, **kwargs):
from sxc.pages import render_entry_add_button
return sx_response(await render_entry_add_button(g.calendar, day, month, year))
return bp