Monorepo: consolidate 7 repos into one
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:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

View File

308
events/bp/tickets/routes.py Normal file
View 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

View File

View 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