Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Extract events/calendar functionality into standalone microservice: - app.py and events_api.py from apps/events/ - Calendar blueprints (calendars, calendar, calendar_entries, calendar_entry, day, slots, slot, ticket_types, ticket_type) - Templates for all calendar/event views including admin - Dockerfile (APP_MODULE=app:app, IMAGE=events) - entrypoint.sh (no Alembic - migrations managed by blog app) - Gitea CI workflow for build and deploy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
8.0 KiB
Python
227 lines
8.0 KiB
Python
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 suma_browser.app.authz import require_admin
|
|
from suma_browser.app.redis_cacher import clear_cache
|
|
|
|
|
|
from suma_browser.app.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
|