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

4
events/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
node_modules/

49
events/Dockerfile Normal file
View 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
View 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
View File

154
events/app.py Normal file
View 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
View 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

View File

View 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

View 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

View 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

View File

@@ -0,0 +1 @@
from .visiblity import get_visible_entries_for_period

View File

@@ -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

View 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

View 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

View 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

View 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,
)

View 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

View 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

View 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

View 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

View 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)

View 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

View 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

View 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

View 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
View 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

View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

View 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

View File

View 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

View File

View 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)

View File

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

View File

View 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
View 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

View 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
View 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

View 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

View File

View 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

View 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

View 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

View 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

View 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

View File

308
events/bp/tickets/routes.py Normal file
View File

@@ -0,0 +1,308 @@
"""
Tickets blueprint — user-facing ticket views and QR codes.
Routes:
GET /tickets/ — My tickets list
GET /tickets/<code>/ — Ticket detail with QR code
POST /tickets/buy/ — Purchase tickets for an entry
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
"""
from __future__ import annotations
import logging
from quart import (
Blueprint, g, request, render_template, make_response,
)
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from shared.infrastructure.cart_identity import current_cart_identity
from shared.browser.app.redis_cacher import clear_cache
from .services.tickets import (
create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count,
get_tickets_for_entry,
get_sold_ticket_count,
get_user_reserved_count,
cancel_latest_reserved_ticket,
)
logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
async def my_tickets():
"""List all tickets for the current user/session."""
from shared.browser.app.utils.htmx import is_htmx_request
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
if not is_htmx_request():
html = await render_template(
"_types/tickets/index.html",
tickets=tickets,
)
else:
html = await render_template(
"_types/tickets/_main_panel.html",
tickets=tickets,
)
return await make_response(html, 200)
@bp.get("/<code>/")
async def ticket_detail(code: str):
"""View a single ticket with QR code."""
from shared.browser.app.utils.htmx import is_htmx_request
ticket = await get_ticket_by_code(g.s, code)
if not ticket:
return await make_response("Ticket not found", 404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
return await make_response("Ticket not found", 404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
return await make_response("Ticket not found", 404)
else:
return await make_response("Ticket not found", 404)
if not is_htmx_request():
html = await render_template(
"_types/tickets/detail.html",
ticket=ticket,
)
else:
html = await render_template(
"_types/tickets/_detail_panel.html",
ticket=ticket,
)
return await make_response(html, 200)
@bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets():
"""
Purchase tickets for a calendar entry.
Creates ticket records with state='reserved' (awaiting payment).
Form fields:
entry_id — the calendar entry ID
ticket_type_id (optional) — specific ticket type
quantity — number of tickets (default 1)
"""
form = await request.form
entry_id_raw = form.get("entry_id", "").strip()
if not entry_id_raw:
return await make_response("Entry ID required", 400)
try:
entry_id = int(entry_id_raw)
except ValueError:
return await make_response("Invalid entry ID", 400)
# Load entry
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
if not entry:
return await make_response("Entry not found", 404)
if entry.ticket_price is None:
return await make_response("Tickets not available for this entry", 400)
# Check availability
available = await get_available_ticket_count(g.s, entry_id)
quantity = int(form.get("quantity", 1))
if quantity < 1:
quantity = 1
if available is not None and quantity > available:
return await make_response(
f"Only {available} ticket(s) remaining", 400
)
# Ticket type (optional)
ticket_type_id = None
tt_raw = form.get("ticket_type_id", "").strip()
if tt_raw:
try:
ticket_type_id = int(tt_raw)
except ValueError:
pass
ident = current_cart_identity()
# Create tickets
created = []
for _ in range(quantity):
ticket = await create_ticket(
g.s,
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
state="reserved",
)
created.append(ticket)
# Re-check availability for display
remaining = await get_available_ticket_count(g.s, entry_id)
all_tickets = await get_tickets_for_entry(g.s, entry_id)
html = await render_template(
"_types/tickets/_buy_result.html",
entry=entry,
created_tickets=created,
remaining=remaining,
all_tickets=all_tickets,
)
return await make_response(html, 200)
@bp.post("/adjust/")
@clear_cache(tag="calendars", tag_scope="all")
async def adjust_quantity():
"""
Adjust ticket quantity for a calendar entry (+/- pattern).
Creates or cancels tickets to reach the target count.
Form fields:
entry_id — the calendar entry ID
ticket_type_id — (optional) specific ticket type
count — target quantity of reserved tickets
"""
form = await request.form
entry_id_raw = form.get("entry_id", "").strip()
if not entry_id_raw:
return await make_response("Entry ID required", 400)
try:
entry_id = int(entry_id_raw)
except ValueError:
return await make_response("Invalid entry ID", 400)
# Load entry
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
if not entry:
return await make_response("Entry not found", 404)
if entry.ticket_price is None:
return await make_response("Tickets not available for this entry", 400)
# Ticket type (optional)
ticket_type_id = None
tt_raw = form.get("ticket_type_id", "").strip()
if tt_raw:
try:
ticket_type_id = int(tt_raw)
except ValueError:
pass
target = max(int(form.get("count", 0)), 0)
ident = current_cart_identity()
current = await get_user_reserved_count(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
if target > current:
# Need to add tickets
to_add = target - current
available = await get_available_ticket_count(g.s, entry_id)
if available is not None and to_add > available:
return await make_response(
f"Only {available} ticket(s) remaining", 400
)
for _ in range(to_add):
await create_ticket(
g.s,
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
state="reserved",
)
elif target < current:
# Need to remove tickets
to_remove = current - target
for _ in range(to_remove):
await cancel_latest_reserved_ticket(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
# Build context for re-rendering the buy form
ticket_remaining = await get_available_ticket_count(g.s, entry_id)
ticket_sold_count = await get_sold_ticket_count(g.s, entry_id)
user_ticket_count = await get_user_reserved_count(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
# Per-type counts for multi-type entries
user_ticket_counts_by_type = {}
if entry.ticket_types:
for tt in entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
# Compute cart count for OOB mini-cart update
from shared.services.registry import services
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
cart_count = summary.count + summary.calendar_count + summary.ticket_count
html = await render_template(
"_types/tickets/_adjust_response.html",
entry=entry,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
cart_count=cart_count,
)
return await make_response(html, 200)
return bp

View File

View File

@@ -0,0 +1,313 @@
"""
Ticket service layer — create, query, and manage tickets.
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from typing import Optional
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.calendars import Ticket, TicketType, CalendarEntry
async def create_ticket(
session: AsyncSession,
*,
entry_id: int,
ticket_type_id: Optional[int] = None,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
order_id: Optional[int] = None,
state: str = "reserved",
) -> Ticket:
"""Create a single ticket with a unique code."""
ticket = Ticket(
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=user_id,
session_id=session_id,
order_id=order_id,
code=uuid.uuid4().hex,
state=state,
)
session.add(ticket)
await session.flush()
return ticket
async def create_tickets_for_order(
session: AsyncSession,
order_id: int,
user_id: Optional[int],
session_id: Optional[str],
) -> list[Ticket]:
"""
Create ticket records for all calendar entries in an order
that have ticket_price configured.
Called during checkout after calendar entries are transitioned to 'ordered'.
"""
# Find all ordered entries for this order that have ticket pricing
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.order_id == order_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.ticket_price.isnot(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
entries = result.scalars().all()
tickets = []
for entry in entries:
if entry.ticket_types:
# Entry has specific ticket types — create one ticket per type
# (quantity handling can be added later)
for tt in entry.ticket_types:
if tt.deleted_at is None:
ticket = await create_ticket(
session,
entry_id=entry.id,
ticket_type_id=tt.id,
user_id=user_id,
session_id=session_id,
order_id=order_id,
state="reserved",
)
tickets.append(ticket)
else:
# Simple ticket — one per entry
ticket = await create_ticket(
session,
entry_id=entry.id,
user_id=user_id,
session_id=session_id,
order_id=order_id,
state="reserved",
)
tickets.append(ticket)
return tickets
async def confirm_tickets_for_order(
session: AsyncSession,
order_id: int,
) -> int:
"""
Transition tickets from reserved → confirmed when payment succeeds.
Returns the number of tickets confirmed.
"""
result = await session.execute(
update(Ticket)
.where(
Ticket.order_id == order_id,
Ticket.state == "reserved",
)
.values(state="confirmed")
)
return result.rowcount
async def get_ticket_by_code(
session: AsyncSession,
code: str,
) -> Optional[Ticket]:
"""Look up a ticket by its unique code."""
result = await session.execute(
select(Ticket)
.where(Ticket.code == code)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
)
return result.scalar_one_or_none()
async def get_user_tickets(
session: AsyncSession,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
state: Optional[str] = None,
) -> list[Ticket]:
"""Get all tickets for a user or session."""
filters = []
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return []
if state:
filters.append(Ticket.state == state)
else:
# Exclude cancelled by default
filters.append(Ticket.state != "cancelled")
result = await session.execute(
select(Ticket)
.where(*filters)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
)
return result.scalars().all()
async def get_tickets_for_entry(
session: AsyncSession,
entry_id: int,
) -> list[Ticket]:
"""Get all non-cancelled tickets for a calendar entry."""
result = await session.execute(
select(Ticket)
.where(
Ticket.entry_id == entry_id,
Ticket.state != "cancelled",
)
.options(
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.asc())
)
return result.scalars().all()
async def get_sold_ticket_count(
session: AsyncSession,
entry_id: int,
) -> int:
"""Count all non-cancelled tickets for an entry (total sold/reserved)."""
result = await session.scalar(
select(func.count(Ticket.id)).where(
Ticket.entry_id == entry_id,
Ticket.state != "cancelled",
)
)
return result or 0
async def get_user_reserved_count(
session: AsyncSession,
entry_id: int,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
ticket_type_id: Optional[int] = None,
) -> int:
"""Count reserved tickets for a specific user/session + entry + optional type."""
filters = [
Ticket.entry_id == entry_id,
Ticket.state == "reserved",
]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return 0
if ticket_type_id is not None:
filters.append(Ticket.ticket_type_id == ticket_type_id)
result = await session.scalar(
select(func.count(Ticket.id)).where(*filters)
)
return result or 0
async def cancel_latest_reserved_ticket(
session: AsyncSession,
entry_id: int,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
ticket_type_id: Optional[int] = None,
) -> bool:
"""Cancel the most recently created reserved ticket. Returns True if one was cancelled."""
filters = [
Ticket.entry_id == entry_id,
Ticket.state == "reserved",
]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return False
if ticket_type_id is not None:
filters.append(Ticket.ticket_type_id == ticket_type_id)
ticket = await session.scalar(
select(Ticket)
.where(*filters)
.order_by(Ticket.created_at.desc())
.limit(1)
)
if ticket:
ticket.state = "cancelled"
await session.flush()
return True
return False
async def get_available_ticket_count(
session: AsyncSession,
entry_id: int,
) -> Optional[int]:
"""
Get number of remaining tickets for an entry.
Returns None if unlimited.
"""
entry = await session.scalar(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
if not entry or entry.ticket_price is None:
return None
if entry.ticket_count is None:
return None # Unlimited
# Count non-cancelled tickets
sold = await session.scalar(
select(func.count(Ticket.id)).where(
Ticket.entry_id == entry_id,
Ticket.state != "cancelled",
)
)
return max(0, entry.ticket_count - (sold or 0))
async def checkin_ticket(
session: AsyncSession,
code: str,
) -> tuple[bool, Optional[str]]:
"""
Check in a ticket by its code.
Returns (success, error_message).
"""
from datetime import datetime, timezone
ticket = await get_ticket_by_code(session, code)
if not ticket:
return False, "Ticket not found"
if ticket.state == "checked_in":
return False, "Ticket already checked in"
if ticket.state == "cancelled":
return False, "Ticket is cancelled"
if ticket.state not in ("confirmed", "reserved"):
return False, f"Ticket in unexpected state: {ticket.state}"
ticket.state = "checked_in"
ticket.checked_in_at = datetime.now(timezone.utc)
return True, None

View 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
View 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}

View File

@@ -0,0 +1,4 @@
from .calendars import (
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

View 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
View 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)

View 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()

View 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> &middot;
{% else %}
{{ entry.start_at.strftime('%a %-d %b') }} &middot;
{% endif %}
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} &ndash; {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
{% if entry.cost %}
<div class="mt-1 text-sm font-medium text-green-600">
&pound;{{ '%.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>

View 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 %}
&middot;
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} &ndash; {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
{% if entry.cost %}
<div class="mt-1 text-sm font-medium text-green-600">
&pound;{{ '%.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>

View 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 %}

View 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>

View File

@@ -0,0 +1,7 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block content %}
{% include '_types/all_events/_main_panel.html' %}
{% endblock %}

View 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 %}

View 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"
>
&laquo;
</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"
>
&lsaquo;
</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"
>
&rsaquo;
</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"
>
&raquo;
</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>

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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>

View 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>

View 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 %}

View 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 %}

View 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>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View 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 %}

View 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