Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
28
events/bp/calendar_entry/admin/routes.py
Normal file
28
events/bp/calendar_entry/admin/routes.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
render_template, make_response, Blueprint
|
||||
)
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(entry_id: int, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/entry/admin/index.html")
|
||||
else:
|
||||
html = await render_template("_types/entry/admin/_oob_elements.html")
|
||||
|
||||
return await make_response(html)
|
||||
return bp
|
||||
626
events/bp/calendar_entry/routes.py
Normal file
626
events/bp/calendar_entry/routes.py
Normal file
@@ -0,0 +1,626 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from models.calendars import CalendarEntry, CalendarSlot
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
from ..calendar_entries.services.entries import (
|
||||
svc_update_entry,
|
||||
CalendarError, # <-- add this if you want to catch it explicitly
|
||||
)
|
||||
from .services.post_associations import (
|
||||
add_post_to_entry,
|
||||
remove_post_from_entry,
|
||||
get_entry_posts,
|
||||
search_posts as svc_search_posts,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
import logging
|
||||
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
from ..ticket_types.routes import register as register_ticket_types
|
||||
|
||||
from .admin.routes import register as register_admin
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def register():
|
||||
bp = Blueprint("calendar_entry", __name__, url_prefix='/<int:entry_id>')
|
||||
|
||||
# Register tickets blueprint
|
||||
bp.register_blueprint(
|
||||
register_ticket_types()
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_admin()
|
||||
)
|
||||
|
||||
@bp.before_request
|
||||
async def load_entry():
|
||||
"""Load the calendar entry from the URL parameter."""
|
||||
entry_id = request.view_args.get("entry_id")
|
||||
if entry_id:
|
||||
result = await g.s.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
g.entry = result.scalar_one_or_none()
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_entry():
|
||||
"""Make entry and date parameters available to all templates in this blueprint."""
|
||||
return {
|
||||
"entry": getattr(g, "entry", None),
|
||||
"year": request.view_args.get("year"),
|
||||
"month": request.view_args.get("month"),
|
||||
"day": request.view_args.get("day"),
|
||||
}
|
||||
|
||||
async def get_day_nav_oob(year: int, month: int, day: int):
|
||||
"""Helper to generate OOB update for day entries nav"""
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
from ..calendar.services import get_visible_entries_for_period
|
||||
from quart import session as qsession
|
||||
|
||||
# Get the calendar from g
|
||||
calendar = getattr(g, "calendar", None)
|
||||
if not calendar:
|
||||
return ""
|
||||
|
||||
# Build day date
|
||||
try:
|
||||
day_date = date(year, month, day)
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
# Period: this day only
|
||||
period_start = datetime(year, month, day, tzinfo=timezone.utc)
|
||||
period_end = period_start + timedelta(days=1)
|
||||
|
||||
# Identity
|
||||
user = getattr(g, "user", None)
|
||||
session_id = qsession.get("calendar_sid")
|
||||
|
||||
# Get confirmed entries for this day
|
||||
visible = await get_visible_entries_for_period(
|
||||
sess=g.s,
|
||||
calendar_id=calendar.id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Render OOB template
|
||||
nav_oob = await render_template(
|
||||
"_types/day/admin/_nav_entries_oob.html",
|
||||
confirmed_entries=visible.confirmed_entries,
|
||||
post=g.post_data["post"],
|
||||
calendar=calendar,
|
||||
day_date=day_date,
|
||||
)
|
||||
return nav_oob
|
||||
|
||||
async def get_post_nav_oob(entry_id: int):
|
||||
"""Helper to generate OOB update for post entries nav when entry state changes"""
|
||||
# Get the entry to find associated posts
|
||||
entry = await g.s.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if not entry:
|
||||
return ""
|
||||
|
||||
# Get all posts associated with this entry
|
||||
from .services.post_associations import get_entry_posts
|
||||
entry_posts = await get_entry_posts(g.s, entry_id)
|
||||
|
||||
# Generate OOB updates for each post's nav
|
||||
nav_oobs = []
|
||||
for post in entry_posts:
|
||||
# Get associated entries for this post
|
||||
from ..post.services.entry_associations import get_associated_entries
|
||||
associated_entries = await get_associated_entries(g.s, post.id)
|
||||
|
||||
# Load calendars for this post
|
||||
from models.calendars import Calendar
|
||||
calendars = (
|
||||
await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.container_type == "page", Calendar.container_id == post.id, Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# Render OOB template for this post's nav
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=calendars,
|
||||
post=post,
|
||||
)
|
||||
nav_oobs.append(nav_oob)
|
||||
|
||||
return "".join(nav_oobs)
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
from ..tickets.services.tickets import (
|
||||
get_available_ticket_count,
|
||||
get_sold_ticket_count,
|
||||
get_user_reserved_count,
|
||||
)
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
view_args = getattr(request, "view_args", {}) or {}
|
||||
entry_id = view_args.get("entry_id")
|
||||
calendar_entry = None
|
||||
entry_posts = []
|
||||
ticket_remaining = None
|
||||
ticket_sold_count = 0
|
||||
user_ticket_count = 0
|
||||
user_ticket_counts_by_type = {}
|
||||
|
||||
stmt = (
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.ticket_types))
|
||||
)
|
||||
result = await g.s.execute(stmt)
|
||||
calendar_entry = result.scalar_one_or_none()
|
||||
|
||||
# Optional: also ensure it belongs to the current calendar, if g.calendar is set
|
||||
if calendar_entry is not None and getattr(g, "calendar", None):
|
||||
if calendar_entry.calendar_id != g.calendar.id:
|
||||
calendar_entry = None
|
||||
|
||||
# Refresh slot relationship if we have a valid entry
|
||||
if calendar_entry is not None:
|
||||
await g.s.refresh(calendar_entry, ['slot'])
|
||||
# Fetch associated posts
|
||||
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
|
||||
# Get ticket availability
|
||||
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
|
||||
# Get sold count
|
||||
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
|
||||
# Get current user's reserved count
|
||||
ident = current_cart_identity()
|
||||
user_ticket_count = await get_user_reserved_count(
|
||||
g.s, calendar_entry.id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
)
|
||||
# Per-type counts for multi-type entries
|
||||
if calendar_entry.ticket_types:
|
||||
for tt in calendar_entry.ticket_types:
|
||||
if tt.deleted_at is None:
|
||||
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
||||
g.s, calendar_entry.id,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
ticket_type_id=tt.id,
|
||||
)
|
||||
|
||||
# Fetch container nav from market (skip calendar — we're on a calendar page)
|
||||
container_nav_html = ""
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = post_data["post"]["id"]
|
||||
post_slug = post_data["post"]["slug"]
|
||||
container_nav_html = await fetch_fragment("market", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": post_slug,
|
||||
})
|
||||
|
||||
return {
|
||||
"entry": calendar_entry,
|
||||
"entry_posts": entry_posts,
|
||||
"ticket_remaining": ticket_remaining,
|
||||
"ticket_sold_count": ticket_sold_count,
|
||||
"user_ticket_count": user_ticket_count,
|
||||
"user_ticket_counts_by_type": user_ticket_counts_by_type,
|
||||
"container_nav_html": container_nav_html,
|
||||
}
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(entry_id: int, **rest):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Full template for both HTMX and normal requests
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/entry/index.html",
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/entry/_oob_elements.html",
|
||||
)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_admin
|
||||
async def get_edit(entry_id: int, **rest):
|
||||
html = await render_template("_types/entry/_edit.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def put(year: int, month: int, day: int, entry_id: int, **rest):
|
||||
form = await request.form
|
||||
|
||||
def parse_time_to_dt(value: str | None, year: int, month: int, day: int):
|
||||
"""
|
||||
'HH:MM' + (year, month, day) -> aware datetime in UTC.
|
||||
Returns None if empty/invalid.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
hour_str, minute_str = value.split(":", 1)
|
||||
hour = int(hour_str)
|
||||
minute = int(minute_str)
|
||||
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
name = (form.get("name") or "").strip()
|
||||
start_at = parse_time_to_dt(form.get("start_at"), year, month, day)
|
||||
end_at = parse_time_to_dt(form.get("end_at"), year, month, day)
|
||||
|
||||
# NEW: slot_id
|
||||
slot_id_raw = (form.get("slot_id") or "").strip()
|
||||
slot_id = int(slot_id_raw) if slot_id_raw else None
|
||||
|
||||
# Ticket configuration
|
||||
ticket_price_str = (form.get("ticket_price") or "").strip()
|
||||
ticket_price = None
|
||||
if ticket_price_str:
|
||||
try:
|
||||
from decimal import Decimal
|
||||
ticket_price = Decimal(ticket_price_str)
|
||||
except Exception:
|
||||
pass # Will be validated below if needed
|
||||
|
||||
ticket_count_str = (form.get("ticket_count") or "").strip()
|
||||
ticket_count = None
|
||||
if ticket_count_str:
|
||||
try:
|
||||
ticket_count = int(ticket_count_str)
|
||||
except Exception:
|
||||
pass # Will be validated below if needed
|
||||
|
||||
field_errors: dict[str, list[str]] = {}
|
||||
|
||||
# --- Basic validation (slot-style) -------------------------
|
||||
|
||||
if not name:
|
||||
field_errors.setdefault("name", []).append(
|
||||
"Please enter a name for the entry."
|
||||
)
|
||||
|
||||
# Check slot first before validating times
|
||||
slot = None
|
||||
if slot_id is not None:
|
||||
result = await g.s.execute(
|
||||
select(CalendarSlot).where(
|
||||
CalendarSlot.id == slot_id,
|
||||
CalendarSlot.calendar_id == g.calendar.id,
|
||||
CalendarSlot.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
slot = result.scalar_one_or_none()
|
||||
if slot is None:
|
||||
field_errors.setdefault("slot_id", []).append(
|
||||
"Selected slot is no longer available."
|
||||
)
|
||||
else:
|
||||
# For inflexible slots, override the times with slot times
|
||||
if not slot.flexible:
|
||||
# Replace start/end with slot times
|
||||
start_at = datetime(year, month, day,
|
||||
slot.time_start.hour,
|
||||
slot.time_start.minute,
|
||||
tzinfo=timezone.utc)
|
||||
if slot.time_end:
|
||||
end_at = datetime(year, month, day,
|
||||
slot.time_end.hour,
|
||||
slot.time_end.minute,
|
||||
tzinfo=timezone.utc)
|
||||
else:
|
||||
# Flexible: validate times are within slot band
|
||||
# Only validate if times were provided
|
||||
if not start_at:
|
||||
field_errors.setdefault("start_at", []).append(
|
||||
"Please select a start time."
|
||||
)
|
||||
if not end_at:
|
||||
field_errors.setdefault("end_at", []).append(
|
||||
"Please select an end time."
|
||||
)
|
||||
|
||||
if start_at and end_at:
|
||||
s_time = start_at.timetz()
|
||||
e_time = end_at.timetz()
|
||||
slot_start = slot.time_start
|
||||
slot_end = slot.time_end
|
||||
|
||||
if s_time.replace(tzinfo=None) < slot_start:
|
||||
field_errors.setdefault("start_at", []).append(
|
||||
f"Start time must be at or after {slot_start.strftime('%H:%M')}."
|
||||
)
|
||||
if slot_end is not None and e_time.replace(tzinfo=None) > slot_end:
|
||||
field_errors.setdefault("end_at", []).append(
|
||||
f"End time must be at or before {slot_end.strftime('%H:%M')}."
|
||||
)
|
||||
else:
|
||||
field_errors.setdefault("slot_id", []).append(
|
||||
"Please select a slot."
|
||||
)
|
||||
|
||||
# Time ordering check (only if we have times and no slot override)
|
||||
if start_at and end_at and end_at < start_at:
|
||||
field_errors.setdefault("end_at", []).append(
|
||||
"End time must be after the start time."
|
||||
)
|
||||
|
||||
if field_errors:
|
||||
return jsonify(
|
||||
{
|
||||
"message": "Please fix the highlighted fields.",
|
||||
"errors": field_errors,
|
||||
}
|
||||
), 422
|
||||
|
||||
# --- Service call & safety net for extra validation -------
|
||||
|
||||
try:
|
||||
entry = await svc_update_entry(
|
||||
g.s,
|
||||
entry_id,
|
||||
name=name,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
slot_id=slot_id, # Pass slot_id to service
|
||||
)
|
||||
|
||||
# Update ticket configuration
|
||||
entry.ticket_price = ticket_price
|
||||
entry.ticket_count = ticket_count
|
||||
|
||||
except CalendarError as e:
|
||||
# If the service still finds something wrong, surface it nicely.
|
||||
msg = str(e)
|
||||
return jsonify(
|
||||
{
|
||||
"message": "There was a problem updating the entry.",
|
||||
"errors": {"__all__": [msg]},
|
||||
}
|
||||
), 422
|
||||
|
||||
# --- Success: re-render the entry block -------------------
|
||||
|
||||
# Get nav OOB update
|
||||
nav_oob = await get_day_nav_oob(year, month, day)
|
||||
|
||||
html = await render_template(
|
||||
"_types/entry/index.html",
|
||||
#entry=entry,
|
||||
)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
|
||||
@bp.post("/confirm/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def confirm_entry(entry_id: int, year: int, month: int, day: int, **rest):
|
||||
await g.s.execute(
|
||||
update(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "provisional",
|
||||
)
|
||||
.values(state="confirmed")
|
||||
)
|
||||
await g.s.flush()
|
||||
|
||||
# Get nav OOB updates (both day and post navs)
|
||||
day_nav_oob = await get_day_nav_oob(year, month, day)
|
||||
post_nav_oob = await get_post_nav_oob(entry_id)
|
||||
|
||||
# redirect back to calendar admin or order page as you prefer
|
||||
html = await render_template("_types/entry/_optioned.html")
|
||||
return await make_response(html + day_nav_oob + post_nav_oob, 200)
|
||||
|
||||
@bp.post("/decline/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def decline_entry(entry_id: int, year: int, month: int, day: int, **rest):
|
||||
await g.s.execute(
|
||||
update(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "provisional",
|
||||
)
|
||||
.values(state="declined")
|
||||
)
|
||||
await g.s.flush()
|
||||
|
||||
# Get nav OOB updates (both day and post navs)
|
||||
day_nav_oob = await get_day_nav_oob(year, month, day)
|
||||
post_nav_oob = await get_post_nav_oob(entry_id)
|
||||
|
||||
# redirect back to calendar admin or order page as you prefer
|
||||
html = await render_template("_types/entry/_optioned.html")
|
||||
return await make_response(html + day_nav_oob + post_nav_oob, 200)
|
||||
|
||||
@bp.post("/provisional/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def provisional_entry(entry_id: int, year: int, month: int, day: int, **rest):
|
||||
await g.s.execute(
|
||||
update(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "confirmed",
|
||||
)
|
||||
.values(state="provisional")
|
||||
)
|
||||
await g.s.flush()
|
||||
|
||||
# Get nav OOB updates (both day and post navs)
|
||||
day_nav_oob = await get_day_nav_oob(year, month, day)
|
||||
post_nav_oob = await get_post_nav_oob(entry_id)
|
||||
|
||||
# redirect back to calendar admin or order page as you prefer
|
||||
html = await render_template("_types/entry/_optioned.html")
|
||||
return await make_response(html + day_nav_oob + post_nav_oob, 200)
|
||||
|
||||
@bp.post("/tickets/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def update_tickets(entry_id: int, **rest):
|
||||
"""Update ticket configuration for a calendar entry"""
|
||||
from .services.ticket_operations import update_ticket_config
|
||||
from decimal import Decimal
|
||||
|
||||
form = await request.form
|
||||
|
||||
# Parse ticket price
|
||||
ticket_price_str = (form.get("ticket_price") or "").strip()
|
||||
ticket_price = None
|
||||
if ticket_price_str:
|
||||
try:
|
||||
ticket_price = Decimal(ticket_price_str)
|
||||
except Exception:
|
||||
return await make_response("Invalid ticket price", 400)
|
||||
|
||||
# Parse ticket count
|
||||
ticket_count_str = (form.get("ticket_count") or "").strip()
|
||||
ticket_count = None
|
||||
if ticket_count_str:
|
||||
try:
|
||||
ticket_count = int(ticket_count_str)
|
||||
except Exception:
|
||||
return await make_response("Invalid ticket count", 400)
|
||||
|
||||
# Update ticket configuration
|
||||
success, error = await update_ticket_config(
|
||||
g.s, entry_id, ticket_price, ticket_count
|
||||
)
|
||||
|
||||
if not success:
|
||||
return await make_response(error, 400)
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
|
||||
html = await render_template("_types/entry/_tickets.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/posts/search/")
|
||||
@require_admin
|
||||
async def search_posts(entry_id: int, **rest):
|
||||
"""Search for posts to associate with this entry"""
|
||||
query = request.args.get("q", "").strip()
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = 10
|
||||
|
||||
search_posts, total = await svc_search_posts(g.s, query, page, per_page)
|
||||
total_pages = math.ceil(total / per_page) if total > 0 else 0
|
||||
|
||||
html = await render_template(
|
||||
"_types/entry/_post_search_results.html",
|
||||
search_posts=search_posts,
|
||||
search_query=query,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/posts/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def add_post(entry_id: int, **rest):
|
||||
"""Add a post association to this entry"""
|
||||
form = await request.form
|
||||
post_id = form.get("post_id")
|
||||
|
||||
if not post_id:
|
||||
return await make_response("Post ID is required", 400)
|
||||
|
||||
try:
|
||||
post_id = int(post_id)
|
||||
except ValueError:
|
||||
return await make_response("Invalid post ID", 400)
|
||||
|
||||
success, error = await add_post_to_entry(g.s, entry_id, post_id)
|
||||
|
||||
if not success:
|
||||
return await make_response(error, 400)
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
# Reload entry_posts for nav update
|
||||
entry_posts = await get_entry_posts(g.s, entry_id)
|
||||
|
||||
# Return updated posts list + OOB nav update
|
||||
html = await render_template("_types/entry/_posts.html")
|
||||
nav_oob = await render_template(
|
||||
"_types/entry/admin/_nav_posts_oob.html",
|
||||
entry_posts=entry_posts,
|
||||
)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
@bp.delete("/posts/<int:post_id>/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def remove_post(entry_id: int, post_id: int, **rest):
|
||||
"""Remove a post association from this entry"""
|
||||
success, error = await remove_post_from_entry(g.s, entry_id, post_id)
|
||||
|
||||
if not success:
|
||||
return await make_response(error or "Association not found", 404)
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
# Reload entry_posts for nav update
|
||||
entry_posts = await get_entry_posts(g.s, entry_id)
|
||||
|
||||
# Return updated posts list + OOB nav update
|
||||
html = await render_template("_types/entry/_posts.html")
|
||||
nav_oob = await render_template(
|
||||
"_types/entry/admin/_nav_posts_oob.html",
|
||||
entry_posts=entry_posts,
|
||||
)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
return bp
|
||||
121
events/bp/calendar_entry/services/post_associations.py
Normal file
121
events/bp/calendar_entry/services/post_associations.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from models.calendars import CalendarEntry, CalendarEntryPost
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
async def add_post_to_entry(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
post_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Associate a post with a calendar entry.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
# Check if entry exists
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if not entry:
|
||||
return False, "Calendar entry not found"
|
||||
|
||||
# Check if post exists
|
||||
post = await services.blog.get_post_by_id(session, post_id)
|
||||
if not post:
|
||||
return False, "Post not found"
|
||||
|
||||
# Check if association already exists
|
||||
existing = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
return False, "Post is already associated with this entry"
|
||||
|
||||
# Create association
|
||||
association = CalendarEntryPost(
|
||||
entry_id=entry_id,
|
||||
content_type="post",
|
||||
content_id=post_id
|
||||
)
|
||||
session.add(association)
|
||||
await session.flush()
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
async def remove_post_from_entry(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
post_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Remove a post association from a calendar entry (soft delete).
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
# Find the association
|
||||
association = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if not association:
|
||||
return False, "Association not found"
|
||||
|
||||
# Soft delete
|
||||
association.deleted_at = func.now()
|
||||
await session.flush()
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
async def get_entry_posts(
|
||||
session: AsyncSession,
|
||||
entry_id: int
|
||||
) -> list:
|
||||
"""
|
||||
Get all posts (as PostDTOs) associated with a calendar entry.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(CalendarEntryPost.content_id).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
post_ids = list(result.scalars().all())
|
||||
if not post_ids:
|
||||
return []
|
||||
posts = await services.blog.get_posts_by_ids(session, post_ids)
|
||||
return sorted(posts, key=lambda p: (p.title or ""))
|
||||
|
||||
|
||||
async def search_posts(
|
||||
session: AsyncSession,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> tuple[list, int]:
|
||||
"""
|
||||
Search for posts by title with pagination.
|
||||
If query is empty, returns all posts in published order.
|
||||
Returns (post_dtos, total_count).
|
||||
"""
|
||||
return await services.blog.search_posts(session, query, page, per_page)
|
||||
87
events/bp/calendar_entry/services/ticket_operations.py
Normal file
87
events/bp/calendar_entry/services/ticket_operations.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
|
||||
async def update_ticket_config(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
ticket_price: Optional[Decimal],
|
||||
ticket_count: Optional[int],
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Update ticket configuration for a calendar entry.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
entry_id: Calendar entry ID
|
||||
ticket_price: Price per ticket (None = no tickets)
|
||||
ticket_count: Total available tickets (None = unlimited)
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
# Get the entry
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if not entry:
|
||||
return False, "Calendar entry not found"
|
||||
|
||||
# Validate inputs
|
||||
if ticket_price is not None and ticket_price < 0:
|
||||
return False, "Ticket price cannot be negative"
|
||||
|
||||
if ticket_count is not None and ticket_count < 0:
|
||||
return False, "Ticket count cannot be negative"
|
||||
|
||||
# Update ticket configuration
|
||||
entry.ticket_price = ticket_price
|
||||
entry.ticket_count = ticket_count
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
async def get_available_tickets(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""
|
||||
Get the number of available tickets for a calendar entry.
|
||||
|
||||
Returns:
|
||||
(available_count, error_message)
|
||||
- available_count is None if unlimited tickets
|
||||
- available_count is the remaining count if limited
|
||||
"""
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if not entry:
|
||||
return None, "Calendar entry not found"
|
||||
|
||||
# If no ticket configuration, return None (unlimited)
|
||||
if entry.ticket_price is None:
|
||||
return None, None
|
||||
|
||||
# If ticket_count is None, unlimited tickets
|
||||
if entry.ticket_count is None:
|
||||
return None, None
|
||||
|
||||
# Returns total count (booked tickets not yet subtracted)
|
||||
return entry.ticket_count, None
|
||||
Reference in New Issue
Block a user