feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Extract events/calendar functionality into standalone microservice: - app.py and events_api.py from apps/events/ - Calendar blueprints (calendars, calendar, calendar_entries, calendar_entry, day, slots, slot, ticket_types, ticket_type) - Templates for all calendar/event views including admin - Dockerfile (APP_MODULE=app:app, IMAGE=events) - entrypoint.sh (no Alembic - migrations managed by blog app) - Gitea CI workflow for build and deploy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
63
.gitea/workflows/ci.yml
Normal file
63
.gitea/workflows/ci.yml
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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
48
README.md
Normal 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
54
app.py
Normal 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()
|
||||||
76
bp/calendar/admin/routes.py
Normal file
76
bp/calendar/admin/routes.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request, render_template, make_response, Blueprint, g
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from 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
251
bp/calendar/routes.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from models.calendars import Calendar
|
||||||
|
|
||||||
|
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||||
|
from 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
|
||||||
1
bp/calendar/services/__init__.py
Normal file
1
bp/calendar/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .visiblity import get_visible_entries_for_period
|
||||||
24
bp/calendar/services/adopt_session_entries_for_user.py
Normal file
24
bp/calendar/services/adopt_session_entries_for_user.py
Normal 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
|
||||||
28
bp/calendar/services/calendar.py
Normal file
28
bp/calendar/services/calendar.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
from models.calendars import Calendar
|
||||||
|
from ...calendars.services.calendars import CalendarError
|
||||||
|
|
||||||
|
async def update_calendar_config(sess, calendar_id: int, *, description: str | None, slots: list | None):
|
||||||
|
"""Update description and slots for a calendar."""
|
||||||
|
cal = await sess.get(Calendar, calendar_id)
|
||||||
|
if not cal:
|
||||||
|
raise CalendarError(f"Calendar {calendar_id} not found.")
|
||||||
|
cal.description = (description or '').strip() or None
|
||||||
|
# Validate slots shape a bit
|
||||||
|
norm_slots: list[dict] = []
|
||||||
|
if slots:
|
||||||
|
for s in slots:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
norm_slots.append({
|
||||||
|
"days": str(s.get("days", ""))[:7].lower(),
|
||||||
|
"time_from": str(s.get("time_from", ""))[:5],
|
||||||
|
"time_to": str(s.get("time_to", ""))[:5],
|
||||||
|
"cost_name": (s.get("cost_name") or "")[:64],
|
||||||
|
"description": (s.get("description") or "")[:255],
|
||||||
|
})
|
||||||
|
cal.slots = norm_slots or None
|
||||||
|
await sess.flush()
|
||||||
|
return cal
|
||||||
109
bp/calendar/services/calendar_view.py
Normal file
109
bp/calendar/services/calendar_view.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
import calendar as pycalendar
|
||||||
|
|
||||||
|
from quart import request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||||
|
|
||||||
|
from models.calendars import Calendar, CalendarSlot
|
||||||
|
|
||||||
|
|
||||||
|
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
|
||||||
|
"""Parse an integer query parameter from the request."""
|
||||||
|
val = request.args.get(name, "").strip()
|
||||||
|
if not val:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def add_months(year: int, month: int, delta: int) -> tuple[int, int]:
|
||||||
|
"""Add (or subtract) months to a given year/month, handling year overflow."""
|
||||||
|
new_month = month + delta
|
||||||
|
new_year = year + (new_month - 1) // 12
|
||||||
|
new_month = ((new_month - 1) % 12) + 1
|
||||||
|
return new_year, new_month
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar_weeks(year: int, month: int) -> list[list[dict]]:
|
||||||
|
"""
|
||||||
|
Build a calendar grid for the given year and month.
|
||||||
|
Returns a list of weeks, where each week is a list of 7 day dictionaries.
|
||||||
|
"""
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
cal = pycalendar.Calendar(firstweekday=0) # 0 = Monday
|
||||||
|
weeks: list[list[dict]] = []
|
||||||
|
|
||||||
|
for week in cal.monthdatescalendar(year, month):
|
||||||
|
week_days = []
|
||||||
|
for d in week:
|
||||||
|
week_days.append(
|
||||||
|
{
|
||||||
|
"date": d,
|
||||||
|
"in_month": (d.month == month),
|
||||||
|
"is_today": (d == today),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
weeks.append(week_days)
|
||||||
|
|
||||||
|
return weeks
|
||||||
|
|
||||||
|
|
||||||
|
async def get_calendar_by_post_and_slug(
|
||||||
|
session: AsyncSession,
|
||||||
|
post_id: int,
|
||||||
|
calendar_slug: str,
|
||||||
|
) -> Optional[Calendar]:
|
||||||
|
"""
|
||||||
|
Fetch a calendar by post_id and slug, with slots eagerly loaded.
|
||||||
|
Returns None if not found.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(Calendar)
|
||||||
|
.options(
|
||||||
|
selectinload(Calendar.slots),
|
||||||
|
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Calendar.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
|
||||||
117
bp/calendar/services/slots.py
Normal file
117
bp/calendar/services/slots.py
Normal 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
|
||||||
115
bp/calendar/services/visiblity.py
Normal file
115
bp/calendar/services/visiblity.py
Normal 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,
|
||||||
|
)
|
||||||
226
bp/calendar_entries/routes.py
Normal file
226
bp/calendar_entries/routes.py
Normal 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
|
||||||
258
bp/calendar_entries/services/entries.py
Normal file
258
bp/calendar_entries/services/entries.py
Normal 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
|
||||||
28
bp/calendar_entry/admin/routes.py
Normal file
28
bp/calendar_entry/admin/routes.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
render_template, make_response, Blueprint
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from 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
574
bp/calendar_entry/routes.py
Normal 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
|
||||||
137
bp/calendar_entry/services/post_associations.py
Normal file
137
bp/calendar_entry/services/post_associations.py
Normal 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
|
||||||
87
bp/calendar_entry/services/ticket_operations.py
Normal file
87
bp/calendar_entry/services/ticket_operations.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.calendars import CalendarEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def update_ticket_config(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
ticket_price: Optional[Decimal],
|
||||||
|
ticket_count: Optional[int],
|
||||||
|
) -> tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Update ticket configuration for a calendar entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
entry_id: Calendar entry ID
|
||||||
|
ticket_price: Price per ticket (None = no tickets)
|
||||||
|
ticket_count: Total available tickets (None = unlimited)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, error_message)
|
||||||
|
"""
|
||||||
|
# Get the entry
|
||||||
|
entry = await session.scalar(
|
||||||
|
select(CalendarEntry).where(
|
||||||
|
CalendarEntry.id == entry_id,
|
||||||
|
CalendarEntry.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
return False, "Calendar entry not found"
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
if ticket_price is not None and ticket_price < 0:
|
||||||
|
return False, "Ticket price cannot be negative"
|
||||||
|
|
||||||
|
if ticket_count is not None and ticket_count < 0:
|
||||||
|
return False, "Ticket count cannot be negative"
|
||||||
|
|
||||||
|
# Update ticket configuration
|
||||||
|
entry.ticket_price = ticket_price
|
||||||
|
entry.ticket_count = ticket_count
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_available_tickets(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
) -> tuple[Optional[int], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Get the number of available tickets for a calendar entry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(available_count, error_message)
|
||||||
|
- available_count is None if unlimited tickets
|
||||||
|
- available_count is the remaining count if limited
|
||||||
|
"""
|
||||||
|
entry = await session.scalar(
|
||||||
|
select(CalendarEntry).where(
|
||||||
|
CalendarEntry.id == entry_id,
|
||||||
|
CalendarEntry.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
return None, "Calendar entry not found"
|
||||||
|
|
||||||
|
# If no ticket configuration, return None (unlimited)
|
||||||
|
if entry.ticket_price is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# If ticket_count is None, unlimited tickets
|
||||||
|
if entry.ticket_count is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 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
98
bp/calendars/routes.py
Normal 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
|
||||||
104
bp/calendars/services/calendars.py
Normal file
104
bp/calendars/services/calendars.py
Normal 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
28
bp/day/admin/routes.py
Normal 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
121
bp/day/routes.py
Normal 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
182
bp/slot/routes.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request, render_template, make_response, Blueprint, g, jsonify
|
||||||
|
)
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
|
from 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
90
bp/slot/services/slot.py
Normal 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
152
bp/slots/routes.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request, render_template, make_response, Blueprint, g, jsonify
|
||||||
|
)
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from 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
|
||||||
64
bp/slots/services/slots.py
Normal file
64
bp/slots/services/slots.py
Normal 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
159
bp/ticket_type/routes.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request, render_template, make_response, Blueprint, g, jsonify
|
||||||
|
)
|
||||||
|
|
||||||
|
from suma_browser.app.authz import require_admin
|
||||||
|
from suma_browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
|
from .services.ticket import (
|
||||||
|
get_ticket_type as svc_get_ticket_type,
|
||||||
|
update_ticket_type as svc_update_ticket_type,
|
||||||
|
soft_delete_ticket_type as svc_delete_ticket_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..ticket_types.services.tickets import (
|
||||||
|
list_ticket_types as svc_list_ticket_types,
|
||||||
|
)
|
||||||
|
from suma_browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
|
||||||
|
|
||||||
|
@bp.get("/")
|
||||||
|
@require_admin
|
||||||
|
async def get(ticket_type_id: int, **kwargs):
|
||||||
|
"""View a single ticket type."""
|
||||||
|
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||||
|
if not ticket_type:
|
||||||
|
return await make_response("Not found", 404)
|
||||||
|
if not is_htmx_request():
|
||||||
|
# Normal browser request: full page with layout
|
||||||
|
html = await render_template(
|
||||||
|
"_types/ticket_type/index.html",
|
||||||
|
ticket_type=ticket_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/ticket_type/_oob_elements.html",
|
||||||
|
ticket_type=ticket_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
|
@bp.get("/edit/")
|
||||||
|
@require_admin
|
||||||
|
async def get_edit(ticket_type_id: int, **kwargs):
|
||||||
|
"""Show the edit form for a ticket type."""
|
||||||
|
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||||
|
if not ticket_type:
|
||||||
|
return await make_response("Not found", 404)
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/ticket_type/_edit.html",
|
||||||
|
ticket_type=ticket_type,
|
||||||
|
)
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
|
@bp.get("/view/")
|
||||||
|
@require_admin
|
||||||
|
async def get_view(ticket_type_id: int, **kwargs):
|
||||||
|
"""Show the view for a ticket type."""
|
||||||
|
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||||
|
if not ticket_type:
|
||||||
|
return await make_response("Not found", 404)
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/ticket_type/_main_panel.html",
|
||||||
|
ticket_type=ticket_type,
|
||||||
|
)
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
|
@bp.put("/")
|
||||||
|
@require_admin
|
||||||
|
@clear_cache(tag="calendars", tag_scope="all")
|
||||||
|
async def put(ticket_type_id: int, **kwargs):
|
||||||
|
"""Update a ticket type."""
|
||||||
|
form = await request.form
|
||||||
|
|
||||||
|
name = (form.get("name") or "").strip()
|
||||||
|
cost_str = (form.get("cost") or "").strip()
|
||||||
|
count_str = (form.get("count") or "").strip()
|
||||||
|
|
||||||
|
field_errors: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
# Validate name
|
||||||
|
if not name:
|
||||||
|
field_errors.setdefault("name", []).append("Please enter a ticket type name.")
|
||||||
|
|
||||||
|
# Validate cost
|
||||||
|
cost = None
|
||||||
|
if not cost_str:
|
||||||
|
field_errors.setdefault("cost", []).append("Please enter a cost.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cost = float(cost_str)
|
||||||
|
if cost < 0:
|
||||||
|
field_errors.setdefault("cost", []).append("Cost must be positive.")
|
||||||
|
except ValueError:
|
||||||
|
field_errors.setdefault("cost", []).append("Please enter a valid number.")
|
||||||
|
|
||||||
|
# Validate count
|
||||||
|
count = None
|
||||||
|
if not count_str:
|
||||||
|
field_errors.setdefault("count", []).append("Please enter a ticket count.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
count = int(count_str)
|
||||||
|
if count < 0:
|
||||||
|
field_errors.setdefault("count", []).append("Count must be positive.")
|
||||||
|
except ValueError:
|
||||||
|
field_errors.setdefault("count", []).append("Please enter a valid whole number.")
|
||||||
|
|
||||||
|
if field_errors:
|
||||||
|
return jsonify({
|
||||||
|
"message": "Please fix the highlighted fields.",
|
||||||
|
"errors": field_errors,
|
||||||
|
}), 422
|
||||||
|
|
||||||
|
# Update ticket type
|
||||||
|
ticket_type = await svc_update_ticket_type(
|
||||||
|
g.s,
|
||||||
|
ticket_type_id,
|
||||||
|
name=name,
|
||||||
|
cost=cost,
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ticket_type:
|
||||||
|
return await make_response("Not found", 404)
|
||||||
|
|
||||||
|
# Return updated view with OOB flag
|
||||||
|
html = await render_template(
|
||||||
|
"_types/ticket_type/_main_panel.html",
|
||||||
|
ticket_type=ticket_type,
|
||||||
|
oob=True,
|
||||||
|
)
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
|
@bp.delete("/")
|
||||||
|
@require_admin
|
||||||
|
@clear_cache(tag="calendars", tag_scope="all")
|
||||||
|
async def delete(ticket_type_id: int, **kwargs):
|
||||||
|
"""Soft-delete a ticket type."""
|
||||||
|
success = await svc_delete_ticket_type(g.s, ticket_type_id)
|
||||||
|
if not success:
|
||||||
|
return await make_response("Not found", 404)
|
||||||
|
|
||||||
|
# Re-render the ticket types list
|
||||||
|
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
|
||||||
|
html = await render_template(
|
||||||
|
"_types/ticket_types/_main_panel.html",
|
||||||
|
ticket_types=ticket_types
|
||||||
|
)
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
|
return bp
|
||||||
56
bp/ticket_type/services/ticket.py
Normal file
56
bp/ticket_type/services/ticket.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.calendars import TicketType
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ticket_type(session: AsyncSession, ticket_type_id: int) -> TicketType | None:
|
||||||
|
"""Get a single ticket type by ID (only if not soft-deleted)."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(TicketType)
|
||||||
|
.where(
|
||||||
|
TicketType.id == ticket_type_id,
|
||||||
|
TicketType.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_ticket_type(
|
||||||
|
session: AsyncSession,
|
||||||
|
ticket_type_id: int,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
cost: float,
|
||||||
|
count: int,
|
||||||
|
) -> TicketType | None:
|
||||||
|
"""Update an existing ticket type."""
|
||||||
|
ticket_type = await get_ticket_type(session, ticket_type_id)
|
||||||
|
if not ticket_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ticket_type.name = name
|
||||||
|
ticket_type.cost = cost
|
||||||
|
ticket_type.count = count
|
||||||
|
ticket_type.updated_at = utcnow()
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
return ticket_type
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_ticket_type(session: AsyncSession, ticket_type_id: int) -> bool:
|
||||||
|
"""Soft-delete a ticket type."""
|
||||||
|
ticket_type = await get_ticket_type(session, ticket_type_id)
|
||||||
|
if not ticket_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ticket_type.deleted_at = utcnow()
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
132
bp/ticket_types/routes.py
Normal file
132
bp/ticket_types/routes.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request, render_template, make_response, Blueprint, g, jsonify
|
||||||
|
)
|
||||||
|
|
||||||
|
from 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
|
||||||
47
bp/ticket_types/services/tickets.py
Normal file
47
bp/ticket_types/services/tickets.py
Normal 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
29
entrypoint.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Optional: wait for Postgres to be reachable
|
||||||
|
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
|
||||||
|
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
|
||||||
|
for i in {1..60}; do
|
||||||
|
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NOTE: Events app does NOT run Alembic migrations.
|
||||||
|
# Migrations are managed by the blog app which owns the shared database schema.
|
||||||
|
|
||||||
|
# Clear Redis page cache on deploy
|
||||||
|
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
|
||||||
|
echo "Flushing Redis cache..."
|
||||||
|
python3 -c "
|
||||||
|
import redis, os
|
||||||
|
r = redis.from_url(os.environ['REDIS_URL'])
|
||||||
|
r.flushall()
|
||||||
|
print('Redis cache cleared.')
|
||||||
|
" || echo "Redis flush failed (non-fatal), continuing..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the app
|
||||||
|
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
|
||||||
|
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000}
|
||||||
132
events_api.py
Normal file
132
events_api.py
Normal 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
|
||||||
12
templates/_types/calendar/_description.html
Normal file
12
templates/_types/calendar/_description.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% macro description(calendar, oob=False) %}
|
||||||
|
<div
|
||||||
|
id="calendar-description-title"
|
||||||
|
{% if oob %}
|
||||||
|
hx-swap-oob="outerHTML"
|
||||||
|
{% endif %}
|
||||||
|
class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
|
||||||
|
>
|
||||||
|
{{ calendar.description or ''}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endmacro %}
|
||||||
180
templates/_types/calendar/_main_panel.html
Normal file
180
templates/_types/calendar/_main_panel.html
Normal 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"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</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>
|
||||||
17
templates/_types/calendar/_nav.html
Normal file
17
templates/_types/calendar/_nav.html
Normal 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 %}
|
||||||
22
templates/_types/calendar/_oob_elements.html
Normal file
22
templates/_types/calendar/_oob_elements.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "oob_elements.html" %}
|
||||||
|
{# OOB elements for post admin page #}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('post-header-child', 'calendar-header-child', '_types/calendar/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/post/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/calendar/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/calendar/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
33
templates/_types/calendar/admin/_description.html
Normal file
33
templates/_types/calendar/admin/_description.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
43
templates/_types/calendar/admin/_description_edit.html
Normal file
43
templates/_types/calendar/admin/_description_edit.html
Normal 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>
|
||||||
46
templates/_types/calendar/admin/_main_panel.html
Normal file
46
templates/_types/calendar/admin/_main_panel.html
Normal 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>
|
||||||
2
templates/_types/calendar/admin/_nav.html
Normal file
2
templates/_types/calendar/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
25
templates/_types/calendar/admin/_oob_elements.html
Normal file
25
templates/_types/calendar/admin/_oob_elements.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{# OOB elements for calendar admin page #}
|
||||||
|
|
||||||
|
{# Import shared OOB macros #}
|
||||||
|
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('calendar-header-child', 'calendar-admin-header-child', '_types/calendar/admin/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||||
|
{{header_row(oob=True)}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/calendar/admin/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/calendar/admin/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
14
templates/_types/calendar/admin/header/_header.html
Normal file
14
templates/_types/calendar/admin/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='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 %}
|
||||||
24
templates/_types/calendar/admin/index.html
Normal file
24
templates/_types/calendar/admin/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends '_types/calendar/index.html' %}
|
||||||
|
{% import 'macros/layout.html' as layout %}
|
||||||
|
|
||||||
|
{% block calendar_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import header with context %}
|
||||||
|
{% call header() %}
|
||||||
|
{% from '_types/calendar/admin/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row() }}
|
||||||
|
<div id="calendar-admin-header-child">
|
||||||
|
{% block calendar_admin_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include '_types/calendar/admin/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/calendar/admin/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
23
templates/_types/calendar/header/_header.html
Normal file
23
templates/_types/calendar/header/_header.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='calendar-row', oob=oob) %}
|
||||||
|
{% call links.link(url_for('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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
20
templates/_types/calendar/index.html
Normal file
20
templates/_types/calendar/index.html
Normal 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 %}
|
||||||
44
templates/_types/calendars/_calendars_list.html
Normal file
44
templates/_types/calendars/_calendars_list.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% for row in calendars %}
|
||||||
|
{% set cal = row %}
|
||||||
|
<div class="mt-6 border rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
|
||||||
|
{% set calendar_href = url_for('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 %}
|
||||||
27
templates/_types/calendars/_main_panel.html
Normal file
27
templates/_types/calendars/_main_panel.html
Normal 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>
|
||||||
2
templates/_types/calendars/_nav.html
Normal file
2
templates/_types/calendars/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
28
templates/_types/calendars/_oob_elements.html
Normal file
28
templates/_types/calendars/_oob_elements.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||||
|
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('post-admin-header-child', 'calendars-header-child', '_types/calendars/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/post/admin/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/calendars/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "_types/calendars/_main_panel.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
14
templates/_types/calendars/header/_header.html
Normal file
14
templates/_types/calendars/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='calendars-row', oob=oob) %}
|
||||||
|
{% call links.link(url_for('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 %}
|
||||||
22
templates/_types/calendars/index.html
Normal file
22
templates/_types/calendars/index.html
Normal 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 %}
|
||||||
301
templates/_types/day/_add.html
Normal file
301
templates/_types/day/_add.html
Normal 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>
|
||||||
17
templates/_types/day/_add_button.html
Normal file
17
templates/_types/day/_add_button.html
Normal 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>
|
||||||
28
templates/_types/day/_main_panel.html
Normal file
28
templates/_types/day/_main_panel.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<section id="day-entries" class="{{styles.list_container}}">
|
||||||
|
<table class="w-full text-sm border table-fixed">
|
||||||
|
<thead class="bg-stone-100">
|
||||||
|
<tr>
|
||||||
|
<th class="p-2 text-left w-2/6">Name</th>
|
||||||
|
<th class="text-left p-2 w-1/6">Slot/Time</th>
|
||||||
|
<th class="text-left p-2 w-1/6">State</th>
|
||||||
|
<th class="text-left p-2 w-1/6">Cost</th>
|
||||||
|
<th class="text-left p-2 w-1/6">Tickets</th>
|
||||||
|
<th class="text-left p-2 w-1/6">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in day_entries %}
|
||||||
|
{% include '_types/day/_row.html' %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="p-3 text-stone-500">No entries yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="entry-add-container" class="mt-4">
|
||||||
|
{% include '_types/day/_add_button.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
41
templates/_types/day/_nav.html
Normal file
41
templates/_types/day/_nav.html
Normal 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 %}
|
||||||
18
templates/_types/day/_oob_elements.html
Normal file
18
templates/_types/day/_oob_elements.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "oob_elements.html" %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('calendar-header-child', 'day-header-child', '_types/day/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/day/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/day/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
76
templates/_types/day/_row.html
Normal file
76
templates/_types/day/_row.html
Normal 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>
|
||||||
2
templates/_types/day/admin/_main_panel.html
Normal file
2
templates/_types/day/admin/_main_panel.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
2
templates/_types/day/admin/_nav.html
Normal file
2
templates/_types/day/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
34
templates/_types/day/admin/_nav_entries_oob.html
Normal file
34
templates/_types/day/admin/_nav_entries_oob.html
Normal 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 %}
|
||||||
25
templates/_types/day/admin/_oob_elements.html
Normal file
25
templates/_types/day/admin/_oob_elements.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{# OOB elements for calendar admin page #}
|
||||||
|
|
||||||
|
{# Import shared OOB macros #}
|
||||||
|
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('day-header-child', 'day-admin-header-child', '_types/day/admin/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||||
|
{{header_row(oob=True)}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/day/admin/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/day/admin/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
21
templates/_types/day/admin/header/_header.html
Normal file
21
templates/_types/day/admin/header/_header.html
Normal 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 %}
|
||||||
24
templates/_types/day/admin/index.html
Normal file
24
templates/_types/day/admin/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends '_types/day/index.html' %}
|
||||||
|
{% import 'macros/layout.html' as layout %}
|
||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block day_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import header with context %}
|
||||||
|
{% call header() %}
|
||||||
|
{% from '_types/day/admin/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row() }}
|
||||||
|
<div id="day-admin-header-child">
|
||||||
|
{% block day_admin_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include '_types/day/admin/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/day/admin/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
27
templates/_types/day/header/_header.html
Normal file
27
templates/_types/day/header/_header.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
18
templates/_types/day/index.html
Normal file
18
templates/_types/day/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends '_types/calendar/index.html' %}
|
||||||
|
|
||||||
|
{% block calendar_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('day-header-child', '_types/day/header/_header.html') %}
|
||||||
|
{% block day_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include '_types/day/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/day/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
334
templates/_types/entry/_edit.html
Normal file
334
templates/_types/entry/_edit.html
Normal 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>
|
||||||
126
templates/_types/entry/_main_panel.html
Normal file
126
templates/_types/entry/_main_panel.html
Normal 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>
|
||||||
40
templates/_types/entry/_nav.html
Normal file
40
templates/_types/entry/_nav.html
Normal 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 %}
|
||||||
18
templates/_types/entry/_oob_elements.html
Normal file
18
templates/_types/entry/_oob_elements.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "oob_elements.html" %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('day-header-child', 'entry-header-child', '_types/entry/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/day/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/entry/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/entry/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
9
templates/_types/entry/_optioned.html
Normal file
9
templates/_types/entry/_optioned.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
{% include '_types/entry/_options.html' %}
|
||||||
|
<div id="entry-title-{{entry.id}}" hx-swap-oob="innerHTML">
|
||||||
|
{% include '_types/entry/_title.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="entry-state-{{entry.id}}" hx-swap-oob="innerHTML">
|
||||||
|
{% include '_types/entry/_state.html' %}
|
||||||
|
</div>
|
||||||
98
templates/_types/entry/_options.html
Normal file
98
templates/_types/entry/_options.html
Normal 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>
|
||||||
107
templates/_types/entry/_post_search_results.html
Normal file
107
templates/_types/entry/_post_search_results.html
Normal 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 %}
|
||||||
74
templates/_types/entry/_posts.html
Normal file
74
templates/_types/entry/_posts.html
Normal 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>
|
||||||
15
templates/_types/entry/_state.html
Normal file
15
templates/_types/entry/_state.html
Normal 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 %}
|
||||||
105
templates/_types/entry/_tickets.html
Normal file
105
templates/_types/entry/_tickets.html
Normal 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>
|
||||||
5
templates/_types/entry/_times.html
Normal file
5
templates/_types/entry/_times.html
Normal 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>
|
||||||
3
templates/_types/entry/_title.html
Normal file
3
templates/_types/entry/_title.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<i class="fa fa-clock"></i>
|
||||||
|
{{ entry.name }}
|
||||||
|
{% include '_types/entry/_state.html' %}
|
||||||
2
templates/_types/entry/admin/_main_panel.html
Normal file
2
templates/_types/entry/admin/_main_panel.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
18
templates/_types/entry/admin/_nav.html
Normal file
18
templates/_types/entry/admin/_nav.html
Normal 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 %}
|
||||||
31
templates/_types/entry/admin/_nav_posts_oob.html
Normal file
31
templates/_types/entry/admin/_nav_posts_oob.html
Normal 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 %}
|
||||||
25
templates/_types/entry/admin/_oob_elements.html
Normal file
25
templates/_types/entry/admin/_oob_elements.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{# OOB elements for calendar admin page #}
|
||||||
|
|
||||||
|
{# Import shared OOB macros #}
|
||||||
|
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('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 %}
|
||||||
22
templates/_types/entry/admin/header/_header.html
Normal file
22
templates/_types/entry/admin/header/_header.html
Normal 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 %}
|
||||||
24
templates/_types/entry/admin/index.html
Normal file
24
templates/_types/entry/admin/index.html
Normal 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 %}
|
||||||
28
templates/_types/entry/header/_header.html
Normal file
28
templates/_types/entry/header/_header.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
20
templates/_types/entry/index.html
Normal file
20
templates/_types/entry/index.html
Normal 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 %}
|
||||||
48
templates/_types/post_entries/_main_panel.html
Normal file
48
templates/_types/post_entries/_main_panel.html
Normal 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>
|
||||||
2
templates/_types/post_entries/_nav.html
Normal file
2
templates/_types/post_entries/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
28
templates/_types/post_entries/_oob_elements.html
Normal file
28
templates/_types/post_entries/_oob_elements.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
17
templates/_types/post_entries/header/_header.html
Normal file
17
templates/_types/post_entries/header/_header.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
19
templates/_types/post_entries/index.html
Normal file
19
templates/_types/post_entries/index.html
Normal 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 %}
|
||||||
13
templates/_types/slot/__description.html
Normal file
13
templates/_types/slot/__description.html
Normal 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 %}
|
||||||
5
templates/_types/slot/_description.html
Normal file
5
templates/_types/slot/_description.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<p class="text-stone-500 whitespace-pre-line break-all w-full">
|
||||||
|
{% if slot.description %}
|
||||||
|
{{ slot.description }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
182
templates/_types/slot/_edit.html
Normal file
182
templates/_types/slot/_edit.html
Normal 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 it’s 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>
|
||||||
73
templates/_types/slot/_main_panel.html
Normal file
73
templates/_types/slot/_main_panel.html
Normal 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 %}
|
||||||
15
templates/_types/slot/_oob_elements.html
Normal file
15
templates/_types/slot/_oob_elements.html
Normal 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 %}
|
||||||
26
templates/_types/slot/header/_header.html
Normal file
26
templates/_types/slot/header/_header.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
20
templates/_types/slot/index.html
Normal file
20
templates/_types/slot/index.html
Normal 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 %}
|
||||||
125
templates/_types/slots/_add.html
Normal file
125
templates/_types/slots/_add.html
Normal 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 it’s 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>
|
||||||
12
templates/_types/slots/_add_button.html
Normal file
12
templates/_types/slots/_add_button.html
Normal 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
Reference in New Issue
Block a user