Monorepo: consolidate 7 repos into one
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:
4
events/.gitignore
vendored
Normal file
4
events/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
node_modules/
|
||||
49
events/Dockerfile
Normal file
49
events/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
APP_PORT=8000 \
|
||||
APP_MODULE=app:app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps + psql client
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY shared/requirements.txt ./requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Shared code (replaces submodule)
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# App code
|
||||
COPY events/ ./
|
||||
|
||||
# Sibling models for cross-domain SQLAlchemy imports
|
||||
COPY blog/__init__.py ./blog/__init__.py
|
||||
COPY blog/models/ ./blog/models/
|
||||
COPY market/__init__.py ./market/__init__.py
|
||||
COPY market/models/ ./market/models/
|
||||
COPY cart/__init__.py ./cart/__init__.py
|
||||
COPY cart/models/ ./cart/models/
|
||||
COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY events/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE ${APP_PORT}
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
78
events/README.md
Normal file
78
events/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Events App
|
||||
|
||||
Calendar and event booking service for the Rose Ash cooperative platform. Manages calendars, time slots, calendar entries (bookings), tickets, and ticket types.
|
||||
|
||||
## Architecture
|
||||
|
||||
One of five Quart microservices sharing a single PostgreSQL database:
|
||||
|
||||
| App | Port | Domain |
|
||||
|-----|------|--------|
|
||||
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
|
||||
| market | 8001 | Product browsing, Suma scraping |
|
||||
| cart | 8002 | Shopping cart, checkout, orders |
|
||||
| **events** | 8003 | Calendars, bookings, tickets |
|
||||
| federation | 8004 | ActivityPub, fediverse social |
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
app.py # Application factory (create_base_app + blueprints)
|
||||
path_setup.py # Adds project root + app dir to sys.path
|
||||
config/app-config.yaml # App URLs, feature flags
|
||||
models/ # Events-domain models
|
||||
calendars.py # Calendar, CalendarEntry, CalendarSlot,
|
||||
# TicketType, Ticket, CalendarEntryPost
|
||||
bp/ # Blueprints
|
||||
calendars/ # Calendar listing
|
||||
calendar/ # Single calendar view and admin
|
||||
calendar_entries/ # Calendar entries listing
|
||||
calendar_entry/ # Single entry view and admin
|
||||
day/ # Day view and admin
|
||||
slots/ # Slot listing
|
||||
slot/ # Single slot management
|
||||
ticket_types/ # Ticket type listing
|
||||
ticket_type/ # Single ticket type management
|
||||
tickets/ # Ticket listing
|
||||
ticket_admin/ # Ticket administration
|
||||
markets/ # Page-scoped marketplace views
|
||||
payments/ # Payment-related views
|
||||
services/ # register_domain_services() — wires calendar + market + cart
|
||||
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
All events-domain models live in `models/calendars.py`:
|
||||
|
||||
| Model | Description |
|
||||
|-------|-------------|
|
||||
| **Calendar** | Container for entries, scoped to a page via `container_type + container_id` |
|
||||
| **CalendarEntry** | A bookable event/time slot. Has `state` (pending/ordered/provisional), `cost`, ownership (`user_id`/`session_id`), and `order_id` (plain integer, no FK) |
|
||||
| **CalendarSlot** | Recurring time bands (day-of-week + time range) within a calendar |
|
||||
| **TicketType** | Named ticket categories with price and count |
|
||||
| **Ticket** | Individual ticket with unique code, state, and `order_id` (plain integer, no FK) |
|
||||
| **CalendarEntryPost** | Junction linking entries to content via `content_type + content_id` |
|
||||
|
||||
`order_id` on CalendarEntry and Ticket is a plain integer column — no FK constraint to the orders table. The cart app writes these values via service calls, not directly.
|
||||
|
||||
## Cross-Domain Communication
|
||||
|
||||
- `services.market.*` — marketplace queries for page views
|
||||
- `services.cart.*` — cart summary for context processor
|
||||
- `services.federation.*` — AP publishing for new entries
|
||||
- `shared.services.navigation` — site navigation tree
|
||||
|
||||
## Migrations
|
||||
|
||||
This app does **not** run Alembic migrations on startup. Migrations are managed in the `shared/` submodule and run from the blog app's entrypoint.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||
export REDIS_URL=redis://localhost:6379/0
|
||||
export SECRET_KEY=your-secret-key
|
||||
|
||||
hypercorn app:app --bind 0.0.0.0:8003
|
||||
```
|
||||
0
events/__init__.py
Normal file
0
events/__init__.py
Normal file
154
events/app.py
Normal file
154
events/app.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, abort, request
|
||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments
|
||||
|
||||
|
||||
async def events_context() -> dict:
|
||||
"""
|
||||
Events app context processor.
|
||||
|
||||
- nav_tree_html: fetched from blog as fragment
|
||||
- cart_count/cart_total: via cart service (shared DB)
|
||||
"""
|
||||
from shared.infrastructure.context import base_context
|
||||
from shared.services.navigation import get_navigation_tree
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
ctx = await base_context()
|
||||
|
||||
ctx["nav_tree_html"] = await fetch_fragment(
|
||||
"blog", "nav-tree",
|
||||
params={"app_name": "events", "path": request.path},
|
||||
)
|
||||
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||
|
||||
# Cart data via service (replaces cross-app HTTP API)
|
||||
ident = current_cart_identity()
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
|
||||
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
from shared.services.registry import services
|
||||
from services import register_domain_services
|
||||
|
||||
app = create_base_app(
|
||||
"events",
|
||||
context_fn=events_context,
|
||||
domain_services_fn=register_domain_services,
|
||||
)
|
||||
|
||||
# App-specific templates override shared templates
|
||||
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||
app.jinja_loader = ChoiceLoader([
|
||||
FileSystemLoader(app_templates),
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# All events: / — global view across all pages
|
||||
app.register_blueprint(
|
||||
register_all_events(),
|
||||
url_prefix="/",
|
||||
)
|
||||
|
||||
# Page summary: /<slug>/ — upcoming events across all calendars
|
||||
app.register_blueprint(
|
||||
register_page(),
|
||||
url_prefix="/<slug>",
|
||||
)
|
||||
|
||||
# Calendars nested under post slug: /<slug>/calendars/...
|
||||
app.register_blueprint(
|
||||
register_calendars(),
|
||||
url_prefix="/<slug>/calendars",
|
||||
)
|
||||
|
||||
# Markets nested under post slug: /<slug>/markets/...
|
||||
app.register_blueprint(
|
||||
register_markets(),
|
||||
url_prefix="/<slug>/markets",
|
||||
)
|
||||
|
||||
# Payments nested under post slug: /<slug>/payments/...
|
||||
app.register_blueprint(
|
||||
register_payments(),
|
||||
url_prefix="/<slug>/payments",
|
||||
)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
# --- Auto-inject slug into url_for() calls ---
|
||||
@app.url_value_preprocessor
|
||||
def pull_slug(endpoint, values):
|
||||
if values and "slug" in values:
|
||||
g.post_slug = values.pop("slug")
|
||||
|
||||
@app.url_defaults
|
||||
def inject_slug(endpoint, values):
|
||||
slug = g.get("post_slug")
|
||||
if slug and "slug" not in values:
|
||||
if app.url_map.is_endpoint_expecting(endpoint, "slug"):
|
||||
values["slug"] = slug
|
||||
|
||||
# --- Load post data for slug ---
|
||||
@app.before_request
|
||||
async def hydrate_post():
|
||||
slug = getattr(g, "post_slug", None)
|
||||
if not slug:
|
||||
return
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
abort(404)
|
||||
g.post_data = {
|
||||
"post": {
|
||||
"id": post.id,
|
||||
"title": post.title,
|
||||
"slug": post.slug,
|
||||
"feature_image": post.feature_image,
|
||||
"status": post.status,
|
||||
"visibility": post.visibility,
|
||||
},
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
async def inject_post():
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if not post_data:
|
||||
return {}
|
||||
post_id = post_data["post"]["id"]
|
||||
calendars = await services.calendar.calendars_for_container(g.s, "page", post_id)
|
||||
markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
|
||||
return {
|
||||
**post_data,
|
||||
"calendars": calendars,
|
||||
"markets": markets,
|
||||
}
|
||||
|
||||
# Tickets blueprint — user-facing ticket views and QR codes
|
||||
from bp.tickets.routes import register as register_tickets
|
||||
app.register_blueprint(register_tickets())
|
||||
|
||||
# Ticket admin — check-in interface (admin only)
|
||||
from bp.ticket_admin.routes import register as register_ticket_admin
|
||||
app.register_blueprint(register_ticket_admin())
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
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
|
||||
84
events/config/app-config.yaml
Normal file
84
events/config/app-config.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
# App-wide settings
|
||||
base_host: "wholesale.suma.coop"
|
||||
base_login: https://wholesale.suma.coop/customer/account/login/
|
||||
base_url: https://wholesale.suma.coop/
|
||||
title: Rose Ash
|
||||
market_root: /market
|
||||
market_title: Market
|
||||
blog_root: /
|
||||
blog_title: all the news
|
||||
cart_root: /cart
|
||||
app_urls:
|
||||
blog: "http://localhost:8000"
|
||||
market: "http://localhost:8001"
|
||||
cart: "http://localhost:8002"
|
||||
events: "http://localhost:8003"
|
||||
federation: "http://localhost:8004"
|
||||
cache:
|
||||
fs_root: _snapshot # <- absolute path to your snapshot dir
|
||||
categories:
|
||||
allow:
|
||||
Basics: basics
|
||||
Branded Goods: branded-goods
|
||||
Chilled: chilled
|
||||
Frozen: frozen
|
||||
Non-foods: non-foods
|
||||
Supplements: supplements
|
||||
Christmas: christmas
|
||||
slugs:
|
||||
skip:
|
||||
- ""
|
||||
- customer
|
||||
- account
|
||||
- checkout
|
||||
- wishlist
|
||||
- sales
|
||||
- contact
|
||||
- privacy-policy
|
||||
- terms-and-conditions
|
||||
- delivery
|
||||
- catalogsearch
|
||||
- quickorder
|
||||
- apply
|
||||
- search
|
||||
- static
|
||||
- media
|
||||
section-titles:
|
||||
- ingredients
|
||||
- allergy information
|
||||
- allergens
|
||||
- nutritional information
|
||||
- nutrition
|
||||
- storage
|
||||
- directions
|
||||
- preparation
|
||||
- serving suggestions
|
||||
- origin
|
||||
- country of origin
|
||||
- recycling
|
||||
- general information
|
||||
- additional information
|
||||
- a note about prices
|
||||
|
||||
blacklist:
|
||||
category:
|
||||
- branded-goods/alcoholic-drinks
|
||||
- branded-goods/beers
|
||||
- branded-goods/wines
|
||||
- branded-goods/ciders
|
||||
product:
|
||||
- list-price-suma-current-suma-price-list-each-bk012-2-html
|
||||
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
|
||||
product-details:
|
||||
- General Information
|
||||
- A Note About Prices
|
||||
|
||||
# SumUp payment settings (fill these in for live usage)
|
||||
sumup:
|
||||
merchant_code: "ME4J6100"
|
||||
currency: "GBP"
|
||||
# Name of the environment variable that holds your SumUp API key
|
||||
api_key_env: "SUMUP_API_KEY"
|
||||
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
|
||||
checkout_reference_prefix: 'dev-'
|
||||
|
||||
29
events/entrypoint.sh
Normal file
29
events/entrypoint.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Optional: wait for Postgres to be reachable
|
||||
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
|
||||
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
|
||||
for i in {1..60}; do
|
||||
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# NOTE: Events app does NOT run Alembic migrations.
|
||||
# Migrations are managed by the blog app which owns the shared database schema.
|
||||
|
||||
# Clear Redis page cache on deploy
|
||||
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
|
||||
echo "Flushing Redis cache..."
|
||||
python3 -c "
|
||||
import redis, os
|
||||
r = redis.from_url(os.environ['REDIS_URL'])
|
||||
r.flushall()
|
||||
print('Redis cache cleared.')
|
||||
" || echo "Redis flush failed (non-fatal), continuing..."
|
||||
fi
|
||||
|
||||
# Start the app
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
|
||||
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000}
|
||||
4
events/models/__init__.py
Normal file
4
events/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .calendars import (
|
||||
Calendar, CalendarEntry, CalendarSlot,
|
||||
TicketType, Ticket, CalendarEntryPost,
|
||||
)
|
||||
4
events/models/calendars.py
Normal file
4
events/models/calendars.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from shared.models.calendars import ( # noqa: F401
|
||||
Calendar, CalendarEntry, CalendarSlot,
|
||||
TicketType, Ticket, CalendarEntryPost,
|
||||
)
|
||||
9
events/path_setup.py
Normal file
9
events/path_setup.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_project_root = os.path.dirname(_app_dir)
|
||||
|
||||
for _p in (_project_root, _app_dir):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
29
events/services/__init__.py
Normal file
29
events/services/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Events app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the events app.
|
||||
|
||||
Events owns: Calendar, CalendarEntry, CalendarSlot, TicketType,
|
||||
Ticket, CalendarEntryPost.
|
||||
Standard deployment registers all 4 services as real DB impls
|
||||
(shared DB). For composable deployments, swap non-owned services
|
||||
with stubs from shared.services.stubs.
|
||||
"""
|
||||
from shared.services.registry import services
|
||||
from shared.services.blog_impl import SqlBlogService
|
||||
from shared.services.calendar_impl import SqlCalendarService
|
||||
from shared.services.market_impl import SqlMarketService
|
||||
from shared.services.cart_impl import SqlCartService
|
||||
|
||||
services.calendar = SqlCalendarService()
|
||||
if not services.has("blog"):
|
||||
services.blog = SqlBlogService()
|
||||
if not services.has("market"):
|
||||
services.market = SqlMarketService()
|
||||
if not services.has("cart"):
|
||||
services.cart = SqlCartService()
|
||||
if not services.has("federation"):
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
services.federation = SqlFederationService()
|
||||
62
events/templates/_types/all_events/_card.html
Normal file
62
events/templates/_types/all_events/_card.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{# List card for all events — one entry #}
|
||||
{% set pi = page_info.get(entry.calendar_container_id, {}) %}
|
||||
{% set page_slug = pi.get('slug', '') %}
|
||||
{% set page_title = pi.get('title') %}
|
||||
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
{# Left: event info #}
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if page_slug %}
|
||||
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
|
||||
{% else %}
|
||||
{% set day_href = '' %}
|
||||
{% endif %}
|
||||
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' if day_href else '' %}
|
||||
{% if entry_href %}
|
||||
<a href="{{ entry_href }}" class="hover:text-emerald-700">
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ entry.name }}</h2>
|
||||
</a>
|
||||
{% else %}
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ entry.name }}</h2>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
{% if page_title %}
|
||||
<a href="{{ events_url('/' ~ page_slug ~ '/') }}"
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
|
||||
{{ page_title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if entry.calendar_name %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">
|
||||
{{ entry.calendar_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm text-stone-500">
|
||||
{% if day_href %}
|
||||
<a href="{{ day_href }}" class="hover:text-stone-700">{{ entry.start_at.strftime('%a %-d %b') }}</a> ·
|
||||
{% else %}
|
||||
{{ entry.start_at.strftime('%a %-d %b') }} ·
|
||||
{% endif %}
|
||||
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entry.cost %}
|
||||
<div class="mt-1 text-sm font-medium text-green-600">
|
||||
£{{ '%.2f'|format(entry.cost) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Right: ticket widget #}
|
||||
{% if entry.ticket_price is not none %}
|
||||
<div class="shrink-0">
|
||||
{% set qty = pending_tickets.get(entry.id, 0) %}
|
||||
{% set ticket_url = url_for('all_events.adjust_ticket') %}
|
||||
{% include '_types/page_summary/_ticket_widget.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
60
events/templates/_types/all_events/_card_tile.html
Normal file
60
events/templates/_types/all_events/_card_tile.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{# Tile card for all events — compact event tile #}
|
||||
{% set pi = page_info.get(entry.calendar_container_id, {}) %}
|
||||
{% set page_slug = pi.get('slug', '') %}
|
||||
{% set page_title = pi.get('title') %}
|
||||
<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden">
|
||||
{% if page_slug %}
|
||||
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
|
||||
{% else %}
|
||||
{% set day_href = '' %}
|
||||
{% endif %}
|
||||
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' if day_href else '' %}
|
||||
<div class="p-3">
|
||||
{% if entry_href %}
|
||||
<a href="{{ entry_href }}" class="hover:text-emerald-700">
|
||||
<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{{ entry.name }}</h2>
|
||||
</a>
|
||||
{% else %}
|
||||
<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{{ entry.name }}</h2>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1 mt-1">
|
||||
{% if page_title %}
|
||||
<a href="{{ events_url('/' ~ page_slug ~ '/') }}"
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
|
||||
{{ page_title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if entry.calendar_name %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">
|
||||
{{ entry.calendar_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-stone-500">
|
||||
{% if day_href %}
|
||||
<a href="{{ day_href }}" class="hover:text-stone-700">{{ entry.start_at.strftime('%a %-d %b') }}</a>
|
||||
{% else %}
|
||||
{{ entry.start_at.strftime('%a %-d %b') }}
|
||||
{% endif %}
|
||||
·
|
||||
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entry.cost %}
|
||||
<div class="mt-1 text-sm font-medium text-green-600">
|
||||
£{{ '%.2f'|format(entry.cost) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Ticket widget below card #}
|
||||
{% if entry.ticket_price is not none %}
|
||||
<div class="border-t border-stone-100 px-3 py-2">
|
||||
{% set qty = pending_tickets.get(entry.id, 0) %}
|
||||
{% set ticket_url = url_for('all_events.adjust_ticket') %}
|
||||
{% include '_types/page_summary/_ticket_widget.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
31
events/templates/_types/all_events/_cards.html
Normal file
31
events/templates/_types/all_events/_cards.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% for entry in entries %}
|
||||
{% if view == 'tile' %}
|
||||
{% include "_types/all_events/_card_tile.html" %}
|
||||
{% else %}
|
||||
{# Date header when date changes (list view only) #}
|
||||
{% set entry_date = entry.start_at.strftime('%A %-d %B %Y') %}
|
||||
{% if loop.first or entry_date != entries[loop.index0 - 1].start_at.strftime('%A %-d %B %Y') %}
|
||||
<div class="pt-2 pb-1">
|
||||
<h3 class="text-sm font-semibold text-stone-500 uppercase tracking-wide">
|
||||
{{ entry_date }}
|
||||
</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "_types/all_events/_card.html" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if has_more %}
|
||||
{# Infinite scroll sentinel #}
|
||||
{% set entries_url = url_for('all_events.entries_fragment', page=page + 1, view=view if view != 'list' else '')|host %}
|
||||
<div
|
||||
id="sentinel-{{ page }}"
|
||||
class="h-4 opacity-0 pointer-events-none"
|
||||
hx-get="{{ entries_url }}"
|
||||
hx-trigger="intersect once delay:250ms"
|
||||
hx-swap="outerHTML"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
54
events/templates/_types/all_events/_main_panel.html
Normal file
54
events/templates/_types/all_events/_main_panel.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{# View toggle bar - desktop only #}
|
||||
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
||||
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
||||
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
|
||||
<a
|
||||
href="{{ list_href }}"
|
||||
hx-get="{{ list_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||
title="List view"
|
||||
_="on click js localStorage.removeItem('events_view') end"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="{{ tile_href }}"
|
||||
hx-get="{{ tile_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||
title="Tile view"
|
||||
_="on click js localStorage.setItem('events_view','tile') end"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Cards container - list or grid based on view #}
|
||||
{% if entries %}
|
||||
{% if view == 'tile' %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/all_events/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="max-w-full px-3 py-3 space-y-3">
|
||||
{% include "_types/all_events/_cards.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="px-3 py-12 text-center text-stone-400">
|
||||
<i class="fa fa-calendar-xmark text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-lg">No upcoming events</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
7
events/templates/_types/all_events/index.html
Normal file
7
events/templates/_types/all_events/index.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/all_events/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
12
events/templates/_types/calendar/_description.html
Normal file
12
events/templates/_types/calendar/_description.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% macro description(calendar, oob=False) %}
|
||||
<div
|
||||
id="calendar-description-title"
|
||||
{% if oob %}
|
||||
hx-swap-oob="outerHTML"
|
||||
{% endif %}
|
||||
class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
|
||||
>
|
||||
{{ calendar.description or ''}}
|
||||
</div>
|
||||
|
||||
{% endmacro %}
|
||||
170
events/templates/_types/calendar/_main_panel.html
Normal file
170
events/templates/_types/calendar/_main_panel.html
Normal file
@@ -0,0 +1,170 @@
|
||||
<section class="bg-orange-100">
|
||||
<header class="flex items-center justify-center mt-2">
|
||||
|
||||
{# Month / year navigation #}
|
||||
<nav class="flex items-center gap-2 text-2xl">
|
||||
{# Outer left: -1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
«
|
||||
</a>
|
||||
|
||||
{# Inner left: -1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
|
||||
<div class="px-3 font-medium">
|
||||
{{ month_name }} {{ year }}
|
||||
</div>
|
||||
|
||||
{# Inner right: +1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
›
|
||||
</a>
|
||||
|
||||
{# Outer right: +1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
»
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{# Calendar grid #}
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">
|
||||
{# Weekday header: only show on sm+ (desktop/tablet) #}
|
||||
<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">
|
||||
{% for wd in weekday_names %}
|
||||
<div class="py-1">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# On mobile: 1 column; on sm+: 7 columns #}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">
|
||||
{% for week in weeks %}
|
||||
{% for day in week %}
|
||||
<div
|
||||
class="min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %} {% if day.is_today %} ring-2 ring-blue-500 z-10 relative {% endif %}"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col">
|
||||
<span class="sm:hidden text-[16px] text-stone-500">
|
||||
{{ day.date.strftime('%a') }}
|
||||
</span>
|
||||
|
||||
{# Clickable day number: goes to day detail view #}
|
||||
<a
|
||||
class="{{styles.pill}}"
|
||||
href="{{ url_for('calendars.calendar.day.show_day',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.day.show_day',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
{{ day.date.day }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# Entries for this day: merged, chronological #}
|
||||
<div class="mt-1 space-y-0.5">
|
||||
{# Build a list of entries for this specific day.
|
||||
month_entries is already sorted by start_at in Python. #}
|
||||
{% for e in month_entries %}
|
||||
{% if e.start_at.date() == day.date %}
|
||||
{# Decide colour: highlight "mine" differently if you want #}
|
||||
{% set is_mine = (g.user and e.user_id == g.user.id)
|
||||
or (not g.user and e.session_id == qsession.get('calendar_sid')) %}
|
||||
<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5
|
||||
{% if e.state == 'confirmed' %}
|
||||
{% if is_mine %}
|
||||
bg-emerald-200 text-emerald-900
|
||||
{% else %}
|
||||
bg-emerald-100 text-emerald-800
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if is_mine %}
|
||||
bg-sky-100 text-sky-800
|
||||
{% else %}
|
||||
bg-stone-100 text-stone-700
|
||||
{% endif %}
|
||||
{% endif %}">
|
||||
<span class="truncate">
|
||||
{{ e.name }}
|
||||
</span>
|
||||
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">
|
||||
{{ (e.state or 'pending')|replace('_', ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
18
events/templates/_types/calendar/_nav.html
Normal file
18
events/templates/_types/calendar/_nav.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- Desktop nav -->
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(
|
||||
url_for('calendars.calendar.slots.get', calendar_slug=calendar.slug),
|
||||
hx_select_search,
|
||||
select_colours,
|
||||
True,
|
||||
aclass=styles.nav_button
|
||||
) %}
|
||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||
<div>
|
||||
Slots
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{ admin_nav_item(url_for('calendars.calendar.admin.admin', calendar_slug=calendar.slug)) }}
|
||||
{% endif %}
|
||||
22
events/templates/_types/calendar/_oob_elements.html
Normal file
22
events/templates/_types/calendar/_oob_elements.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "oob_elements.html" %}
|
||||
{# OOB elements for post admin page #}
|
||||
|
||||
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('post-header-child', 'calendar-header-child', '_types/calendar/header/_header.html')}}
|
||||
|
||||
{% from '_types/post/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
32
events/templates/_types/calendar/admin/_description.html
Normal file
32
events/templates/_types/calendar/admin/_description.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<div id="calendar-description">
|
||||
{% if calendar.description %}
|
||||
<p class="text-stone-700 whitespace-pre-line break-all">
|
||||
{{ calendar.description }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-stone-400 italic">
|
||||
No description yet.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-xs underline"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_edit',
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if oob %}
|
||||
|
||||
{% from '_types/calendar/_description.html' import description %}
|
||||
{{description(calendar, oob=True)}}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<div id="calendar-description">
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_save',
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<textarea
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
rows="4"
|
||||
class="w-full p-2 border rounded"
|
||||
>{{ calendar.description or '' }}</textarea>
|
||||
|
||||
<div class="mt-2 flex gap-2 text-xs">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-1 rounded bg-stone-800 text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded border"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_view',
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
45
events/templates/_types/calendar/admin/_main_panel.html
Normal file
45
events/templates/_types/calendar/admin/_main_panel.html
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
<section class="max-w-3xl mx-auto p-4 space-y-10">
|
||||
<!-- Calendar config -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Calendar configuration</h2>
|
||||
<div id="cal-put-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700">
|
||||
Description
|
||||
</label>
|
||||
{% include '_types/calendar/admin/_description.html' %}
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="calendar-form"
|
||||
method="post"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"
|
||||
hx-on::response-error="document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
|
||||
class="hidden space-y-4 mt-4"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700">Description</label>
|
||||
<div>{{calendar.description or ''}}</div>
|
||||
<textarea
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
rows="4" class="w-full p-2 border rounded"
|
||||
>{{ (calendar.description or '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="px-3 py-2 rounded bg-stone-800 text-white">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr class="border-stone-200">
|
||||
|
||||
</section>
|
||||
2
events/templates/_types/calendar/admin/_nav.html
Normal file
2
events/templates/_types/calendar/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
25
events/templates/_types/calendar/admin/_oob_elements.html
Normal file
25
events/templates/_types/calendar/admin/_oob_elements.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for calendar admin page #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('calendar-header-child', 'calendar-admin-header-child', '_types/calendar/admin/header/_header.html')}}
|
||||
|
||||
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||
{{header_row(oob=True)}}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/calendar/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
13
events/templates/_types/calendar/admin/header/_header.html
Normal file
13
events/templates/_types/calendar/admin/header/_header.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
hx_select_search
|
||||
) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendar/admin/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
24
events/templates/_types/calendar/admin/index.html
Normal file
24
events/templates/_types/calendar/admin/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends '_types/calendar/index.html' %}
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
|
||||
{% block calendar_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% call header() %}
|
||||
{% from '_types/calendar/admin/header/_header.html' import header_row with context %}
|
||||
{{ header_row() }}
|
||||
<div id="calendar-admin-header-child">
|
||||
{% block calendar_admin_header_child %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendar/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
23
events/templates/_types/calendar/header/_header.html
Normal file
23
events/templates/_types/calendar/header/_header.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendar-row', oob=oob) %}
|
||||
{% call links.link(url_for('calendars.calendar.get', calendar_slug=calendar.slug), hx_select_search) %}
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<div class="shrink-0">
|
||||
{{ calendar.name }}
|
||||
</div>
|
||||
</div>
|
||||
{% from '_types/calendar/_description.html' import description %}
|
||||
{{description(calendar)}}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
26
events/templates/_types/calendar/index.html
Normal file
26
events/templates/_types/calendar/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
|
||||
{% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
|
||||
{% call index_row('calendar-header-child', '_types/calendar/header/_header.html') %}
|
||||
{% block calendar_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
44
events/templates/_types/calendars/_calendars_list.html
Normal file
44
events/templates/_types/calendars/_calendars_list.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% for row in calendars %}
|
||||
{% set cal = row %}
|
||||
<div class="mt-6 border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
{% set calendar_href = url_for('calendars.calendar.get', calendar_slug=cal.slug)|host %}
|
||||
<a
|
||||
class="flex items-baseline gap-3"
|
||||
href="{{ calendar_href }}"
|
||||
hx-get="{{ calendar_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<h3 class="font-semibold">{{ cal.name }}</h3>
|
||||
<h4 class="text-gray-500">/{{ cal.slug }}/</h4>
|
||||
</a>
|
||||
|
||||
<!-- Soft delete -->
|
||||
<button
|
||||
class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
|
||||
data-confirm
|
||||
data-confirm-title="Delete calendar?"
|
||||
data-confirm-text="Entries will be hidden (soft delete)"
|
||||
data-confirm-icon="warning"
|
||||
data-confirm-confirm-text="Yes, delete it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
hx-delete="{{ url_for('calendars.calendar.delete', calendar_slug=cal.slug) }}"
|
||||
hx-trigger="confirmed"
|
||||
hx-target="#calendars-list"
|
||||
hx-select="#calendars-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-headers='{"X-CSRFToken":"{{ csrf_token() }}"}'
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 mt-4">No calendars yet. Create one above.</p>
|
||||
{% endfor %}
|
||||
27
events/templates/_types/calendars/_main_panel.html
Normal file
27
events/templates/_types/calendars/_main_panel.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<section class="p-4">
|
||||
{% if has_access('calendars.create_calendar') %}
|
||||
<!-- error container under the inputs -->
|
||||
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
|
||||
<form
|
||||
class="mt-4 flex gap-2 items-end"
|
||||
hx-post="{{ url_for('calendars.create_calendar') }}"
|
||||
hx-target="#calendars-list"
|
||||
hx-select="#calendars-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::before-request="document.querySelector('#cal-create-errors').textContent='';"
|
||||
hx-on::response-error="document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-600">Name</label>
|
||||
<input name="name" type="text" required class="w-full border rounded px-3 py-2" placeholder="e.g. Events, Gigs, Meetings" />
|
||||
</div>
|
||||
<button type="submit" class="border rounded px-3 py-2">Add calendar</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<!-- list -->
|
||||
<div id="calendars-list" class="mt-6">
|
||||
{% include "_types/calendars/_calendars_list.html" %}
|
||||
</div>
|
||||
</section>
|
||||
2
events/templates/_types/calendars/_nav.html
Normal file
2
events/templates/_types/calendars/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
28
events/templates/_types/calendars/_oob_elements.html
Normal file
28
events/templates/_types/calendars/_oob_elements.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('post-admin-header-child', 'calendars-header-child', '_types/calendars/header/_header.html')}}
|
||||
|
||||
{% from '_types/post/admin/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/calendars/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/calendars/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
14
events/templates/_types/calendars/header/_header.html
Normal file
14
events/templates/_types/calendars/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendars-row', oob=oob) %}
|
||||
{% call links.link(url_for('calendars.home'), hx_select_search) %}
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>
|
||||
Calendars
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendars/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
26
events/templates/_types/calendars/index.html
Normal file
26
events/templates/_types/calendars/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
|
||||
{% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
|
||||
{% call index_row('calendars-header-child', '_types/calendars/header/_header.html') %}
|
||||
{% block calendars_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendars/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendars/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
299
events/templates/_types/day/_add.html
Normal file
299
events/templates/_types/day/_add.html
Normal file
@@ -0,0 +1,299 @@
|
||||
<div id="entry-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
|
||||
<form
|
||||
class="mt-4 grid grid-cols-1 md:grid-cols-4 gap-2"
|
||||
hx-post="{{ url_for(
|
||||
'calendars.calendar.day.calendar_entries.add_entry',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#day-entries"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# 1) Entry name #}
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
class="border rounded px-3 py-2"
|
||||
placeholder="Entry name"
|
||||
/>
|
||||
|
||||
{# 2) Slot picker for this weekday (required) #}
|
||||
{% if day_slots %}
|
||||
<select
|
||||
name="slot_id"
|
||||
class="border rounded px-3 py-2"
|
||||
data-slot-picker
|
||||
required
|
||||
>
|
||||
{% for slot in day_slots %}
|
||||
<option
|
||||
value="{{ slot.id }}"
|
||||
data-start="{{ slot.time_start.strftime('%H:%M') }}"
|
||||
data-end="{{ slot.time_end.strftime('%H:%M') if slot.time_end else '' }}"
|
||||
data-flexible="{{ '1' if slot | getattr('flexible', False) else '0' }}"
|
||||
data-cost="{{ slot.cost if slot.cost is not none else '0' }}"
|
||||
>
|
||||
{{ slot.name }}
|
||||
({{ slot.time_start.strftime('%H:%M') }}
|
||||
{% if slot.time_end %}–{{ slot.time_end.strftime('%H:%M') }}{% else %}–open-ended{% endif %})
|
||||
{% if slot | getattr('flexible', False) %}[flexible]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="text-sm text-stone-500">
|
||||
No slots defined for this day.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# 3) Time entry + cost display #}
|
||||
<div class="md:col-span-2 flex flex-col gap-2">
|
||||
{# Time inputs — hidden until a flexible slot is selected #}
|
||||
<div data-time-fields class="hidden">
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">From</label>
|
||||
<input
|
||||
name="start_time"
|
||||
type="time"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
data-entry-start
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">To</label>
|
||||
<input
|
||||
name="end_time"
|
||||
type="time"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
data-entry-end
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-stone-500" data-slot-boundary></p>
|
||||
</div>
|
||||
|
||||
{# Cost display — shown when a slot is selected #}
|
||||
<div data-cost-row class="hidden text-sm font-medium text-stone-700">
|
||||
Estimated Cost: <span data-cost-display class="text-green-600">£0.00</span>
|
||||
</div>
|
||||
|
||||
{# Summary of fixed times — shown for non-flexible slots #}
|
||||
<div data-fixed-summary class="hidden text-sm text-stone-600"></div>
|
||||
</div>
|
||||
|
||||
{# Ticket Configuration #}
|
||||
<div class="md:col-span-4 border-t pt-3 mt-2">
|
||||
<h4 class="text-sm font-semibold text-stone-700 mb-3">Ticket Configuration (Optional)</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">
|
||||
Ticket Price (£)
|
||||
</label>
|
||||
<input
|
||||
name="ticket_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full border rounded px-3 py-2 text-sm"
|
||||
placeholder="Leave empty for no tickets"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">
|
||||
Total Tickets
|
||||
</label>
|
||||
<input
|
||||
name="ticket_count"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full border rounded px-3 py-2 text-sm"
|
||||
placeholder="Leave empty for unlimited"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2 md:col-span-4">
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.add_button',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#entry-add-container"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="{{styles.action_button}}"
|
||||
data-confirm="true"
|
||||
data-confirm-title="Add entry?"
|
||||
data-confirm-text="Are you sure you want to add this entry?"
|
||||
data-confirm-icon="question"
|
||||
data-confirm-confirm-text="Yes, add it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
>
|
||||
<i class="fa fa-save"></i>
|
||||
Save entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- Behaviour: lock / unlock times based on slot.flexible --- #}
|
||||
<script>
|
||||
(function () {
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function calculateCost(slotCost, slotStart, slotEnd, actualStart, actualEnd, flexible) {
|
||||
if (!flexible) {
|
||||
// Fixed slot: use full slot cost
|
||||
return parseFloat(slotCost);
|
||||
}
|
||||
|
||||
// Flexible slot: prorate based on time range
|
||||
if (!actualStart || !actualEnd) return 0;
|
||||
|
||||
const slotStartMin = timeToMinutes(slotStart);
|
||||
const slotEndMin = timeToMinutes(slotEnd);
|
||||
const actualStartMin = timeToMinutes(actualStart);
|
||||
const actualEndMin = timeToMinutes(actualEnd);
|
||||
|
||||
const slotDuration = slotEndMin - slotStartMin;
|
||||
const actualDuration = actualEndMin - actualStartMin;
|
||||
|
||||
if (slotDuration <= 0 || actualDuration <= 0) return 0;
|
||||
|
||||
const ratio = actualDuration / slotDuration;
|
||||
return parseFloat(slotCost) * ratio;
|
||||
}
|
||||
|
||||
function initEntrySlotPicker(root, applyInitial = false) {
|
||||
const select = root.querySelector('[data-slot-picker]');
|
||||
if (!select) return;
|
||||
|
||||
const timeFields = root.querySelector('[data-time-fields]');
|
||||
const startInput = root.querySelector('[data-entry-start]');
|
||||
const endInput = root.querySelector('[data-entry-end]');
|
||||
const helper = root.querySelector('[data-slot-boundary]');
|
||||
const costDisplay = root.querySelector('[data-cost-display]');
|
||||
const costRow = root.querySelector('[data-cost-row]');
|
||||
const fixedSummary = root.querySelector('[data-fixed-summary]');
|
||||
|
||||
if (!startInput || !endInput) return;
|
||||
|
||||
function updateCost() {
|
||||
const opt = select.selectedOptions[0];
|
||||
if (!opt || !opt.value) {
|
||||
if (costDisplay) costDisplay.textContent = '£0.00';
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = opt.dataset.cost || '0';
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
const calculatedCost = calculateCost(
|
||||
cost, s, e,
|
||||
startInput.value, endInput.value,
|
||||
flexible
|
||||
);
|
||||
|
||||
if (costDisplay) {
|
||||
costDisplay.textContent = '£' + calculatedCost.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFromOption(opt) {
|
||||
if (!opt || !opt.value) {
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (costRow) costRow.classList.add('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
if (!flexible) {
|
||||
// Fixed slot: hide time inputs, show summary + cost
|
||||
if (s) startInput.value = s;
|
||||
if (e) endInput.value = e;
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (fixedSummary) {
|
||||
fixedSummary.classList.remove('hidden');
|
||||
if (e) {
|
||||
fixedSummary.textContent = `${s} – ${e}`;
|
||||
} else {
|
||||
fixedSummary.textContent = `From ${s} (open-ended)`;
|
||||
}
|
||||
}
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
} else {
|
||||
// Flexible slot: show time inputs, hide fixed summary, show cost
|
||||
if (timeFields) timeFields.classList.remove('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
if (helper) {
|
||||
if (e) {
|
||||
helper.textContent = `Times must be between ${s} and ${e}.`;
|
||||
} else {
|
||||
helper.textContent = `Start at or after ${s}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCost();
|
||||
}
|
||||
|
||||
// Only apply initial state if explicitly requested (on first load)
|
||||
if (applyInitial) {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
}
|
||||
|
||||
// Remove any existing listener to prevent duplicates
|
||||
if (select._slotChangeHandler) {
|
||||
select.removeEventListener('change', select._slotChangeHandler);
|
||||
}
|
||||
|
||||
select._slotChangeHandler = () => {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
};
|
||||
|
||||
select.addEventListener('change', select._slotChangeHandler);
|
||||
|
||||
// Update cost when times change (for flexible slots)
|
||||
startInput.addEventListener('input', updateCost);
|
||||
endInput.addEventListener('input', updateCost);
|
||||
}
|
||||
|
||||
// Initial load - apply initial state
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEntrySlotPicker(document, true);
|
||||
});
|
||||
|
||||
// HTMX fragments - apply initial state so visibility is correct
|
||||
if (window.htmx) {
|
||||
htmx.onLoad((content) => {
|
||||
initEntrySlotPicker(content, true);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
16
events/templates/_types/day/_add_button.html
Normal file
16
events/templates/_types/day/_add_button.html
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.day.calendar_entries.add_form',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#entry-add-container"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
+ Add entry
|
||||
</button>
|
||||
28
events/templates/_types/day/_main_panel.html
Normal file
28
events/templates/_types/day/_main_panel.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<section id="day-entries" class="{{styles.list_container}}">
|
||||
<table class="w-full text-sm border table-fixed">
|
||||
<thead class="bg-stone-100">
|
||||
<tr>
|
||||
<th class="p-2 text-left w-2/6">Name</th>
|
||||
<th class="text-left p-2 w-1/6">Slot/Time</th>
|
||||
<th class="text-left p-2 w-1/6">State</th>
|
||||
<th class="text-left p-2 w-1/6">Cost</th>
|
||||
<th class="text-left p-2 w-1/6">Tickets</th>
|
||||
<th class="text-left p-2 w-1/6">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in day_entries %}
|
||||
{% include '_types/day/_row.html' %}
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="p-3 text-stone-500">No entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div id="entry-add-container" class="mt-4">
|
||||
{% include '_types/day/_add_button.html' %}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
39
events/templates/_types/day/_nav.html
Normal file
39
events/templates/_types/day/_nav.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
|
||||
{# Confirmed Entries - vertical on mobile, horizontal with arrows on desktop #}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="day-entries-nav-wrapper">
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
||||
<a
|
||||
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
day=day_date.day,
|
||||
entry_id=entry.id) }}"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
{# Admin link #}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for(
|
||||
'calendars.calendar.day.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
day=day_date.day
|
||||
)
|
||||
)}}
|
||||
{% endif %}
|
||||
18
events/templates/_types/day/_oob_elements.html
Normal file
18
events/templates/_types/day/_oob_elements.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "oob_elements.html" %}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('calendar-header-child', 'day-header-child', '_types/day/header/_header.html')}}
|
||||
|
||||
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/day/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/day/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
74
events/templates/_types/day/_row.html
Normal file
74
events/templates/_types/day/_row.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
<tr class="{{ styles.tr }}">
|
||||
<td class="p-2 align-top w-2/6">
|
||||
<div class="font-medium">
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
entry_id=entry.id
|
||||
),
|
||||
hx_select_search,
|
||||
aclass=styles.pill
|
||||
) %}
|
||||
{{ entry.name }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 align-top w-1/6">
|
||||
{% if entry.slot %}
|
||||
<div class="text-xs font-medium">
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'calendars.calendar.slots.slot.get',
|
||||
calendar_slug=calendar.slug,
|
||||
slot_id=entry.slot.id
|
||||
),
|
||||
hx_select_search,
|
||||
aclass=styles.pill
|
||||
) %}
|
||||
{{ entry.slot.name }}
|
||||
{% endcall %}
|
||||
<span class="text-stone-600 font-normal">
|
||||
({{ entry.slot.time_start.strftime('%H:%M') }}{% if entry.slot.time_end %} → {{ entry.slot.time_end.strftime('%H:%M') }}{% endif %})
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-xs text-stone-600">
|
||||
{% include '_types/entry/_times.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 align-top w-1/6">
|
||||
<div id="entry-state-{{entry.id}}">
|
||||
{% include '_types/entry/_state.html' %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 align-top w-1/6">
|
||||
<span class="font-medium text-green-600">
|
||||
£{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 align-top w-1/6">
|
||||
{% if entry.ticket_price is not none %}
|
||||
<div class="text-xs space-y-1">
|
||||
<div class="font-medium text-green-600">£{{ ('%.2f'|format(entry.ticket_price)) }}</div>
|
||||
<div class="text-stone-600">
|
||||
{% if entry.ticket_count is not none %}
|
||||
{{ entry.ticket_count }} tickets
|
||||
{% else %}
|
||||
Unlimited
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-xs text-stone-400">No tickets</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 align-top w-1/6">
|
||||
{% include '_types/entry/_options.html' %}
|
||||
</td>
|
||||
</tr>
|
||||
2
events/templates/_types/day/admin/_main_panel.html
Normal file
2
events/templates/_types/day/admin/_main_panel.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
2
events/templates/_types/day/admin/_nav.html
Normal file
2
events/templates/_types/day/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
33
events/templates/_types/day/admin/_nav_entries_oob.html
Normal file
33
events/templates/_types/day/admin/_nav_entries_oob.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{# OOB swap for day confirmed entries nav when entries are edited #}
|
||||
{% import 'macros/links.html' as links %}
|
||||
|
||||
{# Confirmed Entries - vertical on mobile, horizontal with arrows on desktop #}
|
||||
{% if confirmed_entries %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="day-entries-nav-wrapper"
|
||||
hx-swap-oob="true">
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
||||
<a
|
||||
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
day=day_date.day,
|
||||
entry_id=entry.id) }}"
|
||||
class="{{styles.nav_button}}"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Empty placeholder to remove nav entries when none are confirmed #}
|
||||
<div id="day-entries-nav-wrapper" hx-swap-oob="true"></div>
|
||||
{% endif %}
|
||||
25
events/templates/_types/day/admin/_oob_elements.html
Normal file
25
events/templates/_types/day/admin/_oob_elements.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for calendar admin page #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('day-header-child', 'day-admin-header-child', '_types/day/admin/header/_header.html')}}
|
||||
|
||||
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||
{{header_row(oob=True)}}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/day/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/day/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
20
events/templates/_types/day/admin/header/_header.html
Normal file
20
events/templates/_types/day/admin/header/_header.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='day-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'calendars.calendar.day.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
day=day_date.day
|
||||
),
|
||||
hx_select_search
|
||||
) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/day/admin/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
24
events/templates/_types/day/admin/index.html
Normal file
24
events/templates/_types/day/admin/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends '_types/day/index.html' %}
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
{% import 'macros/links.html' as links %}
|
||||
|
||||
|
||||
{% block day_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% call header() %}
|
||||
{% from '_types/day/admin/header/_header.html' import header_row with context %}
|
||||
{{ header_row() }}
|
||||
<div id="day-admin-header-child">
|
||||
{% block day_admin_header_child %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/day/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/day/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
26
events/templates/_types/day/header/_header.html
Normal file
26
events/templates/_types/day/header/_header.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='day-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'calendars.calendar.day.show_day',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
day=day_date.day
|
||||
),
|
||||
hx_select_search,
|
||||
) %}
|
||||
<div class="flex gap-1 items-center">
|
||||
<i class="fa fa-calendar-day"></i>
|
||||
{{ day_date.strftime('%A %d %B %Y') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/day/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
18
events/templates/_types/day/index.html
Normal file
18
events/templates/_types/day/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends '_types/calendar/index.html' %}
|
||||
|
||||
{% block calendar_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('day-header-child', '_types/day/header/_header.html') %}
|
||||
{% block day_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/day/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/day/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
332
events/templates/_types/entry/_edit.html
Normal file
332
events/templates/_types/entry/_edit.html
Normal file
@@ -0,0 +1,332 @@
|
||||
<section id="entry-{{ entry.id }}"
|
||||
class="{{styles.list_container}}">
|
||||
|
||||
<!-- Error container -->
|
||||
<div id="entry-errors-{{ entry.id }}" class="mt-2 text-sm text-red-600"></div>
|
||||
|
||||
<form
|
||||
class="space-y-3 mt-4"
|
||||
hx-put="{{ url_for(
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.put',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day, month=month, year=year,
|
||||
entry_id=entry.id
|
||||
) }}"
|
||||
hx-target="#entry-{{ entry.id }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700 mb-1"
|
||||
for="entry-name-{{ entry.id }}">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="entry-name-{{ entry.id }}"
|
||||
name="name"
|
||||
class="w-full border p-2 rounded"
|
||||
placeholder="Name"
|
||||
value="{{ entry.name }}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Slot picker -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700 mb-1"
|
||||
for="entry-slot-{{ entry.id }}">
|
||||
Slot
|
||||
</label>
|
||||
{% if day_slots %}
|
||||
<select
|
||||
id="entry-slot-{{ entry.id }}"
|
||||
name="slot_id"
|
||||
class="w-full border p-2 rounded"
|
||||
data-slot-picker
|
||||
required
|
||||
>
|
||||
{% for slot in day_slots %}
|
||||
<option
|
||||
value="{{ slot.id }}"
|
||||
data-start="{{ slot.time_start.strftime('%H:%M') }}"
|
||||
data-end="{{ slot.time_end.strftime('%H:%M') if slot.time_end else '' }}"
|
||||
data-flexible="{{ '1' if slot.flexible else '0' }}"
|
||||
data-cost="{{ slot.cost if slot.cost is not none else '0' }}"
|
||||
{% if entry.slot_id == slot.id %}selected{% endif %}
|
||||
>
|
||||
{{ slot.name }}
|
||||
({{ slot.time_start.strftime('%H:%M') }}
|
||||
{% if slot.time_end %}–{{ slot.time_end.strftime('%H:%M') }}{% else %}–open-ended{% endif %})
|
||||
{% if slot.flexible %}[flexible]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="text-sm text-stone-500">
|
||||
No slots defined for this day.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Time inputs — shown only for flexible slots -->
|
||||
<div data-time-fields class="hidden space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700 mb-1"
|
||||
for="entry-start-{{ entry.id }}">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
id="entry-start-{{ entry.id }}"
|
||||
name="start_at"
|
||||
type="time"
|
||||
class="w-full border p-2 rounded"
|
||||
value="{{ entry.start_at.strftime('%H:%M') if entry.start_at else '' }}"
|
||||
data-entry-start
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700 mb-1"
|
||||
for="entry-end-{{ entry.id }}">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
id="entry-end-{{ entry.id }}"
|
||||
name="end_at"
|
||||
type="time"
|
||||
class="w-full border p-2 rounded"
|
||||
value="{{ entry.end_at.strftime('%H:%M') if entry.end_at else '' }}"
|
||||
data-entry-end
|
||||
>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-stone-500" data-slot-boundary></p>
|
||||
</div>
|
||||
|
||||
<!-- Fixed time summary — shown for non-flexible slots -->
|
||||
<div data-fixed-summary class="hidden text-sm text-stone-600"></div>
|
||||
|
||||
<!-- Cost display — shown when a slot is selected -->
|
||||
<div data-cost-row class="hidden text-sm font-medium text-stone-700">
|
||||
Estimated Cost: <span data-cost-display class="text-green-600">£{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Configuration -->
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<h4 class="text-sm font-semibold text-stone-700 mb-3">Ticket Configuration</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700 mb-1"
|
||||
for="entry-ticket-price-{{ entry.id }}">
|
||||
Ticket Price (£)
|
||||
</label>
|
||||
<input
|
||||
id="entry-ticket-price-{{ entry.id }}"
|
||||
name="ticket_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full border p-2 rounded"
|
||||
placeholder="Leave empty for no tickets"
|
||||
value="{{ ('%.2f'|format(entry.ticket_price)) if entry.ticket_price is not none else '' }}"
|
||||
>
|
||||
<p class="text-xs text-stone-500 mt-1">Leave empty if no tickets needed</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700 mb-1"
|
||||
for="entry-ticket-count-{{ entry.id }}">
|
||||
Total Tickets
|
||||
</label>
|
||||
<input
|
||||
id="entry-ticket-count-{{ entry.id }}"
|
||||
name="ticket_count"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full border p-2 rounded"
|
||||
placeholder="Leave empty for unlimited"
|
||||
value="{{ entry.ticket_count if entry.ticket_count is not none else '' }}"
|
||||
>
|
||||
<p class="text-xs text-stone-500 mt-1">Leave empty for unlimited</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
|
||||
<!-- Cancel button -->
|
||||
<button
|
||||
type="button"
|
||||
class="{{ styles.cancel_button }}"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day, month=month, year=year,
|
||||
entry_id=entry.id
|
||||
) }}"
|
||||
hx-target="#entry-{{ entry.id }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<!-- Save button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="{{ styles.action_button }}"
|
||||
data-confirm="true"
|
||||
data-confirm-title="Save entry?"
|
||||
data-confirm-text="Are you sure you want to save this entry?"
|
||||
data-confirm-icon="question"
|
||||
data-confirm-confirm-text="Yes, save it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
>
|
||||
<i class="fa fa-save"></i>
|
||||
Save entry
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# --- Behaviour: lock / unlock times based on slot.flexible --- #}
|
||||
<script>
|
||||
(function () {
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function calculateCost(slotCost, slotStart, slotEnd, actualStart, actualEnd, flexible) {
|
||||
if (!flexible) {
|
||||
// Fixed slot: use full slot cost
|
||||
return parseFloat(slotCost);
|
||||
}
|
||||
|
||||
// Flexible slot: prorate based on time range
|
||||
if (!actualStart || !actualEnd) return 0;
|
||||
|
||||
const slotStartMin = timeToMinutes(slotStart);
|
||||
const slotEndMin = timeToMinutes(slotEnd);
|
||||
const actualStartMin = timeToMinutes(actualStart);
|
||||
const actualEndMin = timeToMinutes(actualEnd);
|
||||
|
||||
const slotDuration = slotEndMin - slotStartMin;
|
||||
const actualDuration = actualEndMin - actualStartMin;
|
||||
|
||||
if (slotDuration <= 0 || actualDuration <= 0) return 0;
|
||||
|
||||
const ratio = actualDuration / slotDuration;
|
||||
return parseFloat(slotCost) * ratio;
|
||||
}
|
||||
|
||||
function initEntrySlotPicker(root) {
|
||||
const select = root.querySelector('[data-slot-picker]');
|
||||
if (!select) return;
|
||||
|
||||
const timeFields = root.querySelector('[data-time-fields]');
|
||||
const startInput = root.querySelector('[data-entry-start]');
|
||||
const endInput = root.querySelector('[data-entry-end]');
|
||||
const helper = root.querySelector('[data-slot-boundary]');
|
||||
const costDisplay = root.querySelector('[data-cost-display]');
|
||||
const costRow = root.querySelector('[data-cost-row]');
|
||||
const fixedSummary = root.querySelector('[data-fixed-summary]');
|
||||
|
||||
if (!startInput || !endInput) return;
|
||||
|
||||
function updateCost() {
|
||||
const opt = select.selectedOptions[0];
|
||||
if (!opt || !opt.value) {
|
||||
if (costDisplay) costDisplay.textContent = '£0.00';
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = opt.dataset.cost || '0';
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
const calculatedCost = calculateCost(
|
||||
cost, s, e,
|
||||
startInput.value, endInput.value,
|
||||
flexible
|
||||
);
|
||||
|
||||
if (costDisplay) {
|
||||
costDisplay.textContent = '£' + calculatedCost.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFromOption(opt) {
|
||||
if (!opt || !opt.value) {
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (costRow) costRow.classList.add('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
if (!flexible) {
|
||||
// Fixed slot: hide time inputs, show summary + cost
|
||||
if (s) startInput.value = s;
|
||||
if (e) endInput.value = e;
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (fixedSummary) {
|
||||
fixedSummary.classList.remove('hidden');
|
||||
if (e) {
|
||||
fixedSummary.textContent = `${s} – ${e}`;
|
||||
} else {
|
||||
fixedSummary.textContent = `From ${s} (open-ended)`;
|
||||
}
|
||||
}
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
} else {
|
||||
// Flexible slot: show time inputs, hide fixed summary, show cost
|
||||
if (timeFields) timeFields.classList.remove('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
if (helper) {
|
||||
if (e) {
|
||||
helper.textContent = `Times must be between ${s} and ${e}.`;
|
||||
} else {
|
||||
helper.textContent = `Start at or after ${s}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCost();
|
||||
}
|
||||
|
||||
// Initial state
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
});
|
||||
|
||||
// Update cost when times change (for flexible slots)
|
||||
startInput.addEventListener('input', updateCost);
|
||||
endInput.addEventListener('input', updateCost);
|
||||
}
|
||||
|
||||
// Initial load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEntrySlotPicker(document);
|
||||
});
|
||||
|
||||
// HTMX fragments
|
||||
if (window.htmx) {
|
||||
htmx.onLoad((content) => {
|
||||
initEntrySlotPicker(content);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
128
events/templates/_types/entry/_main_panel.html
Normal file
128
events/templates/_types/entry/_main_panel.html
Normal file
@@ -0,0 +1,128 @@
|
||||
<section id="entry-{{ entry.id }}" class="{{styles.list_container}}">
|
||||
|
||||
<!-- Entry Name -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Name
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-medium">
|
||||
{{ entry.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Slot
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{% if entry.slot %}
|
||||
<span class="px-2 py-1 rounded text-sm bg-blue-100 text-blue-700">
|
||||
{{ entry.slot.name }}
|
||||
</span>
|
||||
{% if entry.slot.flexible %}
|
||||
<span class="ml-2 text-xs text-stone-500">(flexible)</span>
|
||||
{% else %}
|
||||
<span class="ml-2 text-xs text-stone-500">(fixed)</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-sm text-stone-400">No slot assigned</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Period -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Time Period
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ entry.start_at.strftime('%H:%M') }}
|
||||
{% if entry.end_at %}
|
||||
– {{ entry.end_at.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
– open-ended
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
State
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div id="entry-state-{{entry.id}}">
|
||||
{% include '_types/entry/_state.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Cost
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="font-medium text-green-600">
|
||||
£{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Configuration -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Tickets
|
||||
</div>
|
||||
<div class="mt-1" id="entry-tickets-{{entry.id}}">
|
||||
{% include '_types/entry/_tickets.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Tickets (public-facing) -->
|
||||
{% include '_types/tickets/_buy_form.html' %}
|
||||
|
||||
<!-- Date -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Date
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ entry.start_at.strftime('%A, %B %d, %Y') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Associated Posts -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Associated Posts
|
||||
</div>
|
||||
<div class="mt-1" id="entry-posts-{{entry.id}}">
|
||||
{% include '_types/entry/_posts.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options and Edit Button -->
|
||||
<div class="flex gap-2 mt-6">
|
||||
{% include '_types/entry/_options.html' %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
|
||||
entry_id=entry.id,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#entry-{{entry.id}}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
39
events/templates/_types/entry/_nav.html
Normal file
39
events/templates/_types/entry/_nav.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
|
||||
{# Associated Posts - vertical on mobile, horizontal with arrows on desktop #}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="entry-posts-nav-wrapper">
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||
<a
|
||||
href="{{ blog_url('/' + entry_post.slug + '/') }}"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
|
||||
{% if entry_post.feature_image %}
|
||||
<img src="{{ entry_post.feature_image }}"
|
||||
alt="{{ entry_post.title }}"
|
||||
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry_post.title }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
{# Admin link #}
|
||||
{% if g.rights.admin %}
|
||||
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for(
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
entry_id=entry.id
|
||||
)
|
||||
)}}
|
||||
{% endif %}
|
||||
18
events/templates/_types/entry/_oob_elements.html
Normal file
18
events/templates/_types/entry/_oob_elements.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "oob_elements.html" %}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('day-header-child', 'entry-header-child', '_types/entry/header/_header.html')}}
|
||||
|
||||
{% from '_types/day/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/entry/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/entry/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
9
events/templates/_types/entry/_optioned.html
Normal file
9
events/templates/_types/entry/_optioned.html
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
{% include '_types/entry/_options.html' %}
|
||||
<div id="entry-title-{{entry.id}}" hx-swap-oob="innerHTML">
|
||||
{% include '_types/entry/_title.html' %}
|
||||
</div>
|
||||
|
||||
<div id="entry-state-{{entry.id}}" hx-swap-oob="innerHTML">
|
||||
{% include '_types/entry/_state.html' %}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user