Move Post/Author/Tag/PostAuthor/PostTag/PostUser models from
shared/models/ghost_content.py to blog/models/content.py so blog-domain
models no longer live in the shared layer. Replace the shared
SqlBlogService + BlogService protocol with a blog-local singleton
(blog_service), and switch entry_associations.py from direct DB access
to HTTP fetch_data("blog", "post-by-id") to respect the inter-service
boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
625 lines
23 KiB
Python
625 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
from models.calendars import CalendarEntry, CalendarSlot
|
|
|
|
from shared.browser.app.authz import require_admin
|
|
from shared.browser.app.redis_cacher import clear_cache
|
|
|
|
|
|
from sqlalchemy import select
|
|
from quart import (
|
|
request, render_template, make_response, Blueprint, g, jsonify
|
|
)
|
|
from ..calendar_entries.services.entries import (
|
|
svc_update_entry,
|
|
CalendarError, # <-- add this if you want to catch it explicitly
|
|
)
|
|
from .services.post_associations import (
|
|
add_post_to_entry,
|
|
remove_post_from_entry,
|
|
get_entry_posts,
|
|
search_posts as svc_search_posts,
|
|
)
|
|
from datetime import datetime, timezone
|
|
import math
|
|
import logging
|
|
|
|
from shared.infrastructure.fragments import fetch_fragment
|
|
from shared.sx.helpers import sx_response
|
|
|
|
from ..ticket_types.routes import register as register_ticket_types
|
|
|
|
from .admin.routes import register as register_admin
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def register():
|
|
bp = Blueprint("calendar_entry", __name__, url_prefix='/<int:entry_id>')
|
|
|
|
# Register tickets blueprint
|
|
bp.register_blueprint(
|
|
register_ticket_types()
|
|
)
|
|
bp.register_blueprint(
|
|
register_admin()
|
|
)
|
|
|
|
@bp.before_request
|
|
async def load_entry():
|
|
"""Load the calendar entry from the URL parameter."""
|
|
from quart import abort
|
|
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()
|
|
if g.entry is None:
|
|
abort(404)
|
|
|
|
@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 nav
|
|
from sx.sx_components import render_day_entries_nav_oob
|
|
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
|
|
|
|
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 shared.services.entry_associations import get_associated_entries
|
|
associated_entries = await get_associated_entries(post.id)
|
|
|
|
# Load calendars for this post
|
|
from models.calendars import Calendar
|
|
calendars = (
|
|
await g.s.execute(
|
|
select(Calendar)
|
|
.where(Calendar.container_type == "page", Calendar.container_id == post.id, Calendar.deleted_at.is_(None))
|
|
.order_by(Calendar.name.asc())
|
|
)
|
|
).scalars().all()
|
|
|
|
# Render OOB nav for this post
|
|
from sx.sx_components import render_post_nav_entries_oob
|
|
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
|
|
nav_oobs.append(nav_oob)
|
|
|
|
return "".join(nav_oobs)
|
|
|
|
@bp.context_processor
|
|
async def inject_root():
|
|
from ..tickets.services.tickets import (
|
|
get_available_ticket_count,
|
|
get_sold_ticket_count,
|
|
get_user_reserved_count,
|
|
)
|
|
from shared.infrastructure.cart_identity import current_cart_identity
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
view_args = getattr(request, "view_args", {}) or {}
|
|
entry_id = view_args.get("entry_id")
|
|
calendar_entry = None
|
|
entry_posts = []
|
|
ticket_remaining = None
|
|
ticket_sold_count = 0
|
|
user_ticket_count = 0
|
|
user_ticket_counts_by_type = {}
|
|
|
|
stmt = (
|
|
select(CalendarEntry)
|
|
.where(
|
|
CalendarEntry.id == entry_id,
|
|
CalendarEntry.deleted_at.is_(None),
|
|
)
|
|
.options(selectinload(CalendarEntry.ticket_types))
|
|
)
|
|
result = await g.s.execute(stmt)
|
|
calendar_entry = result.scalar_one_or_none()
|
|
|
|
# Optional: also ensure it belongs to the current calendar, if g.calendar is set
|
|
if calendar_entry is not None and getattr(g, "calendar", None):
|
|
if calendar_entry.calendar_id != g.calendar.id:
|
|
calendar_entry = None
|
|
|
|
# Refresh slot relationship if we have a valid entry
|
|
if calendar_entry is not None:
|
|
await g.s.refresh(calendar_entry, ['slot'])
|
|
# Fetch associated posts
|
|
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
|
|
# Get ticket availability
|
|
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
|
|
# Get sold count
|
|
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
|
|
# Get current user's reserved count
|
|
ident = current_cart_identity()
|
|
user_ticket_count = await get_user_reserved_count(
|
|
g.s, calendar_entry.id,
|
|
user_id=ident["user_id"],
|
|
session_id=ident["session_id"],
|
|
)
|
|
# Per-type counts for multi-type entries
|
|
if calendar_entry.ticket_types:
|
|
for tt in calendar_entry.ticket_types:
|
|
if tt.deleted_at is None:
|
|
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
|
g.s, calendar_entry.id,
|
|
user_id=ident["user_id"],
|
|
session_id=ident["session_id"],
|
|
ticket_type_id=tt.id,
|
|
)
|
|
|
|
# Fetch container nav from relations (exclude calendar — we're on a calendar page)
|
|
container_nav = ""
|
|
post_data = getattr(g, "post_data", None)
|
|
if post_data:
|
|
post_id = post_data["post"]["id"]
|
|
post_slug = post_data["post"]["slug"]
|
|
container_nav = await fetch_fragment("relations", "container-nav", params={
|
|
"container_type": "page",
|
|
"container_id": str(post_id),
|
|
"post_slug": post_slug,
|
|
"exclude": "page->calendar",
|
|
})
|
|
|
|
return {
|
|
"entry": calendar_entry,
|
|
"entry_posts": entry_posts,
|
|
"ticket_remaining": ticket_remaining,
|
|
"ticket_sold_count": ticket_sold_count,
|
|
"user_ticket_count": user_ticket_count,
|
|
"user_ticket_counts_by_type": user_ticket_counts_by_type,
|
|
"container_nav": container_nav,
|
|
}
|
|
@bp.get("/")
|
|
@require_admin
|
|
async def get(entry_id: int, **rest):
|
|
from shared.browser.app.utils.htmx import is_htmx_request
|
|
from shared.sx.page import get_template_context
|
|
from sx.sx_components import render_entry_page, render_entry_oob
|
|
|
|
tctx = await get_template_context()
|
|
if not is_htmx_request():
|
|
html = await render_entry_page(tctx)
|
|
return await make_response(html, 200)
|
|
else:
|
|
sx_src = await render_entry_oob(tctx)
|
|
return sx_response(sx_src)
|
|
|
|
@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)
|
|
|
|
from shared.sx.page import get_template_context
|
|
from sx.sx_components import render_entry_page
|
|
|
|
tctx = await get_template_context()
|
|
html = await render_entry_page(tctx)
|
|
return sx_response(html + nav_oob)
|
|
|
|
|
|
@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)
|
|
|
|
# Re-read entry to get updated state
|
|
await g.s.refresh(g.entry)
|
|
from sx.sx_components import render_entry_optioned
|
|
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
|
|
return sx_response(html + day_nav_oob + post_nav_oob)
|
|
|
|
@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)
|
|
|
|
# Re-read entry to get updated state
|
|
await g.s.refresh(g.entry)
|
|
from sx.sx_components import render_entry_optioned
|
|
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
|
|
return sx_response(html + day_nav_oob + post_nav_oob)
|
|
|
|
@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)
|
|
|
|
# Re-read entry to get updated state
|
|
await g.s.refresh(g.entry)
|
|
from sx.sx_components import render_entry_optioned
|
|
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
|
|
return sx_response(html + day_nav_oob + post_nav_oob)
|
|
|
|
@bp.post("/tickets/")
|
|
@require_admin
|
|
@clear_cache(tag="calendars", tag_scope="all")
|
|
async def update_tickets(entry_id: int, **rest):
|
|
"""Update ticket configuration for a calendar entry"""
|
|
from .services.ticket_operations import update_ticket_config
|
|
from decimal import Decimal
|
|
|
|
form = await request.form
|
|
|
|
# Parse ticket price
|
|
ticket_price_str = (form.get("ticket_price") or "").strip()
|
|
ticket_price = None
|
|
if ticket_price_str:
|
|
try:
|
|
ticket_price = Decimal(ticket_price_str)
|
|
except Exception:
|
|
return await make_response("Invalid ticket price", 400)
|
|
|
|
# Parse ticket count
|
|
ticket_count_str = (form.get("ticket_count") or "").strip()
|
|
ticket_count = None
|
|
if ticket_count_str:
|
|
try:
|
|
ticket_count = int(ticket_count_str)
|
|
except Exception:
|
|
return await make_response("Invalid ticket count", 400)
|
|
|
|
# Update ticket configuration
|
|
success, error = await update_ticket_config(
|
|
g.s, entry_id, ticket_price, ticket_count
|
|
)
|
|
|
|
if not success:
|
|
return await make_response(error, 400)
|
|
|
|
await g.s.flush()
|
|
|
|
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
|
|
await g.s.refresh(g.entry)
|
|
from sx.sx_components import render_entry_tickets_config
|
|
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
|
|
return sx_response(html)
|
|
|
|
@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
|
|
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
|
|
va = request.view_args or {}
|
|
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
|
|
nav_oob = render_entry_posts_nav_oob(entry_posts)
|
|
return sx_response(html + nav_oob)
|
|
|
|
@bp.delete("/posts/<int:post_id>/")
|
|
@require_admin
|
|
@clear_cache(tag="calendars", tag_scope="all")
|
|
async def remove_post(entry_id: int, post_id: int, **rest):
|
|
"""Remove a post association from this entry"""
|
|
success, error = await remove_post_from_entry(g.s, entry_id, post_id)
|
|
|
|
if not success:
|
|
return await make_response(error or "Association not found", 404)
|
|
|
|
await g.s.flush()
|
|
|
|
# Reload entry_posts for nav update
|
|
entry_posts = await get_entry_posts(g.s, entry_id)
|
|
|
|
# Return updated posts list + OOB nav update
|
|
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
|
|
va = request.view_args or {}
|
|
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
|
|
nav_oob = render_entry_posts_nav_oob(entry_posts)
|
|
return sx_response(html + nav_oob)
|
|
|
|
return bp
|