commit 3c0fa45f8cb377c24e30f70ca6e6b459d8eecdc4 Author: giles Date: Mon Feb 9 23:16:32 2026 +0000 feat: initialize events app with calendars, slots, tickets, and internal API 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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..00aaf8d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27275ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5ff8d0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..55794cb --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..f14e44e --- /dev/null +++ b/app.py @@ -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() diff --git a/bp/calendar/admin/routes.py b/bp/calendar/admin/routes.py new file mode 100644 index 0000000..7f4cf10 --- /dev/null +++ b/bp/calendar/admin/routes.py @@ -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 diff --git a/bp/calendar/routes.py b/bp/calendar/routes.py new file mode 100644 index 0000000..907a690 --- /dev/null +++ b/bp/calendar/routes.py @@ -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='/') + + 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 diff --git a/bp/calendar/services/__init__.py b/bp/calendar/services/__init__.py new file mode 100644 index 0000000..a8110ed --- /dev/null +++ b/bp/calendar/services/__init__.py @@ -0,0 +1 @@ +from .visiblity import get_visible_entries_for_period diff --git a/bp/calendar/services/adopt_session_entries_for_user.py b/bp/calendar/services/adopt_session_entries_for_user.py new file mode 100644 index 0000000..c44b2fc --- /dev/null +++ b/bp/calendar/services/adopt_session_entries_for_user.py @@ -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 diff --git a/bp/calendar/services/calendar.py b/bp/calendar/services/calendar.py new file mode 100644 index 0000000..e1bda42 --- /dev/null +++ b/bp/calendar/services/calendar.py @@ -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 diff --git a/bp/calendar/services/calendar_view.py b/bp/calendar/services/calendar_view.py new file mode 100644 index 0000000..edacbf4 --- /dev/null +++ b/bp/calendar/services/calendar_view.py @@ -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 diff --git a/bp/calendar/services/slots.py b/bp/calendar/services/slots.py new file mode 100644 index 0000000..31c0e76 --- /dev/null +++ b/bp/calendar/services/slots.py @@ -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 diff --git a/bp/calendar/services/visiblity.py b/bp/calendar/services/visiblity.py new file mode 100644 index 0000000..9c804f1 --- /dev/null +++ b/bp/calendar/services/visiblity.py @@ -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, + ) diff --git a/bp/calendar_entries/routes.py b/bp/calendar_entries/routes.py new file mode 100644 index 0000000..8057c30 --- /dev/null +++ b/bp/calendar_entries/routes.py @@ -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 diff --git a/bp/calendar_entries/services/entries.py b/bp/calendar_entries/services/entries.py new file mode 100644 index 0000000..e1ad623 --- /dev/null +++ b/bp/calendar_entries/services/entries.py @@ -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 \ No newline at end of file diff --git a/bp/calendar_entry/admin/routes.py b/bp/calendar_entry/admin/routes.py new file mode 100644 index 0000000..76d8f96 --- /dev/null +++ b/bp/calendar_entry/admin/routes.py @@ -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 diff --git a/bp/calendar_entry/routes.py b/bp/calendar_entry/routes.py new file mode 100644 index 0000000..35ecd07 --- /dev/null +++ b/bp/calendar_entry/routes.py @@ -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='/') + + # 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//") + @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 diff --git a/bp/calendar_entry/services/post_associations.py b/bp/calendar_entry/services/post_associations.py new file mode 100644 index 0000000..4a0193a --- /dev/null +++ b/bp/calendar_entry/services/post_associations.py @@ -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 diff --git a/bp/calendar_entry/services/ticket_operations.py b/bp/calendar_entry/services/ticket_operations.py new file mode 100644 index 0000000..c949eb2 --- /dev/null +++ b/bp/calendar_entry/services/ticket_operations.py @@ -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 diff --git a/bp/calendars/routes.py b/bp/calendars/routes.py new file mode 100644 index 0000000..6c05695 --- /dev/null +++ b/bp/calendars/routes.py @@ -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'
{e}
', 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 diff --git a/bp/calendars/services/calendars.py b/bp/calendars/services/calendars.py new file mode 100644 index 0000000..752dea9 --- /dev/null +++ b/bp/calendars/services/calendars.py @@ -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 + + diff --git a/bp/day/admin/routes.py b/bp/day/admin/routes.py new file mode 100644 index 0000000..4c1d04b --- /dev/null +++ b/bp/day/admin/routes.py @@ -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 diff --git a/bp/day/routes.py b/bp/day/routes.py new file mode 100644 index 0000000..c92ed63 --- /dev/null +++ b/bp/day/routes.py @@ -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///') + + 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 diff --git a/bp/slot/routes.py b/bp/slot/routes.py new file mode 100644 index 0000000..356a7df --- /dev/null +++ b/bp/slot/routes.py @@ -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='/') + + # ---------- 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 diff --git a/bp/slot/services/slot.py b/bp/slot/services/slot.py new file mode 100644 index 0000000..5069c97 --- /dev/null +++ b/bp/slot/services/slot.py @@ -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 diff --git a/bp/slots/routes.py b/bp/slots/routes.py new file mode 100644 index 0000000..5e72718 --- /dev/null +++ b/bp/slots/routes.py @@ -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 diff --git a/bp/slots/services/slots.py b/bp/slots/services/slots.py new file mode 100644 index 0000000..bc130f9 --- /dev/null +++ b/bp/slots/services/slots.py @@ -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 diff --git a/bp/ticket_type/routes.py b/bp/ticket_type/routes.py new file mode 100644 index 0000000..552af5c --- /dev/null +++ b/bp/ticket_type/routes.py @@ -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='/') + + @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 diff --git a/bp/ticket_type/services/ticket.py b/bp/ticket_type/services/ticket.py new file mode 100644 index 0000000..fb87002 --- /dev/null +++ b/bp/ticket_type/services/ticket.py @@ -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 diff --git a/bp/ticket_types/routes.py b/bp/ticket_types/routes.py new file mode 100644 index 0000000..a37b73f --- /dev/null +++ b/bp/ticket_types/routes.py @@ -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 diff --git a/bp/ticket_types/services/tickets.py b/bp/ticket_types/services/tickets.py new file mode 100644 index 0000000..c848704 --- /dev/null +++ b/bp/ticket_types/services/tickets.py @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..9d2720e --- /dev/null +++ b/entrypoint.sh @@ -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} diff --git a/events_api.py b/events_api.py new file mode 100644 index 0000000..530f871 --- /dev/null +++ b/events_api.py @@ -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/") + @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 diff --git a/templates/_types/calendar/_description.html b/templates/_types/calendar/_description.html new file mode 100644 index 0000000..0f04f3a --- /dev/null +++ b/templates/_types/calendar/_description.html @@ -0,0 +1,12 @@ +{% macro description(calendar, oob=False) %} +
+ {{ calendar.description or ''}} +
+ +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/calendar/_main_panel.html b/templates/_types/calendar/_main_panel.html new file mode 100644 index 0000000..f2f781a --- /dev/null +++ b/templates/_types/calendar/_main_panel.html @@ -0,0 +1,180 @@ +
+
+ + {# Month / year navigation #} + +
+ + {# Calendar grid #} +
+ {# Weekday header: only show on sm+ (desktop/tablet) #} + + + {# On mobile: 1 column; on sm+: 7 columns #} +
+ {% for week in weeks %} + {% for day in week %} +
+
+
+ + {{ day.date.strftime('%a') }} + + + {# Clickable day number: goes to day detail view #} + + {{ day.date.day }} + +
+
+ {# Entries for this day: merged, chronological #} +
+ {# 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')) %} +
+ + {{ e.name }} + + + {{ (e.state or 'pending')|replace('_', ' ') }} + +
+ {% endif %} + {% endfor %} + +
+
+ {% endfor %} + {% endfor %} +
+
diff --git a/templates/_types/calendar/_nav.html b/templates/_types/calendar/_nav.html new file mode 100644 index 0000000..fc7d0b6 --- /dev/null +++ b/templates/_types/calendar/_nav.html @@ -0,0 +1,17 @@ + +{% 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 +) %} + +
+ Slots +
+{% 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 %} \ No newline at end of file diff --git a/templates/_types/calendar/_oob_elements.html b/templates/_types/calendar/_oob_elements.html new file mode 100644 index 0000000..1447e24 --- /dev/null +++ b/templates/_types/calendar/_oob_elements.html @@ -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 %} diff --git a/templates/_types/calendar/admin/_description.html b/templates/_types/calendar/admin/_description.html new file mode 100644 index 0000000..2759d7a --- /dev/null +++ b/templates/_types/calendar/admin/_description.html @@ -0,0 +1,33 @@ +
+ {% if calendar.description %} +

+ {{ calendar.description }} +

+ {% else %} +

+ No description yet. +

+ {% endif %} + + +
+ +{% if oob %} + + {% from '_types/calendar/_description.html' import description %} + {{description(calendar, oob=True)}} +{% endif %} + + diff --git a/templates/_types/calendar/admin/_description_edit.html b/templates/_types/calendar/admin/_description_edit.html new file mode 100644 index 0000000..87b31c5 --- /dev/null +++ b/templates/_types/calendar/admin/_description_edit.html @@ -0,0 +1,43 @@ +
+
+ + + + +
+ + + +
+
+
diff --git a/templates/_types/calendar/admin/_main_panel.html b/templates/_types/calendar/admin/_main_panel.html new file mode 100644 index 0000000..162ecc8 --- /dev/null +++ b/templates/_types/calendar/admin/_main_panel.html @@ -0,0 +1,46 @@ + +
+ +
+

Calendar configuration

+
+
+ + {% include '_types/calendar/admin/_description.html' %} +
+ + +
+ +
+ +
diff --git a/templates/_types/calendar/admin/_nav.html b/templates/_types/calendar/admin/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/calendar/admin/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/calendar/admin/_oob_elements.html b/templates/_types/calendar/admin/_oob_elements.html new file mode 100644 index 0000000..ec6244c --- /dev/null +++ b/templates/_types/calendar/admin/_oob_elements.html @@ -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 %} diff --git a/templates/_types/calendar/admin/header/_header.html b/templates/_types/calendar/admin/header/_header.html new file mode 100644 index 0000000..f0fafda --- /dev/null +++ b/templates/_types/calendar/admin/header/_header.html @@ -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 %} \ No newline at end of file diff --git a/templates/_types/calendar/admin/index.html b/templates/_types/calendar/admin/index.html new file mode 100644 index 0000000..c27d6d2 --- /dev/null +++ b/templates/_types/calendar/admin/index.html @@ -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() }} +
+ {% block calendar_admin_header_child %} + {% endblock %} +
+ {% endcall %} +{% endblock %} + + + +{% block _main_mobile_menu %} + {% include '_types/calendar/admin/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/calendar/admin/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/calendar/header/_header.html b/templates/_types/calendar/header/_header.html new file mode 100644 index 0000000..f62b039 --- /dev/null +++ b/templates/_types/calendar/header/_header.html @@ -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) %} +
+
+ +
+ {{ calendar.name }} +
+
+ {% from '_types/calendar/_description.html' import description %} + {{description(calendar)}} +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/calendar/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/calendar/index.html b/templates/_types/calendar/index.html new file mode 100644 index 0000000..802c45c --- /dev/null +++ b/templates/_types/calendar/index.html @@ -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 %} diff --git a/templates/_types/calendars/_calendars_list.html b/templates/_types/calendars/_calendars_list.html new file mode 100644 index 0000000..92d0fd2 --- /dev/null +++ b/templates/_types/calendars/_calendars_list.html @@ -0,0 +1,44 @@ + {% for row in calendars %} + {% set cal = row %} +
+
+ + {% set calendar_href = url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug=cal.slug)|host%} + +

{{ cal.name }}

+

/{{ cal.slug }}/

+
+ + + + +
+
+ {% else %} +

No calendars yet. Create one above.

+ {% endfor %} diff --git a/templates/_types/calendars/_main_panel.html b/templates/_types/calendars/_main_panel.html new file mode 100644 index 0000000..81f470e --- /dev/null +++ b/templates/_types/calendars/_main_panel.html @@ -0,0 +1,27 @@ +
+ {% if has_access('blog.post.calendars.create_calendar') %} + +
+ +
+ +
+ + +
+ +
+ {% endif %} + +
+ {% include "_types/calendars/_calendars_list.html" %} +
+
\ No newline at end of file diff --git a/templates/_types/calendars/_nav.html b/templates/_types/calendars/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/calendars/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/calendars/_oob_elements.html b/templates/_types/calendars/_oob_elements.html new file mode 100644 index 0000000..6de3bea --- /dev/null +++ b/templates/_types/calendars/_oob_elements.html @@ -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 %} + + diff --git a/templates/_types/calendars/header/_header.html b/templates/_types/calendars/header/_header.html new file mode 100644 index 0000000..d645a5e --- /dev/null +++ b/templates/_types/calendars/header/_header.html @@ -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) %} + +
+ Calendars +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/calendars/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/calendars/index.html b/templates/_types/calendars/index.html new file mode 100644 index 0000000..a4e909a --- /dev/null +++ b/templates/_types/calendars/index.html @@ -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 %} diff --git a/templates/_types/day/_add.html b/templates/_types/day/_add.html new file mode 100644 index 0000000..1fde06f --- /dev/null +++ b/templates/_types/day/_add.html @@ -0,0 +1,301 @@ +
+ +
+ + + {# 1) Entry name #} + + + {# 2) Slot picker for this weekday (required) #} + {% if day_slots %} + + {% else %} +
+ No slots defined for this day. +
+ {% endif %} + + {# 3) Time entry + cost display #} +
+ {# Time inputs — hidden until a flexible slot is selected #} + + + {# Cost display — shown when a slot is selected #} + + + {# Summary of fixed times — shown for non-flexible slots #} + +
+ + {# Ticket Configuration #} +
+

Ticket Configuration (Optional)

+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+ +{# --- Behaviour: lock / unlock times based on slot.flexible --- #} + \ No newline at end of file diff --git a/templates/_types/day/_add_button.html b/templates/_types/day/_add_button.html new file mode 100644 index 0000000..bd7fcd8 --- /dev/null +++ b/templates/_types/day/_add_button.html @@ -0,0 +1,17 @@ + + diff --git a/templates/_types/day/_main_panel.html b/templates/_types/day/_main_panel.html new file mode 100644 index 0000000..0eea6f0 --- /dev/null +++ b/templates/_types/day/_main_panel.html @@ -0,0 +1,28 @@ +
+ + + + + + + + + + + + + {% for entry in day_entries %} + {% include '_types/day/_row.html' %} + {% else %} + + {% endfor %} + + + +
NameSlot/TimeStateCostTicketsActions
No entries yet.
+ +
+ {% include '_types/day/_add_button.html' %} +
+ +
diff --git a/templates/_types/day/_nav.html b/templates/_types/day/_nav.html new file mode 100644 index 0000000..992eb91 --- /dev/null +++ b/templates/_types/day/_nav.html @@ -0,0 +1,41 @@ +{% import 'macros/links.html' as links %} + +{# Confirmed Entries - vertical on mobile, horizontal with arrows on desktop #} +
+ {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} + {% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %} + +
+
{{ entry.name }}
+
+ {{ entry.start_at.strftime('%H:%M') }} + {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %} +
+
+
+ {% endcall %} +
+ +{# 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 %} \ No newline at end of file diff --git a/templates/_types/day/_oob_elements.html b/templates/_types/day/_oob_elements.html new file mode 100644 index 0000000..812e6b0 --- /dev/null +++ b/templates/_types/day/_oob_elements.html @@ -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 %} diff --git a/templates/_types/day/_row.html b/templates/_types/day/_row.html new file mode 100644 index 0000000..139fb23 --- /dev/null +++ b/templates/_types/day/_row.html @@ -0,0 +1,76 @@ +{% import 'macros/links.html' as links %} + + +
+ {% 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 %} +
+ + + {% if entry.slot %} +
+ {% 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 %} + + ({{ entry.slot.time_start.strftime('%H:%M') }}{% if entry.slot.time_end %} → {{ entry.slot.time_end.strftime('%H:%M') }}{% endif %}) + +
+ {% else %} +
+ {% include '_types/entry/_times.html' %} +
+ {% endif %} + + +
+ {% include '_types/entry/_state.html' %} +
+ + + + £{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }} + + + + {% if entry.ticket_price is not none %} +
+
£{{ ('%.2f'|format(entry.ticket_price)) }}
+
+ {% if entry.ticket_count is not none %} + {{ entry.ticket_count }} tickets + {% else %} + Unlimited + {% endif %} +
+
+ {% else %} + No tickets + {% endif %} + + + {% include '_types/entry/_options.html' %} + + \ No newline at end of file diff --git a/templates/_types/day/admin/_main_panel.html b/templates/_types/day/admin/_main_panel.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/day/admin/_main_panel.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/day/admin/_nav.html b/templates/_types/day/admin/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/day/admin/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/day/admin/_nav_entries_oob.html b/templates/_types/day/admin/_nav_entries_oob.html new file mode 100644 index 0000000..957246b --- /dev/null +++ b/templates/_types/day/admin/_nav_entries_oob.html @@ -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 %} +
+ {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} + {% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %} + +
+
{{ entry.name }}
+
+ {{ entry.start_at.strftime('%H:%M') }} + {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %} +
+
+
+ {% endcall %} +
+{% else %} + {# Empty placeholder to remove nav entries when none are confirmed #} +
+{% endif %} diff --git a/templates/_types/day/admin/_oob_elements.html b/templates/_types/day/admin/_oob_elements.html new file mode 100644 index 0000000..20986bf --- /dev/null +++ b/templates/_types/day/admin/_oob_elements.html @@ -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 %} diff --git a/templates/_types/day/admin/header/_header.html b/templates/_types/day/admin/header/_header.html new file mode 100644 index 0000000..896a4e8 --- /dev/null +++ b/templates/_types/day/admin/header/_header.html @@ -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 %} \ No newline at end of file diff --git a/templates/_types/day/admin/index.html b/templates/_types/day/admin/index.html new file mode 100644 index 0000000..f4f37b5 --- /dev/null +++ b/templates/_types/day/admin/index.html @@ -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() }} +
+ {% block day_admin_header_child %} + {% endblock %} +
+ {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/day/admin/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/day/admin/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/day/header/_header.html b/templates/_types/day/header/_header.html new file mode 100644 index 0000000..03a3ab8 --- /dev/null +++ b/templates/_types/day/header/_header.html @@ -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, + ) %} +
+ + {{ day_date.strftime('%A %d %B %Y') }} +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/day/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/day/index.html b/templates/_types/day/index.html new file mode 100644 index 0000000..655ee55 --- /dev/null +++ b/templates/_types/day/index.html @@ -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 %} diff --git a/templates/_types/entry/_edit.html b/templates/_types/entry/_edit.html new file mode 100644 index 0000000..4c43abc --- /dev/null +++ b/templates/_types/entry/_edit.html @@ -0,0 +1,334 @@ +
+ + +
+ +
+ + + + +
+ + +
+ + +
+ + {% if day_slots %} + + {% else %} +
+ No slots defined for this day. +
+ {% endif %} +
+ + + + + + + + + + + +
+

Ticket Configuration

+ +
+
+ + +

Leave empty if no tickets needed

+
+ +
+ + +

Leave empty for unlimited

+
+
+
+ +
+ + + + + + + +
+ +
+
+ +{# --- Behaviour: lock / unlock times based on slot.flexible --- #} + \ No newline at end of file diff --git a/templates/_types/entry/_main_panel.html b/templates/_types/entry/_main_panel.html new file mode 100644 index 0000000..002c261 --- /dev/null +++ b/templates/_types/entry/_main_panel.html @@ -0,0 +1,126 @@ +
+ + +
+
+ Name +
+
+ {{ entry.name }} +
+
+ + +
+
+ Slot +
+
+ {% if entry.slot %} + + {{ entry.slot.name }} + + {% if entry.slot.flexible %} + (flexible) + {% else %} + (fixed) + {% endif %} + {% else %} + No slot assigned + {% endif %} +
+
+ + +
+
+ Time Period +
+
+ {{ entry.start_at.strftime('%H:%M') }} + {% if entry.end_at %} + – {{ entry.end_at.strftime('%H:%M') }} + {% else %} + – open-ended + {% endif %} +
+
+ + +
+
+ State +
+
+
+ {% include '_types/entry/_state.html' %} +
+
+
+ + +
+
+ Cost +
+
+ + £{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }} + +
+
+ + +
+
+ Tickets +
+
+ {% include '_types/entry/_tickets.html' %} +
+
+ + +
+
+ Date +
+
+ {{ entry.start_at.strftime('%A, %B %d, %Y') }} +
+
+ + +
+
+ Associated Posts +
+
+ {% include '_types/entry/_posts.html' %} +
+
+ + +
+ {% include '_types/entry/_options.html' %} + + +
+ +
\ No newline at end of file diff --git a/templates/_types/entry/_nav.html b/templates/_types/entry/_nav.html new file mode 100644 index 0000000..ea42b12 --- /dev/null +++ b/templates/_types/entry/_nav.html @@ -0,0 +1,40 @@ +{% import 'macros/links.html' as links %} + +{# Associated Posts - vertical on mobile, horizontal with arrows on desktop #} +
+ {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} + {% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %} + + {% if entry_post.feature_image %} + {{ entry_post.title }} + {% else %} +
+ {% endif %} +
+
{{ entry_post.title }}
+
+
+ {% endcall %} +
+ +{# 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 %} diff --git a/templates/_types/entry/_oob_elements.html b/templates/_types/entry/_oob_elements.html new file mode 100644 index 0000000..8981fa1 --- /dev/null +++ b/templates/_types/entry/_oob_elements.html @@ -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 %} diff --git a/templates/_types/entry/_optioned.html b/templates/_types/entry/_optioned.html new file mode 100644 index 0000000..ba23391 --- /dev/null +++ b/templates/_types/entry/_optioned.html @@ -0,0 +1,9 @@ + +{% include '_types/entry/_options.html' %} +
+ {% include '_types/entry/_title.html' %} +
+ +
+ {% include '_types/entry/_state.html' %} +
\ No newline at end of file diff --git a/templates/_types/entry/_options.html b/templates/_types/entry/_options.html new file mode 100644 index 0000000..b240699 --- /dev/null +++ b/templates/_types/entry/_options.html @@ -0,0 +1,98 @@ +
+ {% if entry.state == 'provisional' %} +
+ + +
+
+ + +
+ {% endif %} + {% if entry.state == 'confirmed' %} +
+ + + +
+ {% endif %} +
\ No newline at end of file diff --git a/templates/_types/entry/_post_search_results.html b/templates/_types/entry/_post_search_results.html new file mode 100644 index 0000000..6891b8f --- /dev/null +++ b/templates/_types/entry/_post_search_results.html @@ -0,0 +1,107 @@ +{% for search_post in search_posts %} +
+ + + +
+{% endfor %} + +{# Infinite scroll sentinel #} +{% if page < total_pages|int %} + +{% elif search_posts %} +
+ End of results +
+{% endif %} diff --git a/templates/_types/entry/_posts.html b/templates/_types/entry/_posts.html new file mode 100644 index 0000000..3ad9122 --- /dev/null +++ b/templates/_types/entry/_posts.html @@ -0,0 +1,74 @@ + +
+ {% if entry_posts %} +
+ {% for entry_post in entry_posts %} +
+ {% if entry_post.feature_image %} + {{ entry_post.title }} + {% else %} +
+ {% endif %} + {{ entry_post.title }} + +
+ {% endfor %} +
+ {% else %} +

No posts associated

+ {% endif %} + + +
+ + +
+
+
diff --git a/templates/_types/entry/_state.html b/templates/_types/entry/_state.html new file mode 100644 index 0000000..b67254a --- /dev/null +++ b/templates/_types/entry/_state.html @@ -0,0 +1,15 @@ +{% if entry.state %} + + {{ entry.state|capitalize }} + + {% endif %} \ No newline at end of file diff --git a/templates/_types/entry/_tickets.html b/templates/_types/entry/_tickets.html new file mode 100644 index 0000000..5cae490 --- /dev/null +++ b/templates/_types/entry/_tickets.html @@ -0,0 +1,105 @@ +{% if entry.ticket_price is not none %} + {# Tickets are configured #} +
+
+ Price: + + £{{ ('%.2f'|format(entry.ticket_price)) }} + +
+
+ Available: + + {% if entry.ticket_count is not none %} + {{ entry.ticket_count }} tickets + {% else %} + Unlimited + {% endif %} + +
+ +
+{% else %} + {# No tickets configured #} +
+ No tickets configured + +
+{% endif %} + +{# Ticket configuration form (hidden by default) #} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
diff --git a/templates/_types/entry/_times.html b/templates/_types/entry/_times.html new file mode 100644 index 0000000..3543fe4 --- /dev/null +++ b/templates/_types/entry/_times.html @@ -0,0 +1,5 @@ +{% from 'macros/date.html' import t %} +
+ {{ t(entry.start_at) }} + {% if entry.end_at %} → {{ t(entry.end_at) }}{% endif %} +
\ No newline at end of file diff --git a/templates/_types/entry/_title.html b/templates/_types/entry/_title.html new file mode 100644 index 0000000..3c1dc63 --- /dev/null +++ b/templates/_types/entry/_title.html @@ -0,0 +1,3 @@ + + {{ entry.name }} + {% include '_types/entry/_state.html' %} diff --git a/templates/_types/entry/admin/_main_panel.html b/templates/_types/entry/admin/_main_panel.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/entry/admin/_main_panel.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/entry/admin/_nav.html b/templates/_types/entry/admin/_nav.html new file mode 100644 index 0000000..54a952c --- /dev/null +++ b/templates/_types/entry/admin/_nav.html @@ -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 %} diff --git a/templates/_types/entry/admin/_nav_posts_oob.html b/templates/_types/entry/admin/_nav_posts_oob.html new file mode 100644 index 0000000..5272856 --- /dev/null +++ b/templates/_types/entry/admin/_nav_posts_oob.html @@ -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 %} +
+ {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} + {% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %} + + {% if entry_post.feature_image %} + {{ entry_post.title }} + {% else %} +
+ {% endif %} +
+
{{ entry_post.title }}
+
+
+ {% endcall %} +
+{% else %} + {# Empty placeholder to remove nav posts when all are disassociated #} +
+{% endif %} diff --git a/templates/_types/entry/admin/_oob_elements.html b/templates/_types/entry/admin/_oob_elements.html new file mode 100644 index 0000000..bcf2255 --- /dev/null +++ b/templates/_types/entry/admin/_oob_elements.html @@ -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 %} diff --git a/templates/_types/entry/admin/header/_header.html b/templates/_types/entry/admin/header/_header.html new file mode 100644 index 0000000..eb029a1 --- /dev/null +++ b/templates/_types/entry/admin/header/_header.html @@ -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 %} \ No newline at end of file diff --git a/templates/_types/entry/admin/index.html b/templates/_types/entry/admin/index.html new file mode 100644 index 0000000..caa100c --- /dev/null +++ b/templates/_types/entry/admin/index.html @@ -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() }} +
+ {% block entry_admin_header_child %} + {% endblock %} +
+ {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/entry/admin/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/entry/admin/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/entry/header/_header.html b/templates/_types/entry/header/_header.html new file mode 100644 index 0000000..eef03bb --- /dev/null +++ b/templates/_types/entry/header/_header.html @@ -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, + ) %} +
+ {% include '_types/entry/_title.html' %} + {% include '_types/entry/_times.html' %} +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/entry/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/entry/index.html b/templates/_types/entry/index.html new file mode 100644 index 0000000..a980f46 --- /dev/null +++ b/templates/_types/entry/index.html @@ -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 %} \ No newline at end of file diff --git a/templates/_types/post_entries/_main_panel.html b/templates/_types/post_entries/_main_panel.html new file mode 100644 index 0000000..342041e --- /dev/null +++ b/templates/_types/post_entries/_main_panel.html @@ -0,0 +1,48 @@ +
+ + {# Associated Entries List #} + {% include '_types/post/admin/_associated_entries.html' %} + + {# Calendars Browser #} +
+

Browse Calendars

+ {% for calendar in all_calendars %} +
+ + {% if calendar.post.feature_image %} + {{ calendar.post.title }} + {% else %} +
+ {% endif %} +
+
+ + {{ calendar.name }} +
+
+ {{ calendar.post.title }} +
+
+
+
+
Loading calendar...
+
+
+ {% else %} +
No calendars found.
+ {% endfor %} +
+
\ No newline at end of file diff --git a/templates/_types/post_entries/_nav.html b/templates/_types/post_entries/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/post_entries/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/post_entries/_oob_elements.html b/templates/_types/post_entries/_oob_elements.html new file mode 100644 index 0000000..3ef5559 --- /dev/null +++ b/templates/_types/post_entries/_oob_elements.html @@ -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 %} + + diff --git a/templates/_types/post_entries/header/_header.html b/templates/_types/post_entries/header/_header.html new file mode 100644 index 0000000..019c000 --- /dev/null +++ b/templates/_types/post_entries/header/_header.html @@ -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) %} + +
+ entries +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/post_entries/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/post_entries/index.html b/templates/_types/post_entries/index.html new file mode 100644 index 0000000..382d297 --- /dev/null +++ b/templates/_types/post_entries/index.html @@ -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 %} diff --git a/templates/_types/slot/__description.html b/templates/_types/slot/__description.html new file mode 100644 index 0000000..7897fd2 --- /dev/null +++ b/templates/_types/slot/__description.html @@ -0,0 +1,13 @@ +{% macro description(slot, oob=False) %} +
+ {{ slot.description or ''}} +
+ +{% endmacro %} diff --git a/templates/_types/slot/_description.html b/templates/_types/slot/_description.html new file mode 100644 index 0000000..32e28e6 --- /dev/null +++ b/templates/_types/slot/_description.html @@ -0,0 +1,5 @@ +

+ {% if slot.description %} + {{ slot.description }} + {% endif %} +

diff --git a/templates/_types/slot/_edit.html b/templates/_types/slot/_edit.html new file mode 100644 index 0000000..79b18cf --- /dev/null +++ b/templates/_types/slot/_edit.html @@ -0,0 +1,182 @@ +
+ +
+
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + Days + + + {# 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') %} + +
+ {# "All" toggle – no name so it’s not submitted #} + + + {# 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) %} + + {% endfor %} +
+
+ + +
+ + +
+ +
+ + + +
+
+
diff --git a/templates/_types/slot/_main_panel.html b/templates/_types/slot/_main_panel.html new file mode 100644 index 0000000..b391298 --- /dev/null +++ b/templates/_types/slot/_main_panel.html @@ -0,0 +1,73 @@ +
+ +
+
+ Days +
+
+ {% set days = slot.days_display.split(', ') %} + {% if days and days[0] != "—" %} +
+ {% for day in days %} + + {{ day }} + + {% endfor %} +
+ {% else %} + No days + {% endif %} +
+
+ + +
+
+ Flexible +
+
+ {{ 'yes' if slot.flexible else 'no' }} +
+
+ + +
+
+
+ Time +
+
+ {{ slot.time_start.strftime('%H:%M') }} — {{ slot.time_end.strftime('%H:%M') }} +
+
+ +
+
+ Cost +
+
+ {{ ('%.2f'|format(slot.cost)) if slot.cost is not none else '' }} +
+
+
+ +
+ +{% if oob %} + {% from '_types/slot/__description.html' import description %} + {{description(slot, oob=True)}} + +{% endif %} \ No newline at end of file diff --git a/templates/_types/slot/_oob_elements.html b/templates/_types/slot/_oob_elements.html new file mode 100644 index 0000000..3b82170 --- /dev/null +++ b/templates/_types/slot/_oob_elements.html @@ -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 %} diff --git a/templates/_types/slot/header/_header.html b/templates/_types/slot/header/_header.html new file mode 100644 index 0000000..75dde6f --- /dev/null +++ b/templates/_types/slot/header/_header.html @@ -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, + ) %} +
+
+ +
+ {{ slot.name }} +
+
+ {% from '_types/slot/__description.html' import description %} + {{description(slot)}} +
+ {% endcall %} + {% call links.desktop_nav() %} + {#% include '_types/slot/_nav.html' %#} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/slot/index.html b/templates/_types/slot/index.html new file mode 100644 index 0000000..265be24 --- /dev/null +++ b/templates/_types/slot/index.html @@ -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 %} diff --git a/templates/_types/slots/_add.html b/templates/_types/slots/_add.html new file mode 100644 index 0000000..f2959d8 --- /dev/null +++ b/templates/_types/slots/_add.html @@ -0,0 +1,125 @@ +
+
+
+ + +
+ +
+ + +
+ +
+ +
+ {# "All" toggle – no name so it’s not submitted #} + + + {# Individual days #} + {% for key, label in [ + ('mon','Mon'),('tue','Tue'),('wed','Wed'),('thu','Thu'), + ('fri','Fri'),('sat','Sat'),('sun','Sun') + ] %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {# NEW: flexible flag #} +
+ + +
+
+ +
+ + + +
+
diff --git a/templates/_types/slots/_add_button.html b/templates/_types/slots/_add_button.html new file mode 100644 index 0000000..3576bed --- /dev/null +++ b/templates/_types/slots/_add_button.html @@ -0,0 +1,12 @@ + + diff --git a/templates/_types/slots/_main_panel.html b/templates/_types/slots/_main_panel.html new file mode 100644 index 0000000..a2ac263 --- /dev/null +++ b/templates/_types/slots/_main_panel.html @@ -0,0 +1,26 @@ +
+ + + + + + + + + + + + + {% for s in slots %} + {% include '_types/slots/_row.html' %} + {% else %} + + {% endfor %} + +
NameFlexibleDaysTimeCostActions
No slots yet.
+ + +
+ {% include '_types/slots/_add_button.html' %} +
+
diff --git a/templates/_types/slots/_oob_elements.html b/templates/_types/slots/_oob_elements.html new file mode 100644 index 0000000..acf0d05 --- /dev/null +++ b/templates/_types/slots/_oob_elements.html @@ -0,0 +1,15 @@ +{% extends "oob_elements.html" %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('calendar-header-child', 'slots-header-child', '_types/slots/header/_header.html')}} + + {% from '_types/calendar/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + + +{% block content %} + {% include '_types/slots/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/slots/_row.html b/templates/_types/slots/_row.html new file mode 100644 index 0000000..b904d04 --- /dev/null +++ b/templates/_types/slots/_row.html @@ -0,0 +1,63 @@ +{% import 'macros/links.html' as links %} + + +
+ {% call links.link( + url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=s.id), + hx_select_search, + aclass=styles.pill + ) %} + {{ s.name }} + {% endcall %} +
+ {% set slot = s %} + {% include '_types/slot/_description.html' %} + + + {{ 'yes' if s.flexible else 'no' }} + + + {% set days = s.days_display.split(', ') %} + {% if days and days[0] != "—" %} +
+ {% for day in days %} + + {{ day }} + + {% endfor %} +
+ {% else %} + No days + {% endif %} + + + {{ s.time_start.strftime('%H:%M') }} - {{ s.time_end.strftime('%H:%M') }} + + + {{ ('%.2f'|format(s.cost)) if s.cost is not none else '' }} + + + + + diff --git a/templates/_types/slots/header/_header.html b/templates/_types/slots/header/_header.html new file mode 100644 index 0000000..f0221e4 --- /dev/null +++ b/templates/_types/slots/header/_header.html @@ -0,0 +1,19 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='slots-row', oob=oob) %} + {% call links.link( + url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug= calendar.slug), + hx_select_search, + ) %} + +
+ slots +
+ {% endcall %} + {% call links.desktop_nav() %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/slots/index.html b/templates/_types/slots/index.html new file mode 100644 index 0000000..453ba5f --- /dev/null +++ b/templates/_types/slots/index.html @@ -0,0 +1,19 @@ +{% extends '_types/calendar/index.html' %} + +{% block calendar_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('slots-header-child', '_types/slots/header/_header.html') %} + {% block slots_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {#% include '_types/calendar/_nav.html' %#} +{% endblock %} + + + +{% block content %} + {% include '_types/slots/_main_panel.html' %} +{% endblock %} \ No newline at end of file diff --git a/templates/_types/ticket_type/_edit.html b/templates/_types/ticket_type/_edit.html new file mode 100644 index 0000000..857bdc8 --- /dev/null +++ b/templates/_types/ticket_type/_edit.html @@ -0,0 +1,103 @@ +
+ +
+
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + +
+
+
diff --git a/templates/_types/ticket_type/_main_panel.html b/templates/_types/ticket_type/_main_panel.html new file mode 100644 index 0000000..1b93309 --- /dev/null +++ b/templates/_types/ticket_type/_main_panel.html @@ -0,0 +1,50 @@ +
+ +
+
+
+ Name +
+
+ {{ ticket_type.name }} +
+
+ +
+
+ Cost +
+
+ £{{ ('%.2f'|format(ticket_type.cost)) if ticket_type.cost is not none else '0.00' }} +
+
+ +
+
+ Count +
+
+ {{ ticket_type.count }} +
+
+
+ + +
diff --git a/templates/_types/ticket_type/_nav.html b/templates/_types/ticket_type/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/ticket_type/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/ticket_type/_oob_elements.html b/templates/_types/ticket_type/_oob_elements.html new file mode 100644 index 0000000..824e62a --- /dev/null +++ b/templates/_types/ticket_type/_oob_elements.html @@ -0,0 +1,18 @@ +{% extends "oob_elements.html" %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('ticket_types-header-child', 'ticket_type-header-child', '_types/ticket_type/header/_header.html')}} + + {% from '_types/ticket_types/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/ticket_type/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/ticket_type/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/ticket_type/header/_header.html b/templates/_types/ticket_type/header/_header.html new file mode 100644 index 0000000..38262c9 --- /dev/null +++ b/templates/_types/ticket_type/header/_header.html @@ -0,0 +1,33 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='ticket_type-row', oob=oob) %} + {% call links.link( + url_for( + 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get', + slug=post.slug, + calendar_slug=calendar.slug, + year=year, + month=month, + day=day, + entry_id=entry.id, + ticket_type_id=ticket_type.id + ), + hx_select_search, + ) %} +
+
+ +
+ {{ ticket_type.name }} +
+
+
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/ticket_type/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/ticket_type/index.html b/templates/_types/ticket_type/index.html new file mode 100644 index 0000000..245992c --- /dev/null +++ b/templates/_types/ticket_type/index.html @@ -0,0 +1,19 @@ +{% extends '_types/ticket_types/index.html' %} +{% import 'macros/layout.html' as layout %} + +{% block ticket_types_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('ticket_types-header-child', '_types/ticket_type/header/_header.html') %} + {% block ticket_type_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + + +{% block _main_mobile_menu %} + {#% include '_types/ticket_type/_nav.html' %#} +{% endblock %} + +{% block content %} + {% include '_types/ticket_type/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/ticket_types/_add.html b/templates/_types/ticket_types/_add.html new file mode 100644 index 0000000..e63b0c2 --- /dev/null +++ b/templates/_types/ticket_types/_add.html @@ -0,0 +1,87 @@ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + +
+
diff --git a/templates/_types/ticket_types/_add_button.html b/templates/_types/ticket_types/_add_button.html new file mode 100644 index 0000000..aa53a55 --- /dev/null +++ b/templates/_types/ticket_types/_add_button.html @@ -0,0 +1,16 @@ + diff --git a/templates/_types/ticket_types/_main_panel.html b/templates/_types/ticket_types/_main_panel.html new file mode 100644 index 0000000..2afaa7a --- /dev/null +++ b/templates/_types/ticket_types/_main_panel.html @@ -0,0 +1,24 @@ +
+ + + + + + + + + + + {% for tt in ticket_types %} + {% include '_types/ticket_types/_row.html' %} + {% else %} + + {% endfor %} + +
NameCostCountActions
No ticket types yet.
+ + +
+ {% include '_types/ticket_types/_add_button.html' %} +
+
diff --git a/templates/_types/ticket_types/_nav.html b/templates/_types/ticket_types/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/ticket_types/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/ticket_types/_oob_elements.html b/templates/_types/ticket_types/_oob_elements.html new file mode 100644 index 0000000..a746f17 --- /dev/null +++ b/templates/_types/ticket_types/_oob_elements.html @@ -0,0 +1,18 @@ +{% extends "oob_elements.html" %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('entry-admin-header-child', 'ticket_types-header-child', '_types/ticket_types/header/_header.html')}} + + {% from '_types/entry/admin/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/ticket_types/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/ticket_types/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/ticket_types/_row.html b/templates/_types/ticket_types/_row.html new file mode 100644 index 0000000..c2114de --- /dev/null +++ b/templates/_types/ticket_types/_row.html @@ -0,0 +1,57 @@ +{% import 'macros/links.html' as links %} + + +
+ {% call links.link( + url_for( + 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get', + slug=post.slug, + calendar_slug=calendar.slug, + year=year, + month=month, + day=day, + entry_id=entry.id, + ticket_type_id=tt.id + ), + hx_select_search, + aclass=styles.pill + ) %} + {{ tt.name }} + {% endcall %} +
+ + + £{{ ('%.2f'|format(tt.cost)) if tt.cost is not none else '0.00' }} + + + {{ tt.count }} + + + + + diff --git a/templates/_types/ticket_types/header/_header.html b/templates/_types/ticket_types/header/_header.html new file mode 100644 index 0000000..84165a1 --- /dev/null +++ b/templates/_types/ticket_types/header/_header.html @@ -0,0 +1,25 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='ticket_types-row', oob=oob) %} + {% 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) %} + +
+ ticket types +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/ticket_types/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/ticket_types/index.html b/templates/_types/ticket_types/index.html new file mode 100644 index 0000000..9d0362a --- /dev/null +++ b/templates/_types/ticket_types/index.html @@ -0,0 +1,20 @@ +{% extends '_types/entry/admin/index.html' %} + +{% block entry_admin_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('ticket_type-header-child', '_types/ticket_types/header/_header.html') %} + {% block ticket_types_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + + +{% block _main_mobile_menu %} + {% include '_types/ticket_types/_nav.html' %} +{% endblock %} + + + +{% block content %} + {% include '_types/ticket_types/_main_panel.html' %} +{% endblock %}