feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract events/calendar functionality into standalone microservice:
- app.py and events_api.py from apps/events/
- Calendar blueprints (calendars, calendar, calendar_entries, calendar_entry, day, slots, slot, ticket_types, ticket_type)
- Templates for all calendar/event views including admin
- Dockerfile (APP_MODULE=app:app, IMAGE=events)
- entrypoint.sh (no Alembic - migrations managed by blog app)
- Gitea CI workflow for build and deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:16:32 +00:00
commit 3c0fa45f8c
119 changed files with 7163 additions and 0 deletions

63
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,63 @@
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: registry.rose-ash.com:5000
IMAGE: events
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
apt-get update && apt-get install -y --no-install-recommends openssh-client
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Pull latest code on server
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/events
git fetch origin main
git reset --hard origin/main
"
- name: Build and push image
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/events
docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} .
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
"
- name: Deploy stack
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/events
source .env
docker stack deploy -c docker-compose.yml coop
echo 'Waiting for services to update...'
sleep 10
docker stack services coop
"

4
.gitignore vendored Normal file
View File

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

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
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 requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .
# ---------- Runtime setup ----------
COPY 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"]

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# Events App
Calendar and event booking service for the Rose Ash cooperative platform.
## Overview
The events app provides calendar-based event booking with flexible slot management.
It runs as a standalone Quart microservice, part of the multi-app coop architecture.
## Structure
```
app.py # Application factory and entry point
events_api.py # Internal JSON API (server-to-server, CSRF-exempt)
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
templates/ # Jinja2 templates
_types/ # Feature-specific templates
```
## Running
```bash
# Set required environment variables (see .env.example)
export APP_MODULE=app:app
hypercorn app:app --bind 0.0.0.0:8000
```
## Docker
```bash
docker build -t events .
docker run -p 8000:8000 --env-file .env events
```
## Notes
- This app does **not** run Alembic migrations. Database schema is managed by the blog app.
- Internal API endpoints under `/internal/events/` are used by the cart app for cross-service communication.
- Depends on shared packages (`shared/`, `models/`, `config/`) from the main coop monorepo.

54
app.py Normal file
View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from quart import g
from shared.factory import create_base_app
from suma_browser.app.bp import register_calendars
async def events_context() -> dict:
"""
Events app context processor.
- menu_items: fetched from coop internal API
- cart_count/cart_total: fetched from cart internal API
"""
from shared.context import base_context
from shared.internal_api import get as api_get, dictobj
ctx = await base_context()
# Menu items from coop API (wrapped for attribute access in templates)
menu_data = await api_get("coop", "/internal/menu-items")
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
# Cart data from cart API
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
if cart_data:
ctx["cart_count"] = cart_data.get("count", 0)
ctx["cart_total"] = cart_data.get("total", 0)
else:
ctx["cart_count"] = 0
ctx["cart_total"] = 0
return ctx
def create_app() -> "Quart":
app = create_base_app("events", context_fn=events_context)
# Calendars blueprint at root — standalone mode (no post nesting)
app.register_blueprint(
register_calendars(),
url_prefix="/calendars",
)
# Internal API (server-to-server, CSRF-exempt)
from .events_api import register as register_events_api
app.register_blueprint(register_events_api())
return app
app = create_app()

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
)
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
# ---------- Pages ----------
@bp.get("/")
@require_admin
async def admin(slug: str, calendar_slug: str):
from suma_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(slug: str, calendar_slug: str):
# 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(slug: str, calendar_slug: str):
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(slug: str, calendar_slug: str):
# just render the display version without touching the DB (used by Cancel)
html = await render_template(
"_types/calendar/admin/_description.html",
post=g.post_data['post'],
calendar=g.calendar,
)
return await make_response(html)
return bp

251
bp/calendar/routes.py Normal file
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 suma_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 suma_browser.app.utils.htmx import is_htmx_request
from ..slots.routes import register as register_slots
from models.calendars import CalendarSlot
from suma_browser.app.bp.calendars.services.calendars import soft_delete
from suma_browser.app.bp.day.routes import register as register_day
from suma_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 suma_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.post_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,24 @@
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.post_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,117 @@
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,115 @@
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,226 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from quart import (
request, render_template, make_response, Blueprint, g, redirect, url_for, jsonify
)
from sqlalchemy import update
from models.calendars import CalendarEntry
from .services.entries import (
add_entry as svc_add_entry,
)
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from suma_browser.app.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
html = await render_template("_types/day/_main_panel.html")
return await make_response(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,258 @@
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 suma_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()
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.post_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 suma_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 suma_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

574
bp/calendar_entry/routes.py Normal file
View File

@@ -0,0 +1,574 @@
from __future__ import annotations
from sqlalchemy import select, update
from models.calendars import CalendarEntry, CalendarSlot
from suma_browser.app.authz import require_admin
from suma_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 ..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.post_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():
view_args = getattr(request, "view_args", {}) or {}
entry_id = view_args.get("entry_id")
calendar_entry = None
entry_posts = []
stmt = (
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
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)
return {
"entry": calendar_entry,
"entry_posts": entry_posts,
}
@bp.get("/")
@require_admin
async def get(entry_id: int, **rest):
from suma_browser.app.utils.htmx import is_htmx_request
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
# For now, render 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 updated entry view
html = await render_template("_types/entry/index.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,137 @@
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 models.ghost_content import Post
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 session.scalar(
select(Post).where(Post.id == 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.post_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,
post_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.post_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[Post]:
"""
Get all posts associated with a calendar entry.
"""
result = await session.execute(
select(Post)
.join(CalendarEntryPost)
.where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.deleted_at.is_(None)
)
.order_by(Post.title)
)
return list(result.scalars().all())
async def search_posts(
session: AsyncSession,
query: str,
page: int = 1,
per_page: int = 10
) -> tuple[list[Post], int]:
"""
Search for posts by title with pagination.
If query is empty, returns all posts in published order.
Returns (posts, total_count).
"""
# Build base query
if query:
# Search by title
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
# All posts in published order (newest first)
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
# Count total
count_result = await session.execute(count_stmt)
total = count_result.scalar() or 0
# Get paginated results
offset = (page - 1) * per_page
result = await session.execute(
posts_stmt.limit(per_page).offset(offset)
)
return list(result.scalars().all()), total

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
# TODO: Subtract booked tickets when ticket booking is implemented
# For now, just return the total count
return entry.ticket_count, None

98
bp/calendars/routes.py Normal file
View File

@@ -0,0 +1,98 @@
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 suma_browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin
from suma_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.post_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,104 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar
from models.ghost_content import Post # for FK existence checks
import unicodedata
import re
class CalendarError(ValueError):
"""Base error for calendar service operations."""
from suma_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:
cal = (
await sess.execute(
select(Calendar)
.join(Post, Calendar.post_id == Post.id)
.where(
Post.slug == post_slug,
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()
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 sess.execute(select(Post.id).where(Post.id == post_id))).scalar_one_or_none()
if not post:
raise CalendarError(f"Post {post_id} does not exist.")
# Look for existing (including soft-deleted)
q = await sess.execute(
select(Calendar).where(Calendar.post_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()
return existing
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
cal = Calendar(post_id=post_id, name=name, slug=slug)
sess.add(cal)
await sess.flush()
return cal

28
bp/day/admin/routes.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from quart import (
render_template, make_response, Blueprint
)
from suma_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 suma_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

121
bp/day/routes.py Normal file
View File

@@ -0,0 +1,121 @@
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 suma_browser.app.bp.calendar.services import get_visible_entries_for_period
from suma_browser.app.bp.calendar_entries.routes import register as register_calendar_entries
from .admin.routes import register as register_admin
from suma_browser.app.redis_cacher import cache_page
from models.calendars import CalendarSlot # add this import
from sqlalchemy import select
from suma_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())
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, # <-- NEW
}
@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)
return bp

182
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 suma_browser.app.authz import require_admin
from suma_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 suma_browser.app.utils import (
parse_time,
parse_cost
)
from suma_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

90
bp/slot/services/slot.py Normal file
View File

@@ -0,0 +1,90 @@
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
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 suma_browser.app.authz import require_admin
from suma_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 suma_browser.app.utils import (
parse_time,
parse_cost
)
from suma_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,64 @@
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

159
bp/ticket_type/routes.py Normal file
View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
)
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from .services.ticket import (
get_ticket_type as svc_get_ticket_type,
update_ticket_type as svc_update_ticket_type,
soft_delete_ticket_type as svc_delete_ticket_type,
)
from ..ticket_types.services.tickets import (
list_ticket_types as svc_list_ticket_types,
)
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.get("/")
@require_admin
async def get(ticket_type_id: int, **kwargs):
"""View a single ticket type."""
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
if not ticket_type:
return await make_response("Not found", 404)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/ticket_type/index.html",
ticket_type=ticket_type,
)
else:
html = await render_template(
"_types/ticket_type/_oob_elements.html",
ticket_type=ticket_type,
)
return await make_response(html)
@bp.get("/edit/")
@require_admin
async def get_edit(ticket_type_id: int, **kwargs):
"""Show the edit form for a ticket type."""
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
if not ticket_type:
return await make_response("Not found", 404)
html = await render_template(
"_types/ticket_type/_edit.html",
ticket_type=ticket_type,
)
return await make_response(html)
@bp.get("/view/")
@require_admin
async def get_view(ticket_type_id: int, **kwargs):
"""Show the view for a ticket type."""
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
if not ticket_type:
return await make_response("Not found", 404)
html = await render_template(
"_types/ticket_type/_main_panel.html",
ticket_type=ticket_type,
)
return await make_response(html)
@bp.put("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def put(ticket_type_id: int, **kwargs):
"""Update a ticket type."""
form = await request.form
name = (form.get("name") or "").strip()
cost_str = (form.get("cost") or "").strip()
count_str = (form.get("count") or "").strip()
field_errors: dict[str, list[str]] = {}
# Validate name
if not name:
field_errors.setdefault("name", []).append("Please enter a ticket type name.")
# Validate cost
cost = None
if not cost_str:
field_errors.setdefault("cost", []).append("Please enter a cost.")
else:
try:
cost = float(cost_str)
if cost < 0:
field_errors.setdefault("cost", []).append("Cost must be positive.")
except ValueError:
field_errors.setdefault("cost", []).append("Please enter a valid number.")
# Validate count
count = None
if not count_str:
field_errors.setdefault("count", []).append("Please enter a ticket count.")
else:
try:
count = int(count_str)
if count < 0:
field_errors.setdefault("count", []).append("Count must be positive.")
except ValueError:
field_errors.setdefault("count", []).append("Please enter a valid whole number.")
if field_errors:
return jsonify({
"message": "Please fix the highlighted fields.",
"errors": field_errors,
}), 422
# Update ticket type
ticket_type = await svc_update_ticket_type(
g.s,
ticket_type_id,
name=name,
cost=cost,
count=count,
)
if not ticket_type:
return await make_response("Not found", 404)
# Return updated view with OOB flag
html = await render_template(
"_types/ticket_type/_main_panel.html",
ticket_type=ticket_type,
oob=True,
)
return await make_response(html)
@bp.delete("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def delete(ticket_type_id: int, **kwargs):
"""Soft-delete a ticket type."""
success = await svc_delete_ticket_type(g.s, ticket_type_id)
if not success:
return await make_response("Not found", 404)
# Re-render the ticket types list
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
html = await render_template(
"_types/ticket_types/_main_panel.html",
ticket_types=ticket_types
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import TicketType
from datetime import datetime, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)
async def get_ticket_type(session: AsyncSession, ticket_type_id: int) -> TicketType | None:
"""Get a single ticket type by ID (only if not soft-deleted)."""
result = await session.execute(
select(TicketType)
.where(
TicketType.id == ticket_type_id,
TicketType.deleted_at.is_(None)
)
)
return result.scalar_one_or_none()
async def update_ticket_type(
session: AsyncSession,
ticket_type_id: int,
*,
name: str,
cost: float,
count: int,
) -> TicketType | None:
"""Update an existing ticket type."""
ticket_type = await get_ticket_type(session, ticket_type_id)
if not ticket_type:
return None
ticket_type.name = name
ticket_type.cost = cost
ticket_type.count = count
ticket_type.updated_at = utcnow()
await session.flush()
return ticket_type
async def soft_delete_ticket_type(session: AsyncSession, ticket_type_id: int) -> bool:
"""Soft-delete a ticket type."""
ticket_type = await get_ticket_type(session, ticket_type_id)
if not ticket_type:
return False
ticket_type.deleted_at = utcnow()
await session.flush()
return True

132
bp/ticket_types/routes.py Normal file
View File

@@ -0,0 +1,132 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
)
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from .services.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 suma_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,47 @@
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

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

132
events_api.py Normal file
View File

@@ -0,0 +1,132 @@
"""
Internal JSON API for the events app.
These endpoints are called by other apps (cart) over HTTP.
They are CSRF-exempt because they are server-to-server calls.
"""
from __future__ import annotations
from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Calendar
from suma_browser.app.csrf import csrf_exempt
def register() -> Blueprint:
bp = Blueprint("events_api", __name__, url_prefix="/internal/events")
@bp.get("/calendar-entries")
@csrf_exempt
async def calendar_entries():
"""
Return pending calendar entries for a user/session.
Used by the cart app to display calendar items in the cart.
Query params: user_id, session_id, state (default: pending)
"""
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
state = request.args.get("state", "pending")
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == state,
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id:
filters.append(CalendarEntry.session_id == session_id)
else:
return jsonify([])
result = await g.s.execute(
select(CalendarEntry)
.where(*filters)
.options(selectinload(CalendarEntry.calendar))
.order_by(CalendarEntry.start_at.asc())
)
entries = result.scalars().all()
return jsonify([
{
"id": e.id,
"name": e.name,
"cost": float(e.cost) if e.cost else 0,
"state": e.state,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
"calendar_name": e.calendar.name if e.calendar else None,
"calendar_slug": e.calendar.slug if e.calendar else None,
}
for e in entries
])
@bp.post("/adopt")
@csrf_exempt
async def adopt():
"""
Adopt anonymous calendar entries for a user.
Called by the cart app after login.
Body: {"user_id": int, "session_id": str}
"""
data = await request.get_json() or {}
user_id = data.get("user_id")
session_id = data.get("session_id")
if not user_id or not session_id:
return jsonify({"ok": False, "error": "user_id and session_id required"}), 400
# Soft-delete existing user entries
await g.s.execute(
update(CalendarEntry)
.where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.user_id == user_id,
)
.values(deleted_at=func.now())
)
# Adopt anonymous entries
cal_result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
return jsonify({"ok": True})
@bp.get("/entry/<int:entry_id>")
@csrf_exempt
async def entry_detail(entry_id: int):
"""
Return entry details for order display.
Called by the cart app when showing order items.
"""
result = await g.s.execute(
select(CalendarEntry)
.where(CalendarEntry.id == entry_id)
.options(selectinload(CalendarEntry.calendar))
)
entry = result.scalar_one_or_none()
if not entry:
return jsonify(None), 404
return jsonify({
"id": entry.id,
"name": entry.name,
"cost": float(entry.cost) if entry.cost else 0,
"state": entry.state,
"start_at": entry.start_at.isoformat() if entry.start_at else None,
"end_at": entry.end_at.isoformat() if entry.end_at else None,
"calendar_name": entry.calendar.name if entry.calendar else None,
"calendar_slug": entry.calendar.slug if entry.calendar else None,
})
return bp

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,180 @@
<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('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
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('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_month_year,
month=prev_month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
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('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_month_year,
month=next_month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
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('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_year,
month=month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
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('blog.post.calendars.calendar.day.show_day',
slug=post.slug,
calendar_slug=calendar.slug,
year=day.date.year,
month=day.date.month,
day=day.date.day) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.show_day',
slug=post.slug,
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,17 @@
<!-- Desktop nav -->
{% import 'macros/links.html' as links %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug=calendar.slug),
hx_select_search,
select_colours,
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('blog.post.calendars.calendar.admin.admin', slug=post.slug, 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,33 @@
<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(
'blog.post.calendars.calendar.admin.calendar_description_edit',
slug=post.slug,
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,43 @@
<div id="calendar-description">
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.admin.calendar_description_save',
slug=post.slug,
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(
'blog.post.calendars.calendar.admin.calendar_description_view',
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"
hx-swap="outerHTML"
>
Cancel
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,46 @@
<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-put="{{ url_for('blog.post.calendars.calendar.put', slug=post.slug, calendar_slug=calendar.slug ) }}"
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,14 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug),
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('blog.post.calendars.calendar.get', slug=post.slug, 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,20 @@
{% extends '_types/post/index.html' %}
{% block post_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('calendar-header-child', '_types/calendar/header/_header.html') %}
{% block calendar_header_child %}
{% endblock %}
{% 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('blog.post.calendars.calendar.get', slug=post.slug, 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('blog.post.calendars.calendar.delete', slug=post.slug, 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('blog.post.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('blog.post.calendars.create_calendar', slug=post.slug) }}"
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('blog.post.calendars.home', slug=post.slug), 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,22 @@
{% extends '_types/post/admin/index.html' %}
{% block post_admin_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('calendars-header-child', '_types/calendars/header/_header.html') %}
{% block calendars_header_child %}
{% endblock %}
{% 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,301 @@
<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(
'blog.post.calendars.calendar.day.calendar_entries.add_entry',
slug=post.slug,
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('blog.post.calendars.calendar.day.calendar_entries.add_button',
slug=post.slug,
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,17 @@
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.add_form',
slug=post.slug,
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,41 @@
{% 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('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
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(
'blog.post.calendars.calendar.day.admin.admin',
slug=post.slug,
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,76 @@
{% 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(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
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(
'blog.post.calendars.calendar.slots.slot.get',
slug=post.slug,
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,34 @@
{# 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('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
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,21 @@
{% 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(
'blog.post.calendars.calendar.day.admin.admin',
slug=post.slug,
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,27 @@
{% 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(
'blog.post.calendars.calendar.day.show_day',
slug=post.slug,
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,334 @@
<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(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.put',
slug=post.slug,
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(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
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,126 @@
<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>
<!-- 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(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
entry_id=entry.id,
slug=post.slug,
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,40 @@
{% 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="{{ url_for('blog.post.post_detail', slug=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(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
slug=post.slug,
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>

View File

@@ -0,0 +1,98 @@
<div id="calendar_entry_options_{{ entry.id }}" class="flex flex-col md:flex-row gap-1">
{% if entry.state == 'provisional' %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-select="#calendar_entry_options_{{ entry.id }}"
hx-target="#calendar_entry_options_{{entry.id}}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
data-confirm="true"
data-confirm-title="Confirm entry?"
data-confirm-text="Are you sure you want to confirm this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, confirm it"
data-confirm-cancel-text="Cancel"
class="{{styles.action_button}}"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
confirm
</button>
</form>
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-select="#calendar_entry_options_{{ entry.id }}"
hx-target="#calendar_entry_options_{{entry.id}}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
data-confirm="true"
data-confirm-title="Decline entry?"
data-confirm-text="Are you sure you want to decline this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, decine it"
data-confirm-cancel-text="Cancel"
class="{{styles.action_button}}"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
decline
</button>
</form>
{% endif %}
{% if entry.state == 'confirmed' %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-target="#calendar_entry_options_{{ entry.id }}"
hx-select="#calendar_entry_options_{{ entry.id }}"
hx-swap="outerHTML"
hx-trigger="confirmed"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="button"
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Provisional entry?"
data-confirm-text="Are you sure you want to provisional this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, provisional it"
data-confirm-cancel-text="Cancel"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
provisional
</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,107 @@
{% for search_post in search_posts %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.add_post',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-target="#entry-posts-{{entry.id}}"
hx-swap="innerHTML"
class="p-2 hover:bg-stone-50 cursor-pointer rounded text-sm border-b"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="post_id" value="{{ search_post.id }}" />
<button
type="submit"
class="w-full text-left flex items-center gap-2"
data-confirm
data-confirm-title="Add post?"
data-confirm-text="Add {{ search_post.title }} to this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
>
{% if search_post.feature_image %}
<img src="{{ search_post.feature_image }}"
alt="{{ search_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 %}
<span>{{ search_post.title }}</span>
</button>
</form>
{% endfor %}
{# Infinite scroll sentinel #}
{% if page < total_pages|int %}
<div
id="post-search-sentinel-{{ page }}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id,
q=search_query,
page=page + 1
) }}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on sentinel:retry
remove .hidden from .js-loading in me
add .hidden to .js-neterr in me
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
trigger htmx:consume on me
call htmx.trigger(me, 'intersect')
end
def backoff()
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
set myMs to Number(me.dataset.retryMs)
if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end
js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs)
end
on htmx:beforeRequest
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
end
on htmx:afterSwap
set me.dataset.retryMs to 1000
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
class="py-2"
>
<div class="text-xs text-center text-stone-400 js-loading">
Loading more...
</div>
<div class="text-xs text-center text-stone-400 js-neterr hidden">
Connection error. Retrying...
</div>
</div>
{% elif search_posts %}
<div class="py-2 text-xs text-center text-stone-400">
End of results
</div>
{% endif %}

View File

@@ -0,0 +1,74 @@
<!-- Associated Posts Section -->
<div class="space-y-2">
{% if entry_posts %}
<div class="space-y-2">
{% for entry_post in entry_posts %}
<div class="flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border">
{% 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 %}
<span class="text-sm flex-1">{{ entry_post.title }}</span>
<button
type="button"
class="text-xs text-red-600 hover:text-red-800 flex-shrink-0"
data-confirm
data-confirm-title="Remove post?"
data-confirm-text="This will remove {{ entry_post.title }} from this entry"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id,
post_id=entry_post.id
) }}"
hx-trigger="confirmed"
hx-target="#entry-posts-{{entry.id}}"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
>
<i class="fa fa-times"></i> Remove
</button>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-400">No posts associated</p>
{% endif %}
<!-- Search to add posts -->
<div class="mt-3 pt-3 border-t">
<label class="block text-xs font-medium text-stone-700 mb-1">
Add Post
</label>
<input
type="text"
placeholder="Search posts..."
class="w-full px-3 py-2 border rounded text-sm"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-trigger="keyup changed delay:300ms, load"
hx-target="#post-search-results-{{entry.id}}"
hx-swap="innerHTML"
name="q"
/>
<div id="post-search-results-{{entry.id}}" class="mt-2 max-h-96 overflow-y-auto border rounded"></div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
{% if entry.state %}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if entry.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif entry.state == 'provisional' %}
bg-amber-100 text-amber-800
{% elif entry.state == 'ordered' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ entry.state|capitalize }}
</span>
{% endif %}

View File

@@ -0,0 +1,105 @@
{% if entry.ticket_price is not none %}
{# Tickets are configured #}
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-stone-700">Price:</span>
<span class="font-medium text-green-600">
£{{ ('%.2f'|format(entry.ticket_price)) }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-stone-700">Available:</span>
<span class="font-medium text-blue-600">
{% if entry.ticket_count is not none %}
{{ entry.ticket_count }} tickets
{% else %}
Unlimited
{% endif %}
</span>
</div>
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick="document.getElementById('ticket-form-{{entry.id}}').classList.remove('hidden'); this.classList.add('hidden');"
>
Edit ticket config
</button>
</div>
{% else %}
{# No tickets configured #}
<div class="space-y-2">
<span class="text-sm text-stone-400">No tickets configured</span>
<button
type="button"
class="block text-xs text-blue-600 hover:text-blue-800 underline"
onclick="document.getElementById('ticket-form-{{entry.id}}').classList.remove('hidden'); this.classList.add('hidden');"
>
Configure tickets
</button>
</div>
{% endif %}
{# Ticket configuration form (hidden by default) #}
<form
id="ticket-form-{{entry.id}}"
class="{% if entry.ticket_price is not none %}hidden{% endif %} space-y-3 mt-2 p-3 border rounded bg-stone-50"
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.update_tickets',
entry_id=entry.id,
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
) }}"
hx-target="#entry-tickets-{{entry.id}}"
hx-swap="innerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div>
<label for="ticket-price-{{entry.id}}" class="block text-sm font-medium text-stone-700 mb-1">
Ticket Price (£)
</label>
<input
type="number"
id="ticket-price-{{entry.id}}"
name="ticket_price"
step="0.01"
min="0"
value="{{ ('%.2f'|format(entry.ticket_price)) if entry.ticket_price is not none else '' }}"
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., 5.00"
/>
</div>
<div>
<label for="ticket-count-{{entry.id}}" class="block text-sm font-medium text-stone-700 mb-1">
Total Tickets
</label>
<input
type="number"
id="ticket-count-{{entry.id}}"
name="ticket_count"
min="0"
value="{{ entry.ticket_count if entry.ticket_count is not none else '' }}"
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Leave empty for unlimited"
/>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
>
Save
</button>
<button
type="button"
class="px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"
onclick="document.getElementById('ticket-form-{{entry.id}}').classList.add('hidden'); document.getElementById('entry-tickets-{{entry.id}}').querySelectorAll('button:not([type=submit])').forEach(btn => btn.classList.remove('hidden'));"
>
Cancel
</button>
</div>
</form>

View File

@@ -0,0 +1,5 @@
{% from 'macros/date.html' import t %}
<div class="text-sm text-gray-600">
{{ t(entry.start_at) }}
{% if entry.end_at %} → {{ t(entry.end_at) }}{% endif %}
</div>

View File

@@ -0,0 +1,3 @@
<i class="fa fa-clock"></i>
{{ entry.name }}
{% include '_types/entry/_state.html' %}

View File

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

View File

@@ -0,0 +1,18 @@
{% import 'macros/links.html' as links %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day
),
hx_select_search,
select_colours,
True,
aclass=styles.nav_button,
)%}
ticket_types
{% endcall %}

View File

@@ -0,0 +1,31 @@
{# OOB swap for entry posts nav when posts are associated/disassociated #}
{% import 'macros/links.html' as links %}
{# Associated Posts - vertical on mobile, horizontal with arrows on desktop #}
{% if entry_posts %}
<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"
hx-swap-oob="true">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a
href="{{ url_for('blog.post.post_detail', slug=entry_post.slug) }}"
class="{{styles.nav_button}}"
>
{% 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>
{% else %}
{# Empty placeholder to remove nav posts when all are disassociated #}
<div id="entry-posts-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('entry-header-child', 'entry-admin-header-child', '_types/entry/admin/header/_header.html')}}
{% from '_types/entry/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% include '_types/entry/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/entry/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='entry-admin-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
),
hx_select_search
) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/entry/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,24 @@
{% extends '_types/entry/index.html' %}
{% import 'macros/layout.html' as layout %}
{% import 'macros/links.html' as links %}
{% block entry_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/entry/admin/header/_header.html' import header_row with context %}
{{ header_row() }}
<div id="entry-admin-header-child">
{% block entry_admin_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/entry/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/entry/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='entry-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
),
hx_select_search,
) %}
<div id="entry-title-{{entry.id}}" class="flex gap-1 items-center">
{% include '_types/entry/_title.html' %}
{% include '_types/entry/_times.html' %}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/entry/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/day/index.html' %}
{% block day_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('entry-header-child', '_types/entry/header/_header.html') %}
{% block entry_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/entry/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/entry/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
<div id="post-entries-content" class="space-y-6 p-4">
{# Associated Entries List #}
{% include '_types/post/admin/_associated_entries.html' %}
{# Calendars Browser #}
<div class="space-y-3">
<h3 class="text-lg font-semibold">Browse Calendars</h3>
{% for calendar in all_calendars %}
<details class="border rounded-lg bg-white"
_="on toggle
if my.open
for other in <details[open]/>
if other is not me
set other.open to false
end
end
end">
<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">
{% if calendar.post.feature_image %}
<img src="{{ calendar.post.feature_image }}"
alt="{{ calendar.post.title }}"
class="w-12 h-12 rounded object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1">
<div class="font-semibold flex items-center gap-2">
<i class="fa fa-calendar text-stone-500"></i>
{{ calendar.name }}
</div>
<div class="text-sm text-stone-600">
{{ calendar.post.title }}
</div>
</div>
</summary>
<div class="p-4 border-t"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
hx-trigger="intersect once"
hx-swap="innerHTML">
<div class="text-sm text-stone-400">Loading calendar...</div>
</div>
</details>
{% else %}
<div class="text-sm text-stone-400">No calendars found.</div>
{% endfor %}
</div>
</div>

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 #}
{# Import shared OOB macros #}
{% 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('post-admin-header-child', 'post_entries-header-child', '_types/post_entries/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/post_entries/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/post_entries/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_entries-row', oob=oob) %}
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %}
<i class="fa fa-clock" aria-hidden="true"></i>
<div>
entries
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/post_entries/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,19 @@
{% extends '_types/post/admin/index.html' %}
{% block post_admin_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-admin-header-child', '_types/post_entries/header/_header.html') %}
{% block post_entries_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/post_entries/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/post_entries/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% macro description(slot, oob=False) %}
<div
id="slot-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"
>
{{ slot.description or ''}}
</div>
{% endmacro %}

View File

@@ -0,0 +1,5 @@
<p class="text-stone-500 whitespace-pre-line break-all w-full">
{% if slot.description %}
{{ slot.description }}
{% endif %}
</p>

View File

@@ -0,0 +1,182 @@
<section id="slot-{{ slot.id }}" class="{{styles.list_container}}">
<!-- Quick-edit form -->
<div id="slot-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="space-y-3 mt-4"
hx-put="{{ url_for('blog.post.calendars.calendar.slots.slot.put',
slug=post.slug,
calendar_slug=calendar.slug,
slot_id=slot.id) }}"
hx-target="#slot-{{ slot.id }}"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-name-{{ slot.id }}">
Name
</label>
<input
id="slot-name-{{ slot.id }}"
name="name"
placeholder="Name"
class="w-full border p-2 rounded"
value="{{ slot.name }}"
/>
</div>
<!-- Cost -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-cost-{{ slot.id }}">
Cost
</label>
<input
id="slot-cost-{{ slot.id }}"
name="cost"
placeholder="Cost e.g. 12.50"
class="w-full border p-2 rounded"
value="{{ '%.2f'|format(slot.cost) if slot.cost is not none else '' }}"
/>
</div>
<!-- Time start -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-start-{{ slot.id }}">
Start time
</label>
<input
id="slot-start-{{ slot.id }}"
name="time_start"
placeholder="Start HH:MM"
class="w-full border p-2 rounded"
value="{{ slot.time_start.strftime('%H:%M') if slot.time_start else '' }}"
/>
</div>
<!-- Time end -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-end-{{ slot.id }}">
End time
</label>
<input
id="slot-end-{{ slot.id }}"
name="time_end"
placeholder="End HH:MM"
class="w-full border p-2 rounded"
value="{{ slot.time_end.strftime('%H:%M') if slot.time_end else '' }}"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-desc-{{ slot.id }}">
Description
</label>
<textarea
id="slot-desc-{{ slot.id }}"
name="description"
rows="2"
placeholder="Description"
class="w-full border p-2 rounded"
>{{ slot.description or '' }}</textarea>
</div>
<!-- Days -->
<div>
<span class="block text-sm font-medium text-stone-700 mb-1">
Days
</span>
{# pre-check "All" if every day is true on this slot #}
{% set all_days_checked =
slot|getattr('mon')
and slot|getattr('tue')
and slot|getattr('wed')
and slot|getattr('thu')
and slot|getattr('fri')
and slot|getattr('sat')
and slot|getattr('sun') %}
<div
class="flex flex-wrap gap-3 items-center text-sm"
data-days-group
>
{# "All" toggle no name so its not submitted #}
<label class="flex items-center gap-1 px-2 py-1 rounded-full bg-slate-200">
<input
type="checkbox"
data-day-all
{% if all_days_checked %}checked{% endif %}
/>
<span>All</span>
</label>
{# Individual days, with data-day like the add form #}
{% for key, label in [
('mon','Mon'),('tue','Tue'),('wed','Wed'),('thu','Thu'),
('fri','Fri'),('sat','Sat'),('sun','Sun')
] %}
{% set is_checked = slot|getattr(key) %}
<label class="flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100">
<input
type="checkbox"
name="{{ key }}"
value="1"
data-day="{{ key }}"
{% if is_checked %}checked{% endif %}
/>
<span>{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- NEW: Flexible flag -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-flexible-{{ slot.id }}">
Flexible booking
</label>
<label class="inline-flex items-center gap-2 text-xs">
<input
id="slot-flexible-{{ slot.id }}"
type="checkbox"
name="flexible"
value="1"
{% if slot.flexible %}checked{% endif %}
>
<span>Allow bookings at any time within this band</span>
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.slot.get_view',
slug=post.slug,
calendar_slug=calendar.slug,
slot_id=slot.id) }}"
hx-target="#slot-{{ slot.id }}"
hx-swap="outerHTML"
>
Cancel
</button>
<button
type="submit"
class="{{ styles.action_button }}"
data-confirm="true"
data-confirm-title="Save slot?"
data-confirm-text="Are you sure you want to save this slot?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, save it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save slot
</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,73 @@
<section id="slot-{{slot.id}}" class="{{styles.list_container}}">
<!-- Days -->
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Days
</div>
<div class="mt-1">
{% set days = slot.days_display.split(', ') %}
{% if days and days[0] != "—" %}
<div class="flex flex-wrap gap-1">
{% for day in days %}
<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">
{{ day }}
</span>
{% endfor %}
</div>
{% else %}
<span class="text-xs text-slate-400">No days</span>
{% endif %}
</div>
</div>
<!-- Flexible -->
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Flexible
</div>
<div class="mt-1">
{{ 'yes' if slot.flexible else 'no' }}
</div>
</div>
<!-- Time & Cost (still "up-down" per field, but can sit side-by-side on wide screens) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Time
</div>
<div class="mt-1">
{{ slot.time_start.strftime('%H:%M') }} — {{ slot.time_end.strftime('%H:%M') }}
</div>
</div>
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Cost
</div>
<div class="mt-1">
{{ ('%.2f'|format(slot.cost)) if slot.cost is not none else '' }}
</div>
</div>
</div>
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.slots.slot.get_edit',
slot_id=slot.id,
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#slot-{{slot.id}}"
hx-swap="outerHTML"
>
Edit
</button>
</section>
{% if oob %}
{% from '_types/slot/__description.html' import description %}
{{description(slot, oob=True)}}
{% endif %}

View File

@@ -0,0 +1,15 @@
{% extends "oob_elements.html" %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('slots-header-child', 'slot-header-child', '_types/slot/header/_header.html')}}
{% from '_types/slots/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/slot/_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='slot-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=slot.id),
hx_select_search,
) %}
<div class="flex flex-col md:flex-row md:gap-2 items-center">
<div class="flex flex-row items-center gap-2">
<i class="fa fa-clock"></i>
<div class="shrink-0">
{{ slot.name }}
</div>
</div>
{% from '_types/slot/__description.html' import description %}
{{description(slot)}}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{#% include '_types/slot/_nav.html' %#}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/slots/index.html' %}
{% import 'macros/layout.html' as layout %}
{% block slots_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('slot-header-child', '_types/slot/header/_header.html') %}
{% block slot_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{#% include '_types/slot/_nav.html' %#}
{% endblock %}
{% block content %}
{% include '_types/slot/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,125 @@
<form
hx-post="{{ url_for('blog.post.calendars.calendar.slots.post',
slug=post.slug,
calendar_slug=calendar.slug) }}"
hx-target="#slots-table"
hx-select="#slots-table"
hx-disinherit="hx-select"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="space-y-3"
>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="md:col-span-2">
<label class="block text-xs font-semibold mb-1">Name</label>
<input
type="text"
name="name"
class="w-full border rounded px-2 py-1 text-sm"
required
>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold mb-1">Description</label>
<input
type="text"
name="description"
class="w-full border rounded px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Days</label>
<div class="flex flex-wrap gap-1 text-xs" data-days-group>
{# "All" toggle no name so its not submitted #}
<label class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-200">
<input type="checkbox" data-day-all>
<span>All</span>
</label>
{# Individual days #}
{% for key, label in [
('mon','Mon'),('tue','Tue'),('wed','Wed'),('thu','Thu'),
('fri','Fri'),('sat','Sat'),('sun','Sun')
] %}
<label class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100">
<input type="checkbox" name="{{ key }}" value="1" data-day="{{ key }}">
<span>{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Time start</label>
<input
type="time"
name="time_start"
class="w-full border rounded px-2 py-1 text-sm"
required
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Time end</label>
<input
type="time"
name="time_end"
class="w-full border rounded px-2 py-1 text-sm"
required
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Cost</label>
<input
type="text"
name="cost"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="e.g. 5.00"
>
</div>
{# NEW: flexible flag #}
<div class="md:col-span-2">
<label class="block text-xs font-semibold mb-1">Flexible booking</label>
<label class="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
name="flexible"
value="1"
>
<span>Allow bookings at any time within this band</span>
</label>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_button',
slug=post.slug,
calendar_slug=calendar.slug) }}"
hx-target="#slot-add-container"
hx-swap="innerHTML"
>
Cancel
</button>
<button
type="submit"
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Add slot?"
data-confirm-text="Are you sure you want to add this slot?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save slot
</button>
</div>
</form>

View File

@@ -0,0 +1,12 @@
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_form',
slug=post.slug,
calendar_slug=calendar.slug) }}"
hx-target="#slot-add-container"
hx-swap="innerHTML"
>
+ Add slot
</button>

Some files were not shown because too many files have changed in this diff Show More