Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
6
events/bp/__init__.py
Normal file
6
events/bp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .all_events.routes import register as register_all_events
|
||||
from .calendars.routes import register as register_calendars
|
||||
from .markets.routes import register as register_markets
|
||||
from .payments.routes import register as register_payments
|
||||
from .page.routes import register as register_page
|
||||
from .fragments import register_fragments
|
||||
0
events/bp/all_events/__init__.py
Normal file
0
events/bp/all_events/__init__.py
Normal file
143
events/bp/all_events/routes.py
Normal file
143
events/bp/all_events/routes.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
All-events blueprint — shows upcoming events across ALL pages' calendars.
|
||||
|
||||
Mounted at / (root of events app). No slug context — works independently
|
||||
of the post/slug machinery.
|
||||
|
||||
Routes:
|
||||
GET / — full page with first page of entries
|
||||
GET /all-entries — HTMX fragment for infinite scroll
|
||||
POST /all-tickets/adjust — adjust ticket quantity inline
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, render_template_string, make_response
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("all_events", __name__)
|
||||
|
||||
async def _load_entries(page, per_page=20):
|
||||
"""Load all upcoming entries + pending ticket counts + page info."""
|
||||
entries, has_more = await services.calendar.upcoming_entries_for_container(
|
||||
g.s, page=page, per_page=per_page,
|
||||
)
|
||||
|
||||
# Pending ticket counts keyed by entry_id
|
||||
ident = current_cart_identity()
|
||||
pending_tickets = {}
|
||||
if entries:
|
||||
tickets = await services.calendar.pending_tickets(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
for t in tickets:
|
||||
if t.entry_id is not None:
|
||||
pending_tickets[t.entry_id] = pending_tickets.get(t.entry_id, 0) + 1
|
||||
|
||||
# Batch-load page info for container_ids
|
||||
page_info = {} # {post_id: {title, slug}}
|
||||
if entries:
|
||||
post_ids = list({
|
||||
e.calendar_container_id
|
||||
for e in entries
|
||||
if e.calendar_container_type == "page" and e.calendar_container_id
|
||||
})
|
||||
if post_ids:
|
||||
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
|
||||
for p in posts:
|
||||
page_info[p.id] = {"title": p.title, "slug": p.slug}
|
||||
|
||||
return entries, has_more, pending_tickets, page_info
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
view = request.args.get("view", "list")
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
||||
|
||||
ctx = dict(
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
pending_tickets=pending_tickets,
|
||||
page_info=page_info,
|
||||
page=page,
|
||||
view=view,
|
||||
)
|
||||
|
||||
if is_htmx_request():
|
||||
html = await render_template("_types/all_events/_main_panel.html", **ctx)
|
||||
else:
|
||||
html = await render_template("_types/all_events/index.html", **ctx)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/all-entries")
|
||||
async def entries_fragment():
|
||||
view = request.args.get("view", "list")
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
||||
|
||||
html = await render_template(
|
||||
"_types/all_events/_cards.html",
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
pending_tickets=pending_tickets,
|
||||
page_info=page_info,
|
||||
page=page,
|
||||
view=view,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/all-tickets/adjust")
|
||||
async def adjust_ticket():
|
||||
"""Adjust ticket quantity, return updated widget + OOB cart-mini."""
|
||||
ident = current_cart_identity()
|
||||
form = await request.form
|
||||
entry_id = int(form.get("entry_id", 0))
|
||||
count = max(int(form.get("count", 0)), 0)
|
||||
tt_raw = (form.get("ticket_type_id") or "").strip()
|
||||
ticket_type_id = int(tt_raw) if tt_raw else None
|
||||
|
||||
await services.calendar.adjust_ticket_quantity(
|
||||
g.s, entry_id, count,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
ticket_type_id=ticket_type_id,
|
||||
)
|
||||
|
||||
# Get updated ticket count for this entry
|
||||
tickets = await services.calendar.pending_tickets(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
qty = sum(1 for t in tickets if t.entry_id == entry_id)
|
||||
|
||||
# Load entry DTO for the widget template
|
||||
entry = await services.calendar.entry_by_id(g.s, entry_id)
|
||||
|
||||
# Updated cart count for OOB mini-cart
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
|
||||
# Render widget + OOB cart-mini
|
||||
widget_html = await render_template(
|
||||
"_types/page_summary/_ticket_widget.html",
|
||||
entry=entry,
|
||||
qty=qty,
|
||||
ticket_url="/all-tickets/adjust",
|
||||
)
|
||||
mini_html = await render_template_string(
|
||||
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||
'{{ mini(oob="true") }}',
|
||||
cart_count=cart_count,
|
||||
)
|
||||
return await make_response(widget_html + mini_html, 200)
|
||||
|
||||
return bp
|
||||
76
events/bp/calendar/admin/routes.py
Normal file
76
events/bp/calendar/admin/routes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g
|
||||
)
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(calendar_slug: str, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/calendar/admin/index.html")
|
||||
else:
|
||||
# HTMX request: main panel + OOB elements
|
||||
html = await render_template("_types/calendar/admin/_oob_elements.html")
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/description/")
|
||||
@require_admin
|
||||
async def calendar_description_edit(calendar_slug: str, **kwargs):
|
||||
# g.post and g.calendar should already be set by the parent calendar bp
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description_edit.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.post("/description/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def calendar_description_save(calendar_slug: str, **kwargs):
|
||||
form = await request.form
|
||||
description = (form.get("description") or "").strip() or None
|
||||
|
||||
# simple inline update, or call a service if you prefer
|
||||
g.calendar.description = description
|
||||
await g.s.flush()
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
oob=True
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/description/view/")
|
||||
@require_admin
|
||||
async def calendar_description_view(calendar_slug: str, **kwargs):
|
||||
# just render the display version without touching the DB (used by Cancel)
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
251
events/bp/calendar/routes.py
Normal file
251
events/bp/calendar/routes.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
||||
)
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from models.calendars import Calendar
|
||||
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
from shared.browser.app.authz import require_admin
|
||||
|
||||
from .admin.routes import register as register_admin
|
||||
from .services import get_visible_entries_for_period
|
||||
from .services.calendar_view import (
|
||||
parse_int_arg,
|
||||
add_months,
|
||||
build_calendar_weeks,
|
||||
get_calendar_by_post_and_slug,
|
||||
get_calendar_by_slug,
|
||||
update_calendar_description,
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
from ..slots.routes import register as register_slots
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
from bp.calendars.services.calendars import soft_delete
|
||||
|
||||
from bp.day.routes import register as register_day
|
||||
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
import calendar as pycalendar
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("calendar", __name__, url_prefix='/<calendar_slug>')
|
||||
|
||||
bp.register_blueprint(
|
||||
register_admin(),
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_slots(),
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_day()
|
||||
)
|
||||
|
||||
@bp.url_value_preprocessor
|
||||
def pull(endpoint, values):
|
||||
g.calendar_slug = values.get("calendar_slug")
|
||||
|
||||
@bp.before_request
|
||||
async def hydrate_calendar_data():
|
||||
calendar_slug = getattr(g, "calendar_slug", None)
|
||||
|
||||
# Standalone mode (events app): no post context
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
|
||||
else:
|
||||
cal = await get_calendar_by_slug(g.s, calendar_slug)
|
||||
|
||||
if not cal:
|
||||
abort(404)
|
||||
return
|
||||
|
||||
g.calendar = cal
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
|
||||
return {
|
||||
"calendar": getattr(g, "calendar", None),
|
||||
}
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
@bp.get("/")
|
||||
@cache_page(tag="calendars")
|
||||
async def get(calendar_slug: str, **kwargs):
|
||||
"""
|
||||
Show a month-view calendar for this calendar.
|
||||
|
||||
- One month at a time
|
||||
- Outer arrows: +/- 1 year
|
||||
- Inner arrows: +/- 1 month
|
||||
"""
|
||||
|
||||
# --- Determine year & month from query params ---
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
month = parse_int_arg("month")
|
||||
year = parse_int_arg("year")
|
||||
|
||||
if year is None:
|
||||
year = today.year
|
||||
if month is None or not (1 <= month <= 12):
|
||||
month = today.month
|
||||
|
||||
# --- Helpers to move between months ---
|
||||
prev_month_year, prev_month = add_months(year, month, -1)
|
||||
next_month_year, next_month = add_months(year, month, +1)
|
||||
prev_year = year - 1
|
||||
next_year = year + 1
|
||||
|
||||
# --- Build weeks grid (list of weeks, each week = 7 days) ---
|
||||
weeks = build_calendar_weeks(year, month)
|
||||
month_name = pycalendar.month_name[month]
|
||||
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
|
||||
|
||||
# --- Period boundaries for this calendar view ---
|
||||
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
next_y, next_m = add_months(year, month, +1)
|
||||
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
|
||||
|
||||
# --- Identity & admin flag ---
|
||||
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,
|
||||
)
|
||||
|
||||
month_entries = visible.merged_entries
|
||||
user_entries = visible.user_entries
|
||||
confirmed_entries = visible.confirmed_entries
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/calendar/index.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/_oob_elements.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def put(calendar_slug: str, **kwargs):
|
||||
"""
|
||||
Idempotent update for calendar configuration.
|
||||
Accepts HTMX form (POST/PUT) and optional JSON.
|
||||
"""
|
||||
# Try JSON first
|
||||
data = await request.get_json(silent=True)
|
||||
description = None
|
||||
|
||||
if data and isinstance(data, dict):
|
||||
description = (data.get("description") or "").strip()
|
||||
else:
|
||||
form = await request.form
|
||||
description = (form.get("description") or "").strip()
|
||||
|
||||
await update_calendar_description(g.calendar, description)
|
||||
html = await render_template("_types/calendar/admin/index.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
@bp.delete("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def delete(calendar_slug: str, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
cal = g.calendar
|
||||
cal.deleted_at = datetime.now(timezone.utc)
|
||||
await g.s.flush()
|
||||
|
||||
# If we have post context (blog-embedded mode), update nav
|
||||
post_data = getattr(g, "post_data", None)
|
||||
html = await render_template("_types/calendars/index.html")
|
||||
|
||||
if post_data:
|
||||
from ..post.services.entry_associations import get_associated_entries
|
||||
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cals = (
|
||||
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()
|
||||
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=cals,
|
||||
post=post_data["post"],
|
||||
)
|
||||
html = html + nav_oob
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
return bp
|
||||
1
events/bp/calendar/services/__init__.py
Normal file
1
events/bp/calendar/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .visiblity import get_visible_entries_for_period
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import select, update
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
# (Optional) Mark any existing entries for this user as deleted to avoid duplicates
|
||||
await session.execute(
|
||||
update(CalendarEntry)
|
||||
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
# Reassign anonymous entries to the user
|
||||
result = await session.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.session_id == session_id
|
||||
)
|
||||
)
|
||||
anon_entries = result.scalars().all()
|
||||
for entry in anon_entries:
|
||||
entry.user_id = user_id
|
||||
# No commit here; caller will commit
|
||||
28
events/bp/calendar/services/calendar.py
Normal file
28
events/bp/calendar/services/calendar.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from models.calendars import Calendar
|
||||
from ...calendars.services.calendars import CalendarError
|
||||
|
||||
async def update_calendar_config(sess, calendar_id: int, *, description: str | None, slots: list | None):
|
||||
"""Update description and slots for a calendar."""
|
||||
cal = await sess.get(Calendar, calendar_id)
|
||||
if not cal:
|
||||
raise CalendarError(f"Calendar {calendar_id} not found.")
|
||||
cal.description = (description or '').strip() or None
|
||||
# Validate slots shape a bit
|
||||
norm_slots: list[dict] = []
|
||||
if slots:
|
||||
for s in slots:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
norm_slots.append({
|
||||
"days": str(s.get("days", ""))[:7].lower(),
|
||||
"time_from": str(s.get("time_from", ""))[:5],
|
||||
"time_to": str(s.get("time_to", ""))[:5],
|
||||
"cost_name": (s.get("cost_name") or "")[:64],
|
||||
"description": (s.get("description") or "")[:255],
|
||||
})
|
||||
cal.slots = norm_slots or None
|
||||
await sess.flush()
|
||||
return cal
|
||||
109
events/bp/calendar/services/calendar_view.py
Normal file
109
events/bp/calendar/services/calendar_view.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import calendar as pycalendar
|
||||
|
||||
from quart import request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
|
||||
from models.calendars import Calendar, CalendarSlot
|
||||
|
||||
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
|
||||
"""Parse an integer query parameter from the request."""
|
||||
val = request.args.get(name, "").strip()
|
||||
if not val:
|
||||
return default
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def add_months(year: int, month: int, delta: int) -> tuple[int, int]:
|
||||
"""Add (or subtract) months to a given year/month, handling year overflow."""
|
||||
new_month = month + delta
|
||||
new_year = year + (new_month - 1) // 12
|
||||
new_month = ((new_month - 1) % 12) + 1
|
||||
return new_year, new_month
|
||||
|
||||
|
||||
def build_calendar_weeks(year: int, month: int) -> list[list[dict]]:
|
||||
"""
|
||||
Build a calendar grid for the given year and month.
|
||||
Returns a list of weeks, where each week is a list of 7 day dictionaries.
|
||||
"""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
cal = pycalendar.Calendar(firstweekday=0) # 0 = Monday
|
||||
weeks: list[list[dict]] = []
|
||||
|
||||
for week in cal.monthdatescalendar(year, month):
|
||||
week_days = []
|
||||
for d in week:
|
||||
week_days.append(
|
||||
{
|
||||
"date": d,
|
||||
"in_month": (d.month == month),
|
||||
"is_today": (d == today),
|
||||
}
|
||||
)
|
||||
weeks.append(week_days)
|
||||
|
||||
return weeks
|
||||
|
||||
|
||||
async def get_calendar_by_post_and_slug(
|
||||
session: AsyncSession,
|
||||
post_id: int,
|
||||
calendar_slug: str,
|
||||
) -> Optional[Calendar]:
|
||||
"""
|
||||
Fetch a calendar by post_id and slug, with slots eagerly loaded.
|
||||
Returns None if not found.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Calendar)
|
||||
.options(
|
||||
selectinload(Calendar.slots),
|
||||
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
||||
)
|
||||
.where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post_id,
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_calendar_by_slug(
|
||||
session: AsyncSession,
|
||||
calendar_slug: str,
|
||||
) -> Optional[Calendar]:
|
||||
"""
|
||||
Fetch a calendar by slug only (for standalone events service).
|
||||
With slots eagerly loaded. Returns None if not found.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Calendar)
|
||||
.options(
|
||||
selectinload(Calendar.slots),
|
||||
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
||||
)
|
||||
.where(
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_calendar_description(
|
||||
calendar: Calendar,
|
||||
description: Optional[str],
|
||||
) -> None:
|
||||
"""Update calendar description (in-place on the calendar object)."""
|
||||
calendar.description = description or None
|
||||
118
events/bp/calendar/services/slots.py
Normal file
118
events/bp/calendar/services/slots.py
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import time
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
def _b(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
s = str(v).lower()
|
||||
return s in {"1","true","t","yes","y","on"}
|
||||
|
||||
async def list_slots(sess: AsyncSession, calendar_id: int) -> Sequence[CalendarSlot]:
|
||||
res = await sess.execute(
|
||||
select(CalendarSlot)
|
||||
.where(CalendarSlot.calendar_id == calendar_id, CalendarSlot.deleted_at.is_(None))
|
||||
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
|
||||
)
|
||||
return res.scalars().all()
|
||||
|
||||
async def create_slot(sess: AsyncSession, calendar_id: int, *, name: str, description: str | None,
|
||||
days: dict, time_start: time, time_end: time, cost: float | None):
|
||||
if not name:
|
||||
raise SlotError("name is required")
|
||||
if not time_start or not time_end or time_end <= time_start:
|
||||
raise SlotError("time range invalid")
|
||||
slot = CalendarSlot(
|
||||
calendar_id=calendar_id,
|
||||
name=name,
|
||||
description=(description or None),
|
||||
mon=_b(days.get("mon")), tue=_b(days.get("tue")), wed=_b(days.get("wed")),
|
||||
thu=_b(days.get("thu")), fri=_b(days.get("fri")), sat=_b(days.get("sat")), sun=_b(days.get("sun")),
|
||||
time_start=time_start, time_end=time_end, cost=cost,
|
||||
)
|
||||
sess.add(slot)
|
||||
await sess.flush()
|
||||
return slot
|
||||
|
||||
async def update_slot(
|
||||
sess: AsyncSession,
|
||||
slot_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
days: dict | None = None,
|
||||
time_start: time | None = None,
|
||||
time_end: time | None = None,
|
||||
cost: float | None = None,
|
||||
flexible: bool | None = None, # NEW
|
||||
):
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot or slot.deleted_at is not None:
|
||||
raise SlotError("slot not found")
|
||||
|
||||
if name is not None:
|
||||
slot.name = name
|
||||
|
||||
if description is not None:
|
||||
slot.description = description or None
|
||||
|
||||
if days is not None:
|
||||
slot.mon = _b(days.get("mon", slot.mon))
|
||||
slot.tue = _b(days.get("tue", slot.tue))
|
||||
slot.wed = _b(days.get("wed", slot.wed))
|
||||
slot.thu = _b(days.get("thu", slot.thu))
|
||||
slot.fri = _b(days.get("fri", slot.fri))
|
||||
slot.sat = _b(days.get("sat", slot.sat))
|
||||
slot.sun = _b(days.get("sun", slot.sun))
|
||||
|
||||
if time_start is not None:
|
||||
slot.time_start = time_start
|
||||
if time_end is not None:
|
||||
slot.time_end = time_end
|
||||
|
||||
if (time_start or time_end) and slot.time_end <= slot.time_start:
|
||||
raise SlotError("time range invalid")
|
||||
|
||||
if cost is not None:
|
||||
slot.cost = cost
|
||||
|
||||
# NEW: update flexible flag only if explicitly provided
|
||||
if flexible is not None:
|
||||
slot.flexible = flexible
|
||||
|
||||
await sess.flush()
|
||||
return slot
|
||||
|
||||
async def soft_delete_slot(sess: AsyncSession, slot_id: int):
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot or slot.deleted_at is not None:
|
||||
return
|
||||
from datetime import datetime, timezone
|
||||
slot.deleted_at = datetime.now(timezone.utc)
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def get_slot(sess: AsyncSession, slot_id: int) -> CalendarSlot | None:
|
||||
return await sess.get(CalendarSlot, slot_id)
|
||||
|
||||
async def update_slot_description(
|
||||
sess: AsyncSession,
|
||||
slot_id: int,
|
||||
description: str | None,
|
||||
) -> CalendarSlot:
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot:
|
||||
raise SlotError("slot not found")
|
||||
slot.description = description or None
|
||||
await sess.flush()
|
||||
return slot
|
||||
116
events/bp/calendar/services/visiblity.py
Normal file
116
events/bp/calendar/services/visiblity.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisibleEntries:
|
||||
"""
|
||||
Result of applying calendar visibility rules for a given period.
|
||||
"""
|
||||
user_entries: list[CalendarEntry]
|
||||
confirmed_entries: list[CalendarEntry]
|
||||
admin_other_entries: list[CalendarEntry]
|
||||
merged_entries: list[CalendarEntry] # sorted, deduped
|
||||
|
||||
|
||||
async def get_visible_entries_for_period(
|
||||
sess: AsyncSession,
|
||||
calendar_id: int,
|
||||
period_start: datetime,
|
||||
period_end: datetime,
|
||||
user: Optional[object],
|
||||
session_id: Optional[str],
|
||||
) -> VisibleEntries:
|
||||
"""
|
||||
Visibility rules (same as your fixed month view):
|
||||
|
||||
- Non-admin:
|
||||
- sees all *confirmed* entries in the period (any user)
|
||||
- sees all entries for current user/session in the period (any state)
|
||||
- Admin:
|
||||
- sees all confirmed + provisional + ordered entries in the period (all users)
|
||||
- sees pending only for current user/session
|
||||
"""
|
||||
|
||||
user_id = user.id if user else None
|
||||
is_admin = bool(user and getattr(user, "is_admin", False))
|
||||
|
||||
# --- Entries for current user/session (any state, in period) ---
|
||||
user_entries: list[CalendarEntry] = []
|
||||
if user_id or session_id:
|
||||
conditions_user = [
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
]
|
||||
if user_id:
|
||||
conditions_user.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id:
|
||||
conditions_user.append(CalendarEntry.session_id == session_id)
|
||||
|
||||
result_user = await sess.execute(select(CalendarEntry).where(*conditions_user))
|
||||
user_entries = result_user.scalars().all()
|
||||
|
||||
# --- Confirmed entries for everyone in period ---
|
||||
result_conf = await sess.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.state == "confirmed",
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
)
|
||||
)
|
||||
confirmed_entries = result_conf.scalars().all()
|
||||
|
||||
# --- For admins: ordered + provisional for everyone in period ---
|
||||
admin_other_entries: list[CalendarEntry] = []
|
||||
if is_admin:
|
||||
result_admin = await sess.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.state.in_(("ordered", "provisional")),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
)
|
||||
)
|
||||
admin_other_entries = result_admin.scalars().all()
|
||||
|
||||
# --- Merge with de-duplication and keep chronological order ---
|
||||
entries_by_id: dict[int, CalendarEntry] = {}
|
||||
|
||||
# Everyone's confirmed
|
||||
for e in confirmed_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
# Admin-only: everyone's ordered/provisional
|
||||
if is_admin:
|
||||
for e in admin_other_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
# Always include current user/session entries (includes their pending)
|
||||
for e in user_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
merged_entries = sorted(
|
||||
entries_by_id.values(),
|
||||
key=lambda e: e.start_at or period_start,
|
||||
)
|
||||
|
||||
return VisibleEntries(
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
admin_other_entries=admin_other_entries,
|
||||
merged_entries=merged_entries,
|
||||
)
|
||||
257
events/bp/calendar_entries/routes.py
Normal file
257
events/bp/calendar_entries/routes.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from quart import (
|
||||
request, render_template, render_template_string, make_response,
|
||||
Blueprint, g, redirect, url_for, jsonify,
|
||||
)
|
||||
|
||||
|
||||
from sqlalchemy import update, func as sa_func
|
||||
|
||||
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
|
||||
|
||||
# Count pending calendar entries from local session (sees the just-added entry)
|
||||
user_id = getattr(g, "user", None) and g.user.id
|
||||
cal_filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "pending",
|
||||
]
|
||||
if user_id:
|
||||
cal_filters.append(CalendarEntry.user_id == user_id)
|
||||
|
||||
cal_count = await g.s.scalar(
|
||||
select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters)
|
||||
) or 0
|
||||
|
||||
# Get product cart count via service (same DB, no HTTP needed)
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.services.registry import services
|
||||
ident = current_cart_identity()
|
||||
cart_summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
product_count = cart_summary.count
|
||||
total_count = product_count + cal_count
|
||||
|
||||
html = await render_template("_types/day/_main_panel.html")
|
||||
mini_html = await render_template_string(
|
||||
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||
'{{ mini(oob="true") }}',
|
||||
cart_count=total_count,
|
||||
)
|
||||
return await make_response(html + mini_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
|
||||
278
events/bp/calendar_entries/services/entries.py
Normal file
278
events/bp/calendar_entries/services/entries.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Optional, Sequence
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import Calendar, CalendarEntry
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shared.browser.app.errors import AppError
|
||||
|
||||
class CalendarError(AppError):
|
||||
"""Base error for calendar service operations."""
|
||||
status_code = 422
|
||||
|
||||
|
||||
|
||||
async def add_entry(
|
||||
sess: AsyncSession,
|
||||
calendar_id: int,
|
||||
name: str,
|
||||
start_at: Optional[datetime],
|
||||
end_at: Optional[datetime],
|
||||
user_id: int | None = None,
|
||||
session_id: str | None = None,
|
||||
slot_id: int | None = None, # NEW: accept slot_id
|
||||
cost: Optional[Decimal] = None, # NEW: accept cost
|
||||
) -> CalendarEntry:
|
||||
"""
|
||||
Add an entry to a calendar.
|
||||
|
||||
Collects *all* validation errors and raises CalendarError([...])
|
||||
so the HTMX handler can show them as a list.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Normalise
|
||||
name = (name or "").strip()
|
||||
|
||||
# Name validation
|
||||
if not name:
|
||||
errors.append("Entry name must not be empty.")
|
||||
|
||||
# start_at validation
|
||||
if start_at is None:
|
||||
errors.append("Start time is required.")
|
||||
elif not isinstance(start_at, datetime):
|
||||
errors.append("Start time is invalid.")
|
||||
|
||||
# end_at validation
|
||||
if end_at is not None and not isinstance(end_at, datetime):
|
||||
errors.append("End time is invalid.")
|
||||
|
||||
# Time ordering (only if we have sensible datetimes)
|
||||
if isinstance(start_at, datetime) and isinstance(end_at, datetime):
|
||||
if end_at < start_at:
|
||||
errors.append("End time must be greater than or equal to the start time.")
|
||||
|
||||
# If we have any validation errors, bail out now
|
||||
if errors:
|
||||
raise CalendarError(errors, status_code=422)
|
||||
|
||||
# Calendar existence (this is more of a 404 than a validation issue)
|
||||
cal = (
|
||||
await sess.execute(
|
||||
select(Calendar).where(
|
||||
Calendar.id == calendar_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not cal:
|
||||
# Single-message CalendarError – still handled by the same error handler
|
||||
raise CalendarError(
|
||||
f"Calendar {calendar_id} does not exist or has been deleted.",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# All good, create the entry
|
||||
entry = CalendarEntry(
|
||||
calendar_id=calendar_id,
|
||||
name=name,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
slot_id=slot_id, # NEW: save slot_id
|
||||
state="pending",
|
||||
cost=cost if cost is not None else Decimal('10'), # Use provided cost or default
|
||||
)
|
||||
sess.add(entry)
|
||||
await sess.flush()
|
||||
|
||||
# Publish to federation inline
|
||||
if entry.user_id:
|
||||
from shared.services.federation_publish import try_publish
|
||||
await try_publish(
|
||||
sess,
|
||||
user_id=entry.user_id,
|
||||
activity_type="Create",
|
||||
object_type="Event",
|
||||
object_data={
|
||||
"name": entry.name or "",
|
||||
"startTime": entry.start_at.isoformat() if entry.start_at else "",
|
||||
"endTime": entry.end_at.isoformat() if entry.end_at else "",
|
||||
},
|
||||
source_type="CalendarEntry",
|
||||
source_id=entry.id,
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def list_entries(
|
||||
sess: AsyncSession,
|
||||
post_id: int,
|
||||
calendar_slug: str,
|
||||
from_: Optional[datetime] = None,
|
||||
to: Optional[datetime] = None,
|
||||
) -> Sequence[CalendarEntry]:
|
||||
"""
|
||||
List entries for a given post's calendar by name.
|
||||
- Respects soft-deletes (only non-deleted calendar / entries).
|
||||
- If a time window is provided, returns entries that overlap the window:
|
||||
- If only from_ is given: entries where end_at is NULL or end_at >= from_
|
||||
- If only to is given: entries where start_at <= to
|
||||
- If both given: entries where [start_at, end_at or +inf] overlaps [from_, to]
|
||||
- Sorted by start_at ascending.
|
||||
"""
|
||||
calendar_slug = (calendar_slug or "").strip()
|
||||
if not calendar_slug:
|
||||
raise CalendarError("calendar_slug must not be empty.")
|
||||
|
||||
cal = (
|
||||
await sess.execute(
|
||||
select(Calendar.id)
|
||||
.where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post_id,
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not cal:
|
||||
# Return empty list instead of raising, so callers can treat absence as "no entries"
|
||||
return []
|
||||
|
||||
# Base filter: not soft-deleted entries of this calendar
|
||||
filters = [CalendarEntry.calendar_id == cal, CalendarEntry.deleted_at.is_(None)]
|
||||
|
||||
# Time window logic
|
||||
if from_ and to:
|
||||
# Overlap condition: start <= to AND (end is NULL OR end >= from_)
|
||||
filters.append(CalendarEntry.start_at <= to)
|
||||
filters.append(or_(CalendarEntry.end_at.is_(None), CalendarEntry.end_at >= from_))
|
||||
elif from_:
|
||||
# Anything that hasn't ended before from_
|
||||
filters.append(or_(CalendarEntry.end_at.is_(None), CalendarEntry.end_at >= from_))
|
||||
elif to:
|
||||
# Anything that has started by 'to'
|
||||
filters.append(CalendarEntry.start_at <= to)
|
||||
|
||||
stmt = (
|
||||
select(CalendarEntry)
|
||||
.where(and_(*filters))
|
||||
.order_by(CalendarEntry.start_at.asc(), CalendarEntry.id.asc())
|
||||
)
|
||||
|
||||
result = await sess.execute(stmt)
|
||||
entries = list(result.scalars())
|
||||
|
||||
# Eagerly load slot relationships
|
||||
for entry in entries:
|
||||
await sess.refresh(entry, ['slot'])
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
async def svc_update_entry(
|
||||
sess: AsyncSession,
|
||||
entry_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
start_at: datetime | None = None,
|
||||
end_at: datetime | None = None,
|
||||
user_id: int | None = None,
|
||||
session_id: str | None = None,
|
||||
slot_id: int | None = None, # NEW: accept slot_id
|
||||
cost: Decimal | None = None, # NEW: accept cost
|
||||
) -> CalendarEntry:
|
||||
"""
|
||||
Update an existing CalendarEntry.
|
||||
|
||||
- Performs the same validations as add_entry()
|
||||
- Returns the updated CalendarEntry
|
||||
- Raises CalendarError([...]) on validation issues
|
||||
- Raises CalendarError(...) if entry does not exist
|
||||
"""
|
||||
|
||||
# Fetch entry
|
||||
entry = (
|
||||
await sess.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise CalendarError(
|
||||
f"Entry {entry_id} does not exist or has been deleted.",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
# ----- Validation ----- #
|
||||
|
||||
# Name validation only if updating it
|
||||
if name is not None:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
errors.append("Entry name must not be empty.")
|
||||
|
||||
# start_at type validation only if provided
|
||||
if start_at is not None and not isinstance(start_at, datetime):
|
||||
errors.append("Start time is invalid.")
|
||||
|
||||
# end_at type validation
|
||||
if end_at is not None and not isinstance(end_at, datetime):
|
||||
errors.append("End time is invalid.")
|
||||
|
||||
# Time ordering
|
||||
effective_start = start_at if start_at is not None else entry.start_at
|
||||
effective_end = end_at if end_at is not None else entry.end_at
|
||||
|
||||
if isinstance(effective_start, datetime) and isinstance(effective_end, datetime):
|
||||
if effective_end < effective_start:
|
||||
errors.append("End time must be greater than or equal to the start time.")
|
||||
|
||||
# Validation failures?
|
||||
if errors:
|
||||
raise CalendarError(errors, status_code=422)
|
||||
|
||||
# ----- Apply Updates ----- #
|
||||
|
||||
if name is not None:
|
||||
entry.name = name
|
||||
|
||||
if start_at is not None:
|
||||
entry.start_at = start_at
|
||||
|
||||
if end_at is not None:
|
||||
entry.end_at = end_at
|
||||
|
||||
if user_id is not None:
|
||||
entry.user_id = user_id
|
||||
|
||||
if session_id is not None:
|
||||
entry.session_id = session_id
|
||||
|
||||
if slot_id is not None: # NEW: update slot_id
|
||||
entry.slot_id = slot_id
|
||||
|
||||
if cost is not None: # NEW: update cost
|
||||
entry.cost = cost
|
||||
|
||||
entry.updated_at = datetime.utcnow()
|
||||
|
||||
await sess.flush()
|
||||
return entry
|
||||
28
events/bp/calendar_entry/admin/routes.py
Normal file
28
events/bp/calendar_entry/admin/routes.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
render_template, make_response, Blueprint
|
||||
)
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(entry_id: int, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/entry/admin/index.html")
|
||||
else:
|
||||
html = await render_template("_types/entry/admin/_oob_elements.html")
|
||||
|
||||
return await make_response(html)
|
||||
return bp
|
||||
626
events/bp/calendar_entry/routes.py
Normal file
626
events/bp/calendar_entry/routes.py
Normal file
@@ -0,0 +1,626 @@
|
||||
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 ..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='/<int:entry_id>')
|
||||
|
||||
# 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."""
|
||||
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()
|
||||
|
||||
@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 template
|
||||
nav_oob = await render_template(
|
||||
"_types/day/admin/_nav_entries_oob.html",
|
||||
confirmed_entries=visible.confirmed_entries,
|
||||
post=g.post_data["post"],
|
||||
calendar=calendar,
|
||||
day_date=day_date,
|
||||
)
|
||||
return nav_oob
|
||||
|
||||
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 ..post.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 template for this post's nav
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=calendars,
|
||||
post=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 market (skip calendar — we're on a calendar page)
|
||||
container_nav_html = ""
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = post_data["post"]["id"]
|
||||
post_slug = post_data["post"]["slug"]
|
||||
container_nav_html = await fetch_fragment("market", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": post_slug,
|
||||
})
|
||||
|
||||
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_html": container_nav_html,
|
||||
}
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(entry_id: int, **rest):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Full template for both HTMX and normal requests
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/entry/index.html",
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/entry/_oob_elements.html",
|
||||
)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@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)
|
||||
|
||||
html = await render_template(
|
||||
"_types/entry/index.html",
|
||||
#entry=entry,
|
||||
)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
# redirect back to calendar admin or order page as you prefer
|
||||
html = await render_template("_types/entry/_optioned.html")
|
||||
return await make_response(html + day_nav_oob + post_nav_oob, 200)
|
||||
|
||||
@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)
|
||||
|
||||
# redirect back to calendar admin or order page as you prefer
|
||||
html = await render_template("_types/entry/_optioned.html")
|
||||
return await make_response(html + day_nav_oob + post_nav_oob, 200)
|
||||
|
||||
@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)
|
||||
|
||||
# redirect back to calendar admin or order page as you prefer
|
||||
html = await render_template("_types/entry/_optioned.html")
|
||||
return await make_response(html + day_nav_oob + post_nav_oob, 200)
|
||||
|
||||
@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-...")
|
||||
html = await render_template("_types/entry/_tickets.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
@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
|
||||
html = await render_template("_types/entry/_posts.html")
|
||||
nav_oob = await render_template(
|
||||
"_types/entry/admin/_nav_posts_oob.html",
|
||||
entry_posts=entry_posts,
|
||||
)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
@bp.delete("/posts/<int:post_id>/")
|
||||
@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
|
||||
html = await render_template("_types/entry/_posts.html")
|
||||
nav_oob = await render_template(
|
||||
"_types/entry/admin/_nav_posts_oob.html",
|
||||
entry_posts=entry_posts,
|
||||
)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
return bp
|
||||
121
events/bp/calendar_entry/services/post_associations.py
Normal file
121
events/bp/calendar_entry/services/post_associations.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from models.calendars import CalendarEntry, CalendarEntryPost
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
async def add_post_to_entry(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
post_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Associate a post with a calendar entry.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
# Check if entry exists
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if not entry:
|
||||
return False, "Calendar entry not found"
|
||||
|
||||
# Check if post exists
|
||||
post = await services.blog.get_post_by_id(session, post_id)
|
||||
if not post:
|
||||
return False, "Post not found"
|
||||
|
||||
# Check if association already exists
|
||||
existing = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
return False, "Post is already associated with this entry"
|
||||
|
||||
# Create association
|
||||
association = CalendarEntryPost(
|
||||
entry_id=entry_id,
|
||||
content_type="post",
|
||||
content_id=post_id
|
||||
)
|
||||
session.add(association)
|
||||
await session.flush()
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
async def remove_post_from_entry(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
post_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Remove a post association from a calendar entry (soft delete).
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
# Find the association
|
||||
association = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if not association:
|
||||
return False, "Association not found"
|
||||
|
||||
# Soft delete
|
||||
association.deleted_at = func.now()
|
||||
await session.flush()
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
async def get_entry_posts(
|
||||
session: AsyncSession,
|
||||
entry_id: int
|
||||
) -> list:
|
||||
"""
|
||||
Get all posts (as PostDTOs) associated with a calendar entry.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(CalendarEntryPost.content_id).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
post_ids = list(result.scalars().all())
|
||||
if not post_ids:
|
||||
return []
|
||||
posts = await services.blog.get_posts_by_ids(session, post_ids)
|
||||
return sorted(posts, key=lambda p: (p.title or ""))
|
||||
|
||||
|
||||
async def search_posts(
|
||||
session: AsyncSession,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> tuple[list, int]:
|
||||
"""
|
||||
Search for posts by title with pagination.
|
||||
If query is empty, returns all posts in published order.
|
||||
Returns (post_dtos, total_count).
|
||||
"""
|
||||
return await services.blog.search_posts(session, query, page, per_page)
|
||||
87
events/bp/calendar_entry/services/ticket_operations.py
Normal file
87
events/bp/calendar_entry/services/ticket_operations.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
|
||||
async def update_ticket_config(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
ticket_price: Optional[Decimal],
|
||||
ticket_count: Optional[int],
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Update ticket configuration for a calendar entry.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
entry_id: Calendar entry ID
|
||||
ticket_price: Price per ticket (None = no tickets)
|
||||
ticket_count: Total available tickets (None = unlimited)
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
# Get the entry
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if not entry:
|
||||
return False, "Calendar entry not found"
|
||||
|
||||
# Validate inputs
|
||||
if ticket_price is not None and ticket_price < 0:
|
||||
return False, "Ticket price cannot be negative"
|
||||
|
||||
if ticket_count is not None and ticket_count < 0:
|
||||
return False, "Ticket count cannot be negative"
|
||||
|
||||
# Update ticket configuration
|
||||
entry.ticket_price = ticket_price
|
||||
entry.ticket_count = ticket_count
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
async def get_available_tickets(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""
|
||||
Get the number of available tickets for a calendar entry.
|
||||
|
||||
Returns:
|
||||
(available_count, error_message)
|
||||
- available_count is None if unlimited tickets
|
||||
- available_count is the remaining count if limited
|
||||
"""
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if not entry:
|
||||
return None, "Calendar entry not found"
|
||||
|
||||
# If no ticket configuration, return None (unlimited)
|
||||
if entry.ticket_price is None:
|
||||
return None, None
|
||||
|
||||
# If ticket_count is None, unlimited tickets
|
||||
if entry.ticket_count is None:
|
||||
return None, None
|
||||
|
||||
# Returns total count (booked tickets not yet subtracted)
|
||||
return entry.ticket_count, None
|
||||
99
events/bp/calendars/routes.py
Normal file
99
events/bp/calendars/routes.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from models.calendars import Calendar
|
||||
|
||||
|
||||
from .services.calendars import (
|
||||
create_calendar as svc_create_calendar,
|
||||
)
|
||||
|
||||
from ..calendar.routes import register as register_calendar
|
||||
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("calendars", __name__, url_prefix='/calendars')
|
||||
bp.register_blueprint(
|
||||
register_calendar(),
|
||||
)
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
# Must always return a dict
|
||||
return {}
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
@bp.get("/")
|
||||
@cache_page(tag="calendars")
|
||||
async def home(**kwargs):
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/calendars/index.html",
|
||||
)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/calendars/_oob_elements.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.post("/new/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def create_calendar(**kwargs):
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
|
||||
# Get post_id from context if available (blog-embedded mode)
|
||||
post_data = getattr(g, "post_data", None)
|
||||
post_id = (post_data.get("post") or {}).get("id") if post_data else None
|
||||
|
||||
if not post_id:
|
||||
# Standalone mode: post_id from form (or None — calendar without post)
|
||||
post_id = form.get("post_id")
|
||||
if post_id:
|
||||
post_id = int(post_id)
|
||||
|
||||
try:
|
||||
await svc_create_calendar(g.s, post_id, name)
|
||||
except Exception as e:
|
||||
return await make_response(f'<div class="text-red-600 text-sm">{e}</div>', 422)
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendars/index.html",
|
||||
)
|
||||
|
||||
# Blog-embedded mode: also update post nav
|
||||
if post_data:
|
||||
from ..post.services.entry_associations import get_associated_entries
|
||||
|
||||
cals = (
|
||||
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()
|
||||
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=cals,
|
||||
post=post_data["post"],
|
||||
)
|
||||
|
||||
html = html + nav_oob
|
||||
|
||||
return await make_response(html)
|
||||
return bp
|
||||
115
events/bp/calendars/services/calendars.py
Normal file
115
events/bp/calendars/services/calendars.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import Calendar
|
||||
from shared.services.registry import services
|
||||
from shared.services.relationships import attach_child, detach_child
|
||||
import unicodedata
|
||||
import re
|
||||
|
||||
|
||||
class CalendarError(ValueError):
|
||||
"""Base error for calendar service operations."""
|
||||
|
||||
from shared.browser.app.utils import (
|
||||
utcnow
|
||||
)
|
||||
|
||||
def slugify(value: str, max_len: int = 255) -> str:
|
||||
"""
|
||||
Make a URL-friendly slug:
|
||||
- lowercase
|
||||
- remove accents
|
||||
- replace any non [a-z0-9]+ with '-'
|
||||
- no forward slashes
|
||||
- collapse multiple dashes
|
||||
- trim leading/trailing dashes
|
||||
"""
|
||||
if value is None:
|
||||
value = ""
|
||||
# normalize accents -> ASCII
|
||||
value = unicodedata.normalize("NFKD", value)
|
||||
value = value.encode("ascii", "ignore").decode("ascii")
|
||||
value = value.lower()
|
||||
|
||||
# explicitly block forward slashes
|
||||
value = value.replace("/", "-")
|
||||
|
||||
# replace non-alnum with hyphen
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
# collapse multiple hyphens
|
||||
value = re.sub(r"-{2,}", "-", value)
|
||||
# trim hyphens and enforce length
|
||||
value = value.strip("-")[:max_len].strip("-")
|
||||
|
||||
# fallback if empty
|
||||
return value or "calendar"
|
||||
|
||||
|
||||
async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
cal = (
|
||||
await sess.execute(
|
||||
select(Calendar).where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post.id,
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not cal:
|
||||
return False
|
||||
|
||||
cal.deleted_at = utcnow()
|
||||
await sess.flush()
|
||||
await detach_child(sess, "page", cal.container_id, "calendar", cal.id)
|
||||
return True
|
||||
|
||||
async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar:
|
||||
"""
|
||||
Create a calendar for a post. Name must be unique per post.
|
||||
If a calendar with the same (post_id, name) exists but is soft-deleted,
|
||||
it will be revived (deleted_at=None).
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise CalendarError("Calendar name must not be empty.")
|
||||
slug=slugify(name)
|
||||
|
||||
# Ensure post exists (avoid silent FK errors in some DBs)
|
||||
post = await services.blog.get_post_by_id(sess, post_id)
|
||||
if not post:
|
||||
raise CalendarError(f"Post {post_id} does not exist.")
|
||||
|
||||
# Enforce: calendars can only be created on pages with the calendar feature
|
||||
if not post.is_page:
|
||||
raise CalendarError("Calendars can only be created on pages, not posts.")
|
||||
|
||||
# Look for existing (including soft-deleted)
|
||||
q = await sess.execute(
|
||||
select(Calendar).where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.name == name)
|
||||
)
|
||||
existing = q.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
if existing.deleted_at is not None:
|
||||
existing.deleted_at = None # revive
|
||||
await sess.flush()
|
||||
await attach_child(sess, "page", post_id, "calendar", existing.id)
|
||||
return existing
|
||||
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
|
||||
|
||||
cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
|
||||
sess.add(cal)
|
||||
await sess.flush()
|
||||
await attach_child(sess, "page", post_id, "calendar", cal.id)
|
||||
return cal
|
||||
|
||||
|
||||
28
events/bp/day/admin/routes.py
Normal file
28
events/bp/day/admin/routes.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
render_template, make_response, Blueprint
|
||||
)
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(year: int, month: int, day: int, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/day/admin/index.html")
|
||||
else:
|
||||
html = await render_template("_types/day/admin/_oob_elements.html")
|
||||
|
||||
return await make_response(html)
|
||||
return bp
|
||||
154
events/bp/day/routes.py
Normal file
154
events/bp/day/routes.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
||||
)
|
||||
|
||||
from bp.calendar.services import get_visible_entries_for_period
|
||||
|
||||
from bp.calendar_entries.routes import register as register_calendar_entries
|
||||
from .admin.routes import register as register_admin
|
||||
|
||||
from shared.browser.app.redis_cacher import cache_page
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
from models.calendars import CalendarSlot # add this import
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("day", __name__, url_prefix='/day/<int:year>/<int:month>/<int:day>')
|
||||
|
||||
bp.register_blueprint(
|
||||
register_calendar_entries()
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_admin()
|
||||
)
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
view_args = getattr(request, "view_args", {}) or {}
|
||||
day = view_args.get("day")
|
||||
month = view_args.get("month")
|
||||
year = view_args.get("year")
|
||||
|
||||
calendar = getattr(g, "calendar", None)
|
||||
if not calendar:
|
||||
return {}
|
||||
|
||||
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 & admin flag
|
||||
user = getattr(g, "user", None)
|
||||
session_id = qsession.get("calendar_sid")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# --- NEW: slots for this weekday ---
|
||||
weekday_attr = ["mon","tue","wed","thu","fri","sat","sun"][day_date.weekday()]
|
||||
|
||||
stmt = (
|
||||
select(CalendarSlot)
|
||||
.where(
|
||||
CalendarSlot.calendar_id == calendar.id,
|
||||
getattr(CalendarSlot, weekday_attr) == True, # noqa: E712
|
||||
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())
|
||||
|
||||
# Fetch container nav from market (skip calendar — we're on a calendar page)
|
||||
container_nav_html = ""
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = post_data["post"]["id"]
|
||||
post_slug = post_data["post"]["slug"]
|
||||
container_nav_html = await fetch_fragment("market", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": post_slug,
|
||||
})
|
||||
|
||||
return {
|
||||
"qsession": qsession,
|
||||
"day_date": day_date,
|
||||
"day": day,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"day_entries": visible.merged_entries,
|
||||
"user_entries": visible.user_entries,
|
||||
"confirmed_entries": visible.confirmed_entries,
|
||||
"day_slots": day_slots,
|
||||
"container_nav_html": container_nav_html,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@cache_page(tag="calendars")
|
||||
async def show_day(year: int, month: int, day: int, **kwargs):
|
||||
"""
|
||||
Show a detail view for a single calendar day.
|
||||
|
||||
Visibility rules:
|
||||
- Non-admin:
|
||||
- all *confirmed* entries for that day (any user)
|
||||
- all entries for current user/session (any state) for that day
|
||||
(pending/ordered/provisional/confirmed)
|
||||
- Admin:
|
||||
- all confirmed + provisional + ordered entries for that day (all users)
|
||||
- pending only for current user/session
|
||||
"""
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/day/index.html",
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/day/_oob_elements.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/w/<widget_domain>/")
|
||||
async def widget_paginate(widget_domain: str, **kwargs):
|
||||
"""Proxies paginated widget requests to the appropriate fragment provider."""
|
||||
page = int(request.args.get("page", 1))
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if not post_data:
|
||||
abort(404)
|
||||
post_id = post_data["post"]["id"]
|
||||
post_slug = post_data["post"]["slug"]
|
||||
|
||||
if widget_domain == "market":
|
||||
html = await fetch_fragment("market", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": post_slug,
|
||||
})
|
||||
return await make_response(html or "")
|
||||
abort(404)
|
||||
|
||||
return bp
|
||||
1
events/bp/fragments/__init__.py
Normal file
1
events/bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import register as register_fragments
|
||||
130
events/bp/fragments/routes.py
Normal file
130
events/bp/fragments/routes.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Events app fragment endpoints.
|
||||
|
||||
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, g, render_template, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/html")
|
||||
html = await handler()
|
||||
return Response(html, status=200, content_type="text/html")
|
||||
|
||||
# --- container-nav fragment: calendar entries + calendar links -----------
|
||||
|
||||
async def _container_nav_handler():
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = int(request.args.get("container_id", 0))
|
||||
post_slug = request.args.get("post_slug", "")
|
||||
paginate_url_base = request.args.get("paginate_url", "")
|
||||
page = int(request.args.get("page", 1))
|
||||
exclude = request.args.get("exclude", "")
|
||||
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
|
||||
|
||||
html_parts = []
|
||||
|
||||
# Calendar entries nav
|
||||
if not any(e.startswith("calendar") for e in excludes):
|
||||
entries, has_more = await services.calendar.associated_entries(
|
||||
g.s, container_type, container_id, page,
|
||||
)
|
||||
if entries:
|
||||
html_parts.append(await render_template(
|
||||
"fragments/container_nav_entries.html",
|
||||
entries=entries, has_more=has_more,
|
||||
page=page, post_slug=post_slug,
|
||||
paginate_url_base=paginate_url_base,
|
||||
))
|
||||
|
||||
# Calendar links nav
|
||||
if not any(e.startswith("calendar") for e in excludes):
|
||||
calendars = await services.calendar.calendars_for_container(
|
||||
g.s, container_type, container_id,
|
||||
)
|
||||
if calendars:
|
||||
html_parts.append(await render_template(
|
||||
"fragments/container_nav_calendars.html",
|
||||
calendars=calendars, post_slug=post_slug,
|
||||
))
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
_handlers["container-nav"] = _container_nav_handler
|
||||
|
||||
# --- container-cards fragment: entries for blog listing cards ------------
|
||||
|
||||
async def _container_cards_handler():
|
||||
post_ids_raw = request.args.get("post_ids", "")
|
||||
post_slugs_raw = request.args.get("post_slugs", "")
|
||||
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
|
||||
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
|
||||
if not post_ids:
|
||||
return ""
|
||||
|
||||
# Build post_id -> slug mapping
|
||||
slug_map = {}
|
||||
for i, pid in enumerate(post_ids):
|
||||
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
|
||||
|
||||
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
|
||||
return await render_template(
|
||||
"fragments/container_cards_entries.html",
|
||||
batch=batch, post_ids=post_ids, slug_map=slug_map,
|
||||
)
|
||||
|
||||
_handlers["container-cards"] = _container_cards_handler
|
||||
|
||||
# --- account-nav-item fragment: tickets + bookings links for account nav -
|
||||
|
||||
async def _account_nav_item_handler():
|
||||
return await render_template("fragments/account_nav_items.html")
|
||||
|
||||
_handlers["account-nav-item"] = _account_nav_item_handler
|
||||
|
||||
# --- account-page fragment: tickets or bookings panel --------------------
|
||||
|
||||
async def _account_page_handler():
|
||||
slug = request.args.get("slug", "")
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
if not user_id:
|
||||
return ""
|
||||
|
||||
if slug == "tickets":
|
||||
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
|
||||
return await render_template(
|
||||
"fragments/account_page_tickets.html",
|
||||
tickets=tickets,
|
||||
)
|
||||
elif slug == "bookings":
|
||||
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
|
||||
return await render_template(
|
||||
"fragments/account_page_bookings.html",
|
||||
bookings=bookings,
|
||||
)
|
||||
return ""
|
||||
|
||||
_handlers["account-page"] = _account_page_handler
|
||||
|
||||
bp._fragment_handlers = _handlers
|
||||
|
||||
return bp
|
||||
0
events/bp/markets/__init__.py
Normal file
0
events/bp/markets/__init__.py
Normal file
65
events/bp/markets/routes.py
Normal file
65
events/bp/markets/routes.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g
|
||||
)
|
||||
|
||||
from .services.markets import (
|
||||
create_market as svc_create_market,
|
||||
soft_delete as svc_soft_delete,
|
||||
)
|
||||
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("markets", __name__, url_prefix='/markets')
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
return {}
|
||||
|
||||
@bp.get("/")
|
||||
async def home(**kwargs):
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/markets/index.html")
|
||||
else:
|
||||
html = await render_template("_types/markets/_oob_elements.html")
|
||||
return await make_response(html)
|
||||
|
||||
@bp.post("/new/")
|
||||
@require_admin
|
||||
async def create_market(**kwargs):
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
|
||||
post_data = getattr(g, "post_data", None)
|
||||
post_id = (post_data.get("post") or {}).get("id") if post_data else None
|
||||
|
||||
if not post_id:
|
||||
post_id = form.get("post_id")
|
||||
if post_id:
|
||||
post_id = int(post_id)
|
||||
|
||||
try:
|
||||
await svc_create_market(g.s, post_id, name)
|
||||
except Exception as e:
|
||||
return await make_response(f'<div class="text-red-600 text-sm">{e}</div>', 422)
|
||||
|
||||
html = await render_template("_types/markets/index.html")
|
||||
return await make_response(html)
|
||||
|
||||
@bp.delete("/<market_slug>/")
|
||||
@require_admin
|
||||
async def delete_market(market_slug: str, **kwargs):
|
||||
post_slug = getattr(g, "post_slug", None)
|
||||
deleted = await svc_soft_delete(g.s, post_slug, market_slug)
|
||||
if not deleted:
|
||||
return await make_response("Market not found", 404)
|
||||
|
||||
html = await render_template("_types/markets/index.html")
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
0
events/bp/markets/services/__init__.py
Normal file
0
events/bp/markets/services/__init__.py
Normal file
57
events/bp/markets/services/markets.py
Normal file
57
events/bp/markets/services/markets.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.contracts.dtos import MarketPlaceDTO
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
class MarketError(ValueError):
|
||||
"""Base error for market service operations."""
|
||||
|
||||
|
||||
def slugify(value: str, max_len: int = 255) -> str:
|
||||
if value is None:
|
||||
value = ""
|
||||
value = unicodedata.normalize("NFKD", value)
|
||||
value = value.encode("ascii", "ignore").decode("ascii")
|
||||
value = value.lower()
|
||||
value = value.replace("/", "-")
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
value = re.sub(r"-{2,}", "-", value)
|
||||
value = value.strip("-")[:max_len].strip("-")
|
||||
return value or "market"
|
||||
|
||||
|
||||
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
|
||||
"""
|
||||
Create a market for a page. Name must be unique per page.
|
||||
If a market with the same (post_id, slug) exists but is soft-deleted,
|
||||
it will be revived.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise MarketError("Market name must not be empty.")
|
||||
slug = slugify(name)
|
||||
|
||||
post = await services.blog.get_post_by_id(sess, post_id)
|
||||
if not post:
|
||||
raise MarketError(f"Post {post_id} does not exist.")
|
||||
if not post.is_page:
|
||||
raise MarketError("Markets can only be created on pages, not posts.")
|
||||
|
||||
try:
|
||||
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
|
||||
except ValueError as e:
|
||||
raise MarketError(str(e)) from e
|
||||
|
||||
|
||||
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
|
||||
0
events/bp/page/__init__.py
Normal file
0
events/bp/page/__init__.py
Normal file
129
events/bp/page/routes.py
Normal file
129
events/bp/page/routes.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Page summary blueprint — shows upcoming events for a single page's calendars.
|
||||
|
||||
Routes:
|
||||
GET /<slug>/ — full page scoped to this page
|
||||
GET /<slug>/entries — HTMX fragment for infinite scroll
|
||||
POST /<slug>/tickets/adjust — adjust ticket quantity inline
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, render_template_string, make_response
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("page_summary", __name__)
|
||||
|
||||
async def _load_entries(post_id, page, per_page=20):
|
||||
"""Load upcoming entries for this page + pending ticket counts."""
|
||||
entries, has_more = await services.calendar.upcoming_entries_for_container(
|
||||
g.s, "page", post_id, page=page, per_page=per_page,
|
||||
)
|
||||
|
||||
# Pending ticket counts keyed by entry_id
|
||||
ident = current_cart_identity()
|
||||
pending_tickets = {}
|
||||
if entries:
|
||||
tickets = await services.calendar.pending_tickets(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
for t in tickets:
|
||||
if t.entry_id is not None:
|
||||
pending_tickets[t.entry_id] = pending_tickets.get(t.entry_id, 0) + 1
|
||||
|
||||
return entries, has_more, pending_tickets
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
post = g.post_data["post"]
|
||||
view = request.args.get("view", "list")
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
|
||||
|
||||
ctx = dict(
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
pending_tickets=pending_tickets,
|
||||
page_info={},
|
||||
page=page,
|
||||
view=view,
|
||||
)
|
||||
|
||||
if is_htmx_request():
|
||||
html = await render_template("_types/page_summary/_main_panel.html", **ctx)
|
||||
else:
|
||||
html = await render_template("_types/page_summary/index.html", **ctx)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/entries")
|
||||
async def entries_fragment():
|
||||
post = g.post_data["post"]
|
||||
view = request.args.get("view", "list")
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
|
||||
|
||||
html = await render_template(
|
||||
"_types/page_summary/_cards.html",
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
pending_tickets=pending_tickets,
|
||||
page_info={},
|
||||
page=page,
|
||||
view=view,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/tickets/adjust")
|
||||
async def adjust_ticket():
|
||||
"""Adjust ticket quantity, return updated widget + OOB cart-mini."""
|
||||
ident = current_cart_identity()
|
||||
form = await request.form
|
||||
entry_id = int(form.get("entry_id", 0))
|
||||
count = max(int(form.get("count", 0)), 0)
|
||||
tt_raw = (form.get("ticket_type_id") or "").strip()
|
||||
ticket_type_id = int(tt_raw) if tt_raw else None
|
||||
|
||||
await services.calendar.adjust_ticket_quantity(
|
||||
g.s, entry_id, count,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
ticket_type_id=ticket_type_id,
|
||||
)
|
||||
|
||||
# Get updated ticket count for this entry
|
||||
tickets = await services.calendar.pending_tickets(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
qty = sum(1 for t in tickets if t.entry_id == entry_id)
|
||||
|
||||
# Load entry DTO for the widget template
|
||||
entry = await services.calendar.entry_by_id(g.s, entry_id)
|
||||
|
||||
# Updated cart count for OOB mini-cart
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
|
||||
# Render widget + OOB cart-mini
|
||||
widget_html = await render_template(
|
||||
"_types/page_summary/_ticket_widget.html",
|
||||
entry=entry,
|
||||
qty=qty,
|
||||
ticket_url=f"/{g.post_slug}/tickets/adjust",
|
||||
)
|
||||
mini_html = await render_template_string(
|
||||
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||
'{{ mini(oob="true") }}',
|
||||
cart_count=cart_count,
|
||||
)
|
||||
return await make_response(widget_html + mini_html, 200)
|
||||
|
||||
return bp
|
||||
0
events/bp/payments/__init__.py
Normal file
0
events/bp/payments/__init__.py
Normal file
81
events/bp/payments/routes.py
Normal file
81
events/bp/payments/routes.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
render_template, make_response, Blueprint, g, request
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("payments", __name__, url_prefix='/payments')
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
return {}
|
||||
|
||||
async def _load_payment_ctx():
|
||||
"""Load PageConfig SumUp data for the current page."""
|
||||
post = (getattr(g, "post_data", None) or {}).get("post", {})
|
||||
post_id = post.get("id")
|
||||
if not post_id:
|
||||
return {}
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"sumup_configured": bool(pc and pc.sumup_api_key),
|
||||
"sumup_merchant_code": (pc.sumup_merchant_code or "") if pc else "",
|
||||
"sumup_checkout_prefix": (pc.sumup_checkout_prefix or "") if pc else "",
|
||||
}
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def home(**kwargs):
|
||||
ctx = await _load_payment_ctx()
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/payments/index.html", **ctx)
|
||||
else:
|
||||
html = await render_template("_types/payments/_oob_elements.html", **ctx)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
async def update_sumup(**kwargs):
|
||||
"""Update SumUp credentials for this page."""
|
||||
post = (getattr(g, "post_data", None) or {}).get("post", {})
|
||||
post_id = post.get("id")
|
||||
if not post_id:
|
||||
return await make_response("Post not found", 404)
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
if pc is None:
|
||||
pc = PageConfig(container_type="page", container_id=post_id, features={})
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
|
||||
form = await request.form
|
||||
merchant_code = (form.get("merchant_code") or "").strip()
|
||||
api_key = (form.get("api_key") or "").strip()
|
||||
checkout_prefix = (form.get("checkout_prefix") or "").strip()
|
||||
|
||||
pc.sumup_merchant_code = merchant_code or None
|
||||
pc.sumup_checkout_prefix = checkout_prefix or None
|
||||
if api_key:
|
||||
pc.sumup_api_key = api_key
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
ctx = await _load_payment_ctx()
|
||||
html = await render_template("_types/payments/_main_panel.html", **ctx)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
182
events/bp/slot/routes.py
Normal file
182
events/bp/slot/routes.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.slot import (
|
||||
update_slot as svc_update_slot,
|
||||
soft_delete_slot as svc_delete_slot,
|
||||
get_slot as svc_get_slot,
|
||||
)
|
||||
|
||||
from ..slots.services.slots import (
|
||||
list_slots as svc_list_slots,
|
||||
)
|
||||
|
||||
from shared.browser.app.utils import (
|
||||
parse_time,
|
||||
parse_cost
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(slot_id: int, **kwargs):
|
||||
slot = await svc_get_slot(g.s, slot_id)
|
||||
if not slot:
|
||||
return await make_response("Not found", 404)
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/slot/index.html",
|
||||
slot=slot,
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/slot/_oob_elements.html",
|
||||
slot=slot,
|
||||
)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_admin
|
||||
async def get_edit(slot_id: int, **kwargs):
|
||||
slot = await svc_get_slot(g.s, slot_id)
|
||||
if not slot:
|
||||
return await make_response("Not found", 404)
|
||||
html = await render_template(
|
||||
"_types/slot/_edit.html",
|
||||
slot=slot,
|
||||
#post=g.post_data['post'],
|
||||
#calendar=g.calendar,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/view/")
|
||||
@require_admin
|
||||
async def get_view(slot_id: int, **kwargs):
|
||||
slot = await svc_get_slot(g.s, slot_id)
|
||||
if not slot:
|
||||
return await make_response("Not found", 404)
|
||||
html = await render_template(
|
||||
"_types/slot/_main_panel.html",
|
||||
slot=slot,
|
||||
#post=g.post_data['post'],
|
||||
#calendar=g.calendar,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.delete("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def slot_delete(slot_id: int, **kwargs):
|
||||
await svc_delete_slot(g.s, slot_id)
|
||||
slots = await svc_list_slots(g.s, g.calendar.id)
|
||||
html = await render_template("_types/slots/_man_panel.html", calendar=g.calendar, slots=slots)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def put(slot_id: int, **kwargs):
|
||||
form = await request.form
|
||||
|
||||
name = (form.get("name") or "").strip()
|
||||
description = (form.get("description") or "").strip() or None
|
||||
days = {k: form.get(k) for k in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}
|
||||
time_start = parse_time(form.get("time_start"))
|
||||
time_end = parse_time(form.get("time_end"))
|
||||
cost = parse_cost(form.get("cost"))
|
||||
|
||||
# NEW
|
||||
flexible = bool(form.get("flexible"))
|
||||
|
||||
field_errors: dict[str, list[str]] = {}
|
||||
|
||||
# Basic validation...
|
||||
if not name:
|
||||
field_errors.setdefault("name", []).append("Please enter a name for the slot.")
|
||||
|
||||
if not time_start:
|
||||
field_errors.setdefault("time_start", []).append("Please select a start time.")
|
||||
|
||||
if not time_end:
|
||||
field_errors.setdefault("time_end", []).append("Please select an end time.")
|
||||
|
||||
if time_start and time_end and time_end <= time_start:
|
||||
field_errors.setdefault("time_end", []).append(
|
||||
"End time must be after the start time."
|
||||
)
|
||||
|
||||
if not any(form.get(d) for d in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]):
|
||||
field_errors.setdefault("days", []).append(
|
||||
"Please select at least one day."
|
||||
)
|
||||
|
||||
if field_errors:
|
||||
return jsonify(
|
||||
{
|
||||
"message": "Please fix the highlighted fields.",
|
||||
"errors": field_errors,
|
||||
}
|
||||
), 422
|
||||
|
||||
# DB update + friendly duplicate handling
|
||||
try:
|
||||
slot = await svc_update_slot(
|
||||
g.s,
|
||||
slot_id,
|
||||
name=name,
|
||||
description=description,
|
||||
days=days,
|
||||
time_start=time_start,
|
||||
time_end=time_end,
|
||||
cost=cost,
|
||||
flexible=flexible, # <--- NEW
|
||||
)
|
||||
except IntegrityError as e:
|
||||
msg = str(e.orig) if getattr(e, "orig", None) else str(e)
|
||||
if "uq_calendar_slots_unique_band" in msg or "duplicate key value" in msg:
|
||||
field_errors = {
|
||||
"name": [f'A slot called “{name}” already exists on this calendar.']
|
||||
}
|
||||
return jsonify(
|
||||
{
|
||||
"message": "That slot name is already in use.",
|
||||
"errors": field_errors,
|
||||
}
|
||||
), 422
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"message": "An unexpected error occurred while updating the slot.",
|
||||
"errors": {"__all__": [msg]},
|
||||
}
|
||||
), 422
|
||||
|
||||
html = await render_template(
|
||||
"_types/slot/_main_panel.html",
|
||||
slot=slot,
|
||||
oob=True,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
|
||||
return bp
|
||||
91
events/bp/slot/services/slot.py
Normal file
91
events/bp/slot/services/slot.py
Normal file
@@ -0,0 +1,91 @@
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import time
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
def _b(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
s = str(v).lower()
|
||||
return s in {"1","true","t","yes","y","on"}
|
||||
|
||||
|
||||
async def update_slot(
|
||||
sess: AsyncSession,
|
||||
slot_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
days: dict | None = None,
|
||||
time_start: time | None = None,
|
||||
time_end: time | None = None,
|
||||
cost: float | None = None,
|
||||
flexible: bool | None = None, # NEW
|
||||
):
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot or slot.deleted_at is not None:
|
||||
raise SlotError("slot not found")
|
||||
|
||||
if name is not None:
|
||||
slot.name = name
|
||||
|
||||
if description is not None:
|
||||
slot.description = description or None
|
||||
|
||||
if days is not None:
|
||||
slot.mon = _b(days.get("mon", slot.mon))
|
||||
slot.tue = _b(days.get("tue", slot.tue))
|
||||
slot.wed = _b(days.get("wed", slot.wed))
|
||||
slot.thu = _b(days.get("thu", slot.thu))
|
||||
slot.fri = _b(days.get("fri", slot.fri))
|
||||
slot.sat = _b(days.get("sat", slot.sat))
|
||||
slot.sun = _b(days.get("sun", slot.sun))
|
||||
|
||||
if time_start is not None:
|
||||
slot.time_start = time_start
|
||||
if time_end is not None:
|
||||
slot.time_end = time_end
|
||||
|
||||
if (time_start or time_end) and slot.time_end <= slot.time_start:
|
||||
raise SlotError("time range invalid")
|
||||
|
||||
if cost is not None:
|
||||
slot.cost = cost
|
||||
|
||||
# NEW: update flexible flag only if explicitly provided
|
||||
if flexible is not None:
|
||||
slot.flexible = flexible
|
||||
|
||||
await sess.flush()
|
||||
return slot
|
||||
|
||||
async def soft_delete_slot(sess: AsyncSession, slot_id: int):
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot or slot.deleted_at is not None:
|
||||
return
|
||||
from datetime import datetime, timezone
|
||||
slot.deleted_at = datetime.now(timezone.utc)
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def get_slot(sess: AsyncSession, slot_id: int) -> CalendarSlot | None:
|
||||
return await sess.get(CalendarSlot, slot_id)
|
||||
|
||||
async def update_slot_description(
|
||||
sess: AsyncSession,
|
||||
slot_id: int,
|
||||
description: str | None,
|
||||
) -> CalendarSlot:
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot:
|
||||
raise SlotError("slot not found")
|
||||
slot.description = description or None
|
||||
await sess.flush()
|
||||
return slot
|
||||
152
events/bp/slots/routes.py
Normal file
152
events/bp/slots/routes.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.slots import (
|
||||
list_slots as svc_list_slots,
|
||||
create_slot as svc_create_slot,
|
||||
)
|
||||
|
||||
from ..slot.routes import register as register_slot
|
||||
|
||||
from shared.browser.app.utils import (
|
||||
parse_time,
|
||||
parse_cost
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("slots", __name__, url_prefix='/slots')
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
bp.register_blueprint(
|
||||
register_slot()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@bp.context_processor
|
||||
async def get_slots():
|
||||
calendar = getattr(g, "calendar", None)
|
||||
if calendar:
|
||||
return {
|
||||
"slots": await svc_list_slots(g.s, calendar.id)
|
||||
}
|
||||
return {"slots": []}
|
||||
|
||||
@bp.get("/")
|
||||
async def get(**kwargs):
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/slots/index.html",
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/slots/_oob_elements.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def post(**kwargs):
|
||||
form = await request.form
|
||||
|
||||
name = (form.get("name") or "").strip()
|
||||
description = (form.get("description") or "").strip() or None
|
||||
days = {k: form.get(k) for k in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}
|
||||
time_start = parse_time(form.get("time_start"))
|
||||
time_end = parse_time(form.get("time_end"))
|
||||
cost = parse_cost(form.get("cost"))
|
||||
|
||||
# NEW: flexible flag from checkbox
|
||||
flexible = bool(form.get("flexible"))
|
||||
|
||||
field_errors: dict[str, list[str]] = {}
|
||||
|
||||
if not name:
|
||||
field_errors.setdefault("name", []).append("Please enter a name for the slot.")
|
||||
|
||||
if not time_start:
|
||||
field_errors.setdefault("time_start", []).append("Please select a start time.")
|
||||
|
||||
if not time_end:
|
||||
field_errors.setdefault("time_end", []).append("Please select an end time.")
|
||||
|
||||
if time_start and time_end and time_end <= time_start:
|
||||
field_errors.setdefault("time_end", []).append("End time must be after the start time.")
|
||||
|
||||
if not any(form.get(d) for d in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]):
|
||||
field_errors.setdefault("days", []).append("Please select at least one day.")
|
||||
|
||||
if field_errors:
|
||||
return jsonify({
|
||||
"message": "Please fix the highlighted fields.",
|
||||
"errors": field_errors,
|
||||
}), 422
|
||||
|
||||
# DB insert with friendly duplicate detection
|
||||
try:
|
||||
await svc_create_slot(
|
||||
g.s,
|
||||
g.calendar.id,
|
||||
name=name,
|
||||
description=description,
|
||||
days=days,
|
||||
time_start=time_start,
|
||||
time_end=time_end,
|
||||
cost=cost,
|
||||
flexible=flexible, # <<< NEW
|
||||
)
|
||||
except IntegrityError as e:
|
||||
# Improve duplicate detection: check constraint name or message
|
||||
msg = str(e.orig) if getattr(e, "orig", None) else str(e)
|
||||
if "uq_calendar_slots_unique_band" in msg or "duplicate key value" in msg:
|
||||
field_errors = {
|
||||
"name": [f"A slot called “{name}” already exists on this calendar."]
|
||||
}
|
||||
return jsonify({
|
||||
"message": "That slot name is already in use.",
|
||||
"errors": field_errors,
|
||||
}), 422
|
||||
|
||||
# Unknown DB error
|
||||
return jsonify({
|
||||
"message": "An unexpected error occurred while saving the slot.",
|
||||
"errors": {"__all__": [msg]},
|
||||
}), 422
|
||||
|
||||
# Success → re-render the slots table
|
||||
html = await render_template("_types/slots/_main_panel.html")
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/add")
|
||||
@require_admin
|
||||
async def add_form(**kwargs):
|
||||
html = await render_template(
|
||||
"_types/slots/_add.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/add-button")
|
||||
@require_admin
|
||||
async def add_button(**kwargs):
|
||||
|
||||
html = await render_template(
|
||||
"_types/slots/_add_button.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
65
events/bp/slots/services/slots.py
Normal file
65
events/bp/slots/services/slots.py
Normal file
@@ -0,0 +1,65 @@
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import time
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
def _b(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
s = str(v).lower()
|
||||
return s in {"1","true","t","yes","y","on"}
|
||||
|
||||
async def list_slots(sess: AsyncSession, calendar_id: int) -> Sequence[CalendarSlot]:
|
||||
res = await sess.execute(
|
||||
select(CalendarSlot)
|
||||
.where(CalendarSlot.calendar_id == calendar_id, CalendarSlot.deleted_at.is_(None))
|
||||
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
|
||||
)
|
||||
return res.scalars().all()
|
||||
|
||||
async def create_slot(
|
||||
sess: AsyncSession,
|
||||
calendar_id: int,
|
||||
*,
|
||||
name: str,
|
||||
description: str | None,
|
||||
days: dict,
|
||||
time_start: time,
|
||||
time_end: time,
|
||||
cost: float | None,
|
||||
flexible: bool = False, # NEW
|
||||
):
|
||||
if not name:
|
||||
raise SlotError("name is required")
|
||||
|
||||
if not time_start or not time_end or time_end <= time_start:
|
||||
raise SlotError("time range invalid")
|
||||
|
||||
slot = CalendarSlot(
|
||||
calendar_id=calendar_id,
|
||||
name=name,
|
||||
description=(description or None),
|
||||
mon=_b(days.get("mon")),
|
||||
tue=_b(days.get("tue")),
|
||||
wed=_b(days.get("wed")),
|
||||
thu=_b(days.get("thu")),
|
||||
fri=_b(days.get("fri")),
|
||||
sat=_b(days.get("sat")),
|
||||
sun=_b(days.get("sun")),
|
||||
time_start=time_start,
|
||||
time_end=time_end,
|
||||
cost=cost,
|
||||
flexible=flexible, # NEW
|
||||
)
|
||||
sess.add(slot)
|
||||
await sess.flush()
|
||||
return slot
|
||||
0
events/bp/ticket_admin/__init__.py
Normal file
0
events/bp/ticket_admin/__init__.py
Normal file
166
events/bp/ticket_admin/routes.py
Normal file
166
events/bp/ticket_admin/routes.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Ticket admin blueprint — check-in interface and ticket management.
|
||||
|
||||
Routes:
|
||||
GET /admin/tickets/ — Ticket dashboard (scan + list)
|
||||
GET /admin/tickets/entry/<id>/ — Tickets for a specific entry
|
||||
POST /admin/tickets/<code>/checkin — Check in a ticket
|
||||
GET /admin/tickets/<code>/ — Ticket admin detail
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from quart import (
|
||||
Blueprint, g, request, render_template, make_response, jsonify,
|
||||
)
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import CalendarEntry, Ticket, TicketType
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from ..tickets.services.tickets import (
|
||||
get_ticket_by_code,
|
||||
get_tickets_for_entry,
|
||||
checkin_ticket,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def dashboard():
|
||||
"""Ticket admin dashboard with QR scanner and recent tickets."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Get recent tickets
|
||||
result = await g.s.execute(
|
||||
select(Ticket)
|
||||
.options(
|
||||
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
||||
selectinload(Ticket.ticket_type),
|
||||
)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
# Stats
|
||||
total = await g.s.scalar(select(func.count(Ticket.id)))
|
||||
confirmed = await g.s.scalar(
|
||||
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
|
||||
)
|
||||
checked_in = await g.s.scalar(
|
||||
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
|
||||
)
|
||||
reserved = await g.s.scalar(
|
||||
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
|
||||
)
|
||||
|
||||
stats = {
|
||||
"total": total or 0,
|
||||
"confirmed": confirmed or 0,
|
||||
"checked_in": checked_in or 0,
|
||||
"reserved": reserved or 0,
|
||||
}
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/index.html",
|
||||
tickets=tickets,
|
||||
stats=stats,
|
||||
)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/_main_panel.html",
|
||||
tickets=tickets,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/entry/<int:entry_id>/")
|
||||
@require_admin
|
||||
async def entry_tickets(entry_id: int):
|
||||
"""List all tickets for a specific calendar entry."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
entry = await g.s.scalar(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
if not entry:
|
||||
return await make_response("Entry not found", 404)
|
||||
|
||||
tickets = await get_tickets_for_entry(g.s, entry_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/_entry_tickets.html",
|
||||
entry=entry,
|
||||
tickets=tickets,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/lookup/")
|
||||
@require_admin
|
||||
async def lookup():
|
||||
"""Look up a ticket by code (used by scanner)."""
|
||||
code = request.args.get("code", "").strip()
|
||||
if not code:
|
||||
return await make_response(
|
||||
'<div class="text-sm text-stone-500">Enter a ticket code</div>',
|
||||
200,
|
||||
)
|
||||
|
||||
ticket = await get_ticket_by_code(g.s, code)
|
||||
if not ticket:
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/_lookup_result.html",
|
||||
ticket=None,
|
||||
error="Ticket not found",
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/_lookup_result.html",
|
||||
ticket=ticket,
|
||||
error=None,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/<code>/checkin/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def do_checkin(code: str):
|
||||
"""Check in a ticket by its code."""
|
||||
success, error = await checkin_ticket(g.s, code)
|
||||
|
||||
if not success:
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/_checkin_result.html",
|
||||
success=False,
|
||||
error=error,
|
||||
ticket=None,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
ticket = await get_ticket_by_code(g.s, code)
|
||||
html = await render_template(
|
||||
"_types/ticket_admin/_checkin_result.html",
|
||||
success=True,
|
||||
error=None,
|
||||
ticket=ticket,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
return bp
|
||||
0
events/bp/ticket_admin/services/__init__.py
Normal file
0
events/bp/ticket_admin/services/__init__.py
Normal file
159
events/bp/ticket_type/routes.py
Normal file
159
events/bp/ticket_type/routes.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.ticket import (
|
||||
get_ticket_type as svc_get_ticket_type,
|
||||
update_ticket_type as svc_update_ticket_type,
|
||||
soft_delete_ticket_type as svc_delete_ticket_type,
|
||||
)
|
||||
|
||||
from ..ticket_types.services.tickets import (
|
||||
list_ticket_types as svc_list_ticket_types,
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(ticket_type_id: int, **kwargs):
|
||||
"""View a single ticket type."""
|
||||
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||
if not ticket_type:
|
||||
return await make_response("Not found", 404)
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/ticket_type/index.html",
|
||||
ticket_type=ticket_type,
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/ticket_type/_oob_elements.html",
|
||||
ticket_type=ticket_type,
|
||||
)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_admin
|
||||
async def get_edit(ticket_type_id: int, **kwargs):
|
||||
"""Show the edit form for a ticket type."""
|
||||
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||
if not ticket_type:
|
||||
return await make_response("Not found", 404)
|
||||
|
||||
html = await render_template(
|
||||
"_types/ticket_type/_edit.html",
|
||||
ticket_type=ticket_type,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/view/")
|
||||
@require_admin
|
||||
async def get_view(ticket_type_id: int, **kwargs):
|
||||
"""Show the view for a ticket type."""
|
||||
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||
if not ticket_type:
|
||||
return await make_response("Not found", 404)
|
||||
|
||||
html = await render_template(
|
||||
"_types/ticket_type/_main_panel.html",
|
||||
ticket_type=ticket_type,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def put(ticket_type_id: int, **kwargs):
|
||||
"""Update a ticket type."""
|
||||
form = await request.form
|
||||
|
||||
name = (form.get("name") or "").strip()
|
||||
cost_str = (form.get("cost") or "").strip()
|
||||
count_str = (form.get("count") or "").strip()
|
||||
|
||||
field_errors: dict[str, list[str]] = {}
|
||||
|
||||
# Validate name
|
||||
if not name:
|
||||
field_errors.setdefault("name", []).append("Please enter a ticket type name.")
|
||||
|
||||
# Validate cost
|
||||
cost = None
|
||||
if not cost_str:
|
||||
field_errors.setdefault("cost", []).append("Please enter a cost.")
|
||||
else:
|
||||
try:
|
||||
cost = float(cost_str)
|
||||
if cost < 0:
|
||||
field_errors.setdefault("cost", []).append("Cost must be positive.")
|
||||
except ValueError:
|
||||
field_errors.setdefault("cost", []).append("Please enter a valid number.")
|
||||
|
||||
# Validate count
|
||||
count = None
|
||||
if not count_str:
|
||||
field_errors.setdefault("count", []).append("Please enter a ticket count.")
|
||||
else:
|
||||
try:
|
||||
count = int(count_str)
|
||||
if count < 0:
|
||||
field_errors.setdefault("count", []).append("Count must be positive.")
|
||||
except ValueError:
|
||||
field_errors.setdefault("count", []).append("Please enter a valid whole number.")
|
||||
|
||||
if field_errors:
|
||||
return jsonify({
|
||||
"message": "Please fix the highlighted fields.",
|
||||
"errors": field_errors,
|
||||
}), 422
|
||||
|
||||
# Update ticket type
|
||||
ticket_type = await svc_update_ticket_type(
|
||||
g.s,
|
||||
ticket_type_id,
|
||||
name=name,
|
||||
cost=cost,
|
||||
count=count,
|
||||
)
|
||||
|
||||
if not ticket_type:
|
||||
return await make_response("Not found", 404)
|
||||
|
||||
# Return updated view with OOB flag
|
||||
html = await render_template(
|
||||
"_types/ticket_type/_main_panel.html",
|
||||
ticket_type=ticket_type,
|
||||
oob=True,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.delete("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def delete(ticket_type_id: int, **kwargs):
|
||||
"""Soft-delete a ticket type."""
|
||||
success = await svc_delete_ticket_type(g.s, ticket_type_id)
|
||||
if not success:
|
||||
return await make_response("Not found", 404)
|
||||
|
||||
# Re-render the ticket types list
|
||||
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
|
||||
html = await render_template(
|
||||
"_types/ticket_types/_main_panel.html",
|
||||
ticket_types=ticket_types
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
57
events/bp/ticket_type/services/ticket.py
Normal file
57
events/bp/ticket_type/services/ticket.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import TicketType
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def get_ticket_type(session: AsyncSession, ticket_type_id: int) -> TicketType | None:
|
||||
"""Get a single ticket type by ID (only if not soft-deleted)."""
|
||||
result = await session.execute(
|
||||
select(TicketType)
|
||||
.where(
|
||||
TicketType.id == ticket_type_id,
|
||||
TicketType.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_ticket_type(
|
||||
session: AsyncSession,
|
||||
ticket_type_id: int,
|
||||
*,
|
||||
name: str,
|
||||
cost: float,
|
||||
count: int,
|
||||
) -> TicketType | None:
|
||||
"""Update an existing ticket type."""
|
||||
ticket_type = await get_ticket_type(session, ticket_type_id)
|
||||
if not ticket_type:
|
||||
return None
|
||||
|
||||
ticket_type.name = name
|
||||
ticket_type.cost = cost
|
||||
ticket_type.count = count
|
||||
ticket_type.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return ticket_type
|
||||
|
||||
|
||||
async def soft_delete_ticket_type(session: AsyncSession, ticket_type_id: int) -> bool:
|
||||
"""Soft-delete a ticket type."""
|
||||
ticket_type = await get_ticket_type(session, ticket_type_id)
|
||||
if not ticket_type:
|
||||
return False
|
||||
|
||||
ticket_type.deleted_at = utcnow()
|
||||
await session.flush()
|
||||
return True
|
||||
132
events/bp/ticket_types/routes.py
Normal file
132
events/bp/ticket_types/routes.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.tickets import (
|
||||
list_ticket_types as svc_list_ticket_types,
|
||||
create_ticket_type as svc_create_ticket_type,
|
||||
)
|
||||
|
||||
from ..ticket_type.routes import register as register_ticket_type
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("ticket_types", __name__, url_prefix='/ticket-types')
|
||||
|
||||
# Register individual ticket routes
|
||||
bp.register_blueprint(
|
||||
register_ticket_type()
|
||||
)
|
||||
|
||||
@bp.context_processor
|
||||
async def get_ticket_types():
|
||||
"""Make ticket types available to all templates in this blueprint."""
|
||||
entry = getattr(g, "entry", None)
|
||||
if entry:
|
||||
return {
|
||||
"ticket_types": await svc_list_ticket_types(g.s, entry.id)
|
||||
}
|
||||
return {"ticket_types": []}
|
||||
|
||||
@bp.get("/")
|
||||
async def get(**kwargs):
|
||||
"""List all ticket types for the current entry."""
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/ticket_types/index.html",
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/ticket_types/_oob_elements.html",
|
||||
)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def post(**kwargs):
|
||||
"""Create a new ticket type."""
|
||||
form = await request.form
|
||||
|
||||
name = (form.get("name") or "").strip()
|
||||
cost_str = (form.get("cost") or "").strip()
|
||||
count_str = (form.get("count") or "").strip()
|
||||
|
||||
field_errors: dict[str, list[str]] = {}
|
||||
|
||||
# Validate name
|
||||
if not name:
|
||||
field_errors.setdefault("name", []).append("Please enter a ticket type name.")
|
||||
|
||||
# Validate cost
|
||||
cost = None
|
||||
if not cost_str:
|
||||
field_errors.setdefault("cost", []).append("Please enter a cost.")
|
||||
else:
|
||||
try:
|
||||
cost = float(cost_str)
|
||||
if cost < 0:
|
||||
field_errors.setdefault("cost", []).append("Cost must be positive.")
|
||||
except ValueError:
|
||||
field_errors.setdefault("cost", []).append("Please enter a valid number.")
|
||||
|
||||
# Validate count
|
||||
count = None
|
||||
if not count_str:
|
||||
field_errors.setdefault("count", []).append("Please enter a ticket count.")
|
||||
else:
|
||||
try:
|
||||
count = int(count_str)
|
||||
if count < 0:
|
||||
field_errors.setdefault("count", []).append("Count must be positive.")
|
||||
except ValueError:
|
||||
field_errors.setdefault("count", []).append("Please enter a valid whole number.")
|
||||
|
||||
if field_errors:
|
||||
return jsonify({
|
||||
"message": "Please fix the highlighted fields.",
|
||||
"errors": field_errors,
|
||||
}), 422
|
||||
|
||||
# Create ticket type
|
||||
await svc_create_ticket_type(
|
||||
g.s,
|
||||
g.entry.id,
|
||||
name=name,
|
||||
cost=cost,
|
||||
count=count,
|
||||
)
|
||||
|
||||
# Success → re-render the ticket types table
|
||||
html = await render_template("_types/ticket_types/_main_panel.html")
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/add")
|
||||
@require_admin
|
||||
async def add_form(**kwargs):
|
||||
"""Show the add ticket type form."""
|
||||
html = await render_template(
|
||||
"_types/ticket_types/_add.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/add-button")
|
||||
@require_admin
|
||||
async def add_button(**kwargs):
|
||||
"""Show the add ticket type button."""
|
||||
html = await render_template(
|
||||
"_types/ticket_types/_add_button.html",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
48
events/bp/ticket_types/services/tickets.py
Normal file
48
events/bp/ticket_types/services/tickets.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import TicketType
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def list_ticket_types(session: AsyncSession, entry_id: int) -> list[TicketType]:
|
||||
"""Get all active ticket types for a calendar entry."""
|
||||
result = await session.execute(
|
||||
select(TicketType)
|
||||
.where(
|
||||
TicketType.entry_id == entry_id,
|
||||
TicketType.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(TicketType.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_ticket_type(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
*,
|
||||
name: str,
|
||||
cost: float,
|
||||
count: int,
|
||||
) -> TicketType:
|
||||
"""Create a new ticket type for a calendar entry."""
|
||||
ticket_type = TicketType(
|
||||
entry_id=entry_id,
|
||||
name=name,
|
||||
cost=cost,
|
||||
count=count,
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
)
|
||||
session.add(ticket_type)
|
||||
await session.flush()
|
||||
return ticket_type
|
||||
0
events/bp/tickets/__init__.py
Normal file
0
events/bp/tickets/__init__.py
Normal file
308
events/bp/tickets/routes.py
Normal file
308
events/bp/tickets/routes.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Tickets blueprint — user-facing ticket views and QR codes.
|
||||
|
||||
Routes:
|
||||
GET /tickets/ — My tickets list
|
||||
GET /tickets/<code>/ — Ticket detail with QR code
|
||||
POST /tickets/buy/ — Purchase tickets for an entry
|
||||
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from quart import (
|
||||
Blueprint, g, request, render_template, make_response,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.tickets import (
|
||||
create_ticket,
|
||||
get_ticket_by_code,
|
||||
get_user_tickets,
|
||||
get_available_ticket_count,
|
||||
get_tickets_for_entry,
|
||||
get_sold_ticket_count,
|
||||
get_user_reserved_count,
|
||||
cancel_latest_reserved_ticket,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
|
||||
|
||||
@bp.get("/")
|
||||
async def my_tickets():
|
||||
"""List all tickets for the current user/session."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
ident = current_cart_identity()
|
||||
tickets = await get_user_tickets(
|
||||
g.s,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/tickets/index.html",
|
||||
tickets=tickets,
|
||||
)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/tickets/_main_panel.html",
|
||||
tickets=tickets,
|
||||
)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/<code>/")
|
||||
async def ticket_detail(code: str):
|
||||
"""View a single ticket with QR code."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
ticket = await get_ticket_by_code(g.s, code)
|
||||
if not ticket:
|
||||
return await make_response("Ticket not found", 404)
|
||||
|
||||
# Verify ownership
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"] is not None:
|
||||
if ticket.user_id != ident["user_id"]:
|
||||
return await make_response("Ticket not found", 404)
|
||||
elif ident["session_id"] is not None:
|
||||
if ticket.session_id != ident["session_id"]:
|
||||
return await make_response("Ticket not found", 404)
|
||||
else:
|
||||
return await make_response("Ticket not found", 404)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/tickets/detail.html",
|
||||
ticket=ticket,
|
||||
)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/tickets/_detail_panel.html",
|
||||
ticket=ticket,
|
||||
)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/buy/")
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def buy_tickets():
|
||||
"""
|
||||
Purchase tickets for a calendar entry.
|
||||
Creates ticket records with state='reserved' (awaiting payment).
|
||||
|
||||
Form fields:
|
||||
entry_id — the calendar entry ID
|
||||
ticket_type_id (optional) — specific ticket type
|
||||
quantity — number of tickets (default 1)
|
||||
"""
|
||||
form = await request.form
|
||||
|
||||
entry_id_raw = form.get("entry_id", "").strip()
|
||||
if not entry_id_raw:
|
||||
return await make_response("Entry ID required", 400)
|
||||
|
||||
try:
|
||||
entry_id = int(entry_id_raw)
|
||||
except ValueError:
|
||||
return await make_response("Invalid entry ID", 400)
|
||||
|
||||
# Load entry
|
||||
entry = await g.s.scalar(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.ticket_types))
|
||||
)
|
||||
if not entry:
|
||||
return await make_response("Entry not found", 404)
|
||||
|
||||
if entry.ticket_price is None:
|
||||
return await make_response("Tickets not available for this entry", 400)
|
||||
|
||||
# Check availability
|
||||
available = await get_available_ticket_count(g.s, entry_id)
|
||||
quantity = int(form.get("quantity", 1))
|
||||
if quantity < 1:
|
||||
quantity = 1
|
||||
|
||||
if available is not None and quantity > available:
|
||||
return await make_response(
|
||||
f"Only {available} ticket(s) remaining", 400
|
||||
)
|
||||
|
||||
# Ticket type (optional)
|
||||
ticket_type_id = None
|
||||
tt_raw = form.get("ticket_type_id", "").strip()
|
||||
if tt_raw:
|
||||
try:
|
||||
ticket_type_id = int(tt_raw)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ident = current_cart_identity()
|
||||
|
||||
# Create tickets
|
||||
created = []
|
||||
for _ in range(quantity):
|
||||
ticket = await create_ticket(
|
||||
g.s,
|
||||
entry_id=entry_id,
|
||||
ticket_type_id=ticket_type_id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
state="reserved",
|
||||
)
|
||||
created.append(ticket)
|
||||
|
||||
# Re-check availability for display
|
||||
remaining = await get_available_ticket_count(g.s, entry_id)
|
||||
all_tickets = await get_tickets_for_entry(g.s, entry_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/tickets/_buy_result.html",
|
||||
entry=entry,
|
||||
created_tickets=created,
|
||||
remaining=remaining,
|
||||
all_tickets=all_tickets,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/adjust/")
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def adjust_quantity():
|
||||
"""
|
||||
Adjust ticket quantity for a calendar entry (+/- pattern).
|
||||
Creates or cancels tickets to reach the target count.
|
||||
|
||||
Form fields:
|
||||
entry_id — the calendar entry ID
|
||||
ticket_type_id — (optional) specific ticket type
|
||||
count — target quantity of reserved tickets
|
||||
"""
|
||||
form = await request.form
|
||||
|
||||
entry_id_raw = form.get("entry_id", "").strip()
|
||||
if not entry_id_raw:
|
||||
return await make_response("Entry ID required", 400)
|
||||
try:
|
||||
entry_id = int(entry_id_raw)
|
||||
except ValueError:
|
||||
return await make_response("Invalid entry ID", 400)
|
||||
|
||||
# Load entry
|
||||
entry = await g.s.scalar(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.ticket_types))
|
||||
)
|
||||
if not entry:
|
||||
return await make_response("Entry not found", 404)
|
||||
if entry.ticket_price is None:
|
||||
return await make_response("Tickets not available for this entry", 400)
|
||||
|
||||
# Ticket type (optional)
|
||||
ticket_type_id = None
|
||||
tt_raw = form.get("ticket_type_id", "").strip()
|
||||
if tt_raw:
|
||||
try:
|
||||
ticket_type_id = int(tt_raw)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
target = max(int(form.get("count", 0)), 0)
|
||||
ident = current_cart_identity()
|
||||
|
||||
current = await get_user_reserved_count(
|
||||
g.s, entry_id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
ticket_type_id=ticket_type_id,
|
||||
)
|
||||
|
||||
if target > current:
|
||||
# Need to add tickets
|
||||
to_add = target - current
|
||||
available = await get_available_ticket_count(g.s, entry_id)
|
||||
if available is not None and to_add > available:
|
||||
return await make_response(
|
||||
f"Only {available} ticket(s) remaining", 400
|
||||
)
|
||||
for _ in range(to_add):
|
||||
await create_ticket(
|
||||
g.s,
|
||||
entry_id=entry_id,
|
||||
ticket_type_id=ticket_type_id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
state="reserved",
|
||||
)
|
||||
elif target < current:
|
||||
# Need to remove tickets
|
||||
to_remove = current - target
|
||||
for _ in range(to_remove):
|
||||
await cancel_latest_reserved_ticket(
|
||||
g.s, entry_id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
ticket_type_id=ticket_type_id,
|
||||
)
|
||||
|
||||
# Build context for re-rendering the buy form
|
||||
ticket_remaining = await get_available_ticket_count(g.s, entry_id)
|
||||
ticket_sold_count = await get_sold_ticket_count(g.s, entry_id)
|
||||
user_ticket_count = await get_user_reserved_count(
|
||||
g.s, entry_id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
)
|
||||
|
||||
# Per-type counts for multi-type entries
|
||||
user_ticket_counts_by_type = {}
|
||||
if entry.ticket_types:
|
||||
for tt in entry.ticket_types:
|
||||
if tt.deleted_at is None:
|
||||
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
||||
g.s, entry_id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
ticket_type_id=tt.id,
|
||||
)
|
||||
|
||||
# Compute cart count for OOB mini-cart update
|
||||
from shared.services.registry import services
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
|
||||
html = await render_template(
|
||||
"_types/tickets/_adjust_response.html",
|
||||
entry=entry,
|
||||
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,
|
||||
cart_count=cart_count,
|
||||
)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
return bp
|
||||
0
events/bp/tickets/services/__init__.py
Normal file
0
events/bp/tickets/services/__init__.py
Normal file
313
events/bp/tickets/services/tickets.py
Normal file
313
events/bp/tickets/services/tickets.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Ticket service layer — create, query, and manage tickets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import Ticket, TicketType, CalendarEntry
|
||||
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
entry_id: int,
|
||||
ticket_type_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
session_id: Optional[str] = None,
|
||||
order_id: Optional[int] = None,
|
||||
state: str = "reserved",
|
||||
) -> Ticket:
|
||||
"""Create a single ticket with a unique code."""
|
||||
ticket = Ticket(
|
||||
entry_id=entry_id,
|
||||
ticket_type_id=ticket_type_id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
order_id=order_id,
|
||||
code=uuid.uuid4().hex,
|
||||
state=state,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
return ticket
|
||||
|
||||
|
||||
async def create_tickets_for_order(
|
||||
session: AsyncSession,
|
||||
order_id: int,
|
||||
user_id: Optional[int],
|
||||
session_id: Optional[str],
|
||||
) -> list[Ticket]:
|
||||
"""
|
||||
Create ticket records for all calendar entries in an order
|
||||
that have ticket_price configured.
|
||||
Called during checkout after calendar entries are transitioned to 'ordered'.
|
||||
"""
|
||||
# Find all ordered entries for this order that have ticket pricing
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.order_id == order_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.ticket_price.isnot(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.ticket_types))
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
tickets = []
|
||||
for entry in entries:
|
||||
if entry.ticket_types:
|
||||
# Entry has specific ticket types — create one ticket per type
|
||||
# (quantity handling can be added later)
|
||||
for tt in entry.ticket_types:
|
||||
if tt.deleted_at is None:
|
||||
ticket = await create_ticket(
|
||||
session,
|
||||
entry_id=entry.id,
|
||||
ticket_type_id=tt.id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
order_id=order_id,
|
||||
state="reserved",
|
||||
)
|
||||
tickets.append(ticket)
|
||||
else:
|
||||
# Simple ticket — one per entry
|
||||
ticket = await create_ticket(
|
||||
session,
|
||||
entry_id=entry.id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
order_id=order_id,
|
||||
state="reserved",
|
||||
)
|
||||
tickets.append(ticket)
|
||||
|
||||
return tickets
|
||||
|
||||
|
||||
async def confirm_tickets_for_order(
|
||||
session: AsyncSession,
|
||||
order_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Transition tickets from reserved → confirmed when payment succeeds.
|
||||
Returns the number of tickets confirmed.
|
||||
"""
|
||||
result = await session.execute(
|
||||
update(Ticket)
|
||||
.where(
|
||||
Ticket.order_id == order_id,
|
||||
Ticket.state == "reserved",
|
||||
)
|
||||
.values(state="confirmed")
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def get_ticket_by_code(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
) -> Optional[Ticket]:
|
||||
"""Look up a ticket by its unique code."""
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(Ticket.code == code)
|
||||
.options(
|
||||
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
||||
selectinload(Ticket.ticket_type),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_tickets(
|
||||
session: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
session_id: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
) -> list[Ticket]:
|
||||
"""Get all tickets for a user or session."""
|
||||
filters = []
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
if state:
|
||||
filters.append(Ticket.state == state)
|
||||
else:
|
||||
# Exclude cancelled by default
|
||||
filters.append(Ticket.state != "cancelled")
|
||||
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(*filters)
|
||||
.options(
|
||||
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
||||
selectinload(Ticket.ticket_type),
|
||||
)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_tickets_for_entry(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
) -> list[Ticket]:
|
||||
"""Get all non-cancelled tickets for a calendar entry."""
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(
|
||||
Ticket.entry_id == entry_id,
|
||||
Ticket.state != "cancelled",
|
||||
)
|
||||
.options(
|
||||
selectinload(Ticket.ticket_type),
|
||||
)
|
||||
.order_by(Ticket.created_at.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_sold_ticket_count(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
) -> int:
|
||||
"""Count all non-cancelled tickets for an entry (total sold/reserved)."""
|
||||
result = await session.scalar(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.entry_id == entry_id,
|
||||
Ticket.state != "cancelled",
|
||||
)
|
||||
)
|
||||
return result or 0
|
||||
|
||||
|
||||
async def get_user_reserved_count(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
session_id: Optional[str] = None,
|
||||
ticket_type_id: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Count reserved tickets for a specific user/session + entry + optional type."""
|
||||
filters = [
|
||||
Ticket.entry_id == entry_id,
|
||||
Ticket.state == "reserved",
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
else:
|
||||
return 0
|
||||
if ticket_type_id is not None:
|
||||
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||
result = await session.scalar(
|
||||
select(func.count(Ticket.id)).where(*filters)
|
||||
)
|
||||
return result or 0
|
||||
|
||||
|
||||
async def cancel_latest_reserved_ticket(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
session_id: Optional[str] = None,
|
||||
ticket_type_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Cancel the most recently created reserved ticket. Returns True if one was cancelled."""
|
||||
filters = [
|
||||
Ticket.entry_id == entry_id,
|
||||
Ticket.state == "reserved",
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
else:
|
||||
return False
|
||||
if ticket_type_id is not None:
|
||||
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||
|
||||
ticket = await session.scalar(
|
||||
select(Ticket)
|
||||
.where(*filters)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if ticket:
|
||||
ticket.state = "cancelled"
|
||||
await session.flush()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_available_ticket_count(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Get number of remaining tickets for an entry.
|
||||
Returns None if unlimited.
|
||||
"""
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if not entry or entry.ticket_price is None:
|
||||
return None
|
||||
if entry.ticket_count is None:
|
||||
return None # Unlimited
|
||||
|
||||
# Count non-cancelled tickets
|
||||
sold = await session.scalar(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.entry_id == entry_id,
|
||||
Ticket.state != "cancelled",
|
||||
)
|
||||
)
|
||||
return max(0, entry.ticket_count - (sold or 0))
|
||||
|
||||
|
||||
async def checkin_ticket(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check in a ticket by its code.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ticket = await get_ticket_by_code(session, code)
|
||||
if not ticket:
|
||||
return False, "Ticket not found"
|
||||
|
||||
if ticket.state == "checked_in":
|
||||
return False, "Ticket already checked in"
|
||||
|
||||
if ticket.state == "cancelled":
|
||||
return False, "Ticket is cancelled"
|
||||
|
||||
if ticket.state not in ("confirmed", "reserved"):
|
||||
return False, f"Ticket in unexpected state: {ticket.state}"
|
||||
|
||||
ticket.state = "checked_in"
|
||||
ticket.checked_in_at = datetime.now(timezone.utc)
|
||||
return True, None
|
||||
Reference in New Issue
Block a user