feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Extract events/calendar functionality into standalone microservice: - app.py and events_api.py from apps/events/ - Calendar blueprints (calendars, calendar, calendar_entries, calendar_entry, day, slots, slot, ticket_types, ticket_type) - Templates for all calendar/event views including admin - Dockerfile (APP_MODULE=app:app, IMAGE=events) - entrypoint.sh (no Alembic - migrations managed by blog app) - Gitea CI workflow for build and deploy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
159
bp/ticket_type/routes.py
Normal file
159
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 suma_browser.app.authz import require_admin
|
||||
from suma_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 suma_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
|
||||
56
bp/ticket_type/services/ticket.py
Normal file
56
bp/ticket_type/services/ticket.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
Reference in New Issue
Block a user