Monorepo: consolidate 7 repos into one
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:
669
shared/services/calendar_impl.py
Normal file
669
shared/services/calendar_impl.py
Normal file
@@ -0,0 +1,669 @@
|
||||
"""SQL-backed CalendarService implementation.
|
||||
|
||||
Queries ``shared.models.calendars.*`` — only this module may write to
|
||||
calendar-domain tables on behalf of other domains.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost, Ticket
|
||||
from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO, TicketDTO
|
||||
|
||||
|
||||
def _cal_to_dto(cal: Calendar) -> CalendarDTO:
|
||||
return CalendarDTO(
|
||||
id=cal.id,
|
||||
container_type=cal.container_type,
|
||||
container_id=cal.container_id,
|
||||
name=cal.name,
|
||||
slug=cal.slug,
|
||||
description=cal.description,
|
||||
)
|
||||
|
||||
|
||||
def _entry_to_dto(entry: CalendarEntry) -> CalendarEntryDTO:
|
||||
cal = getattr(entry, "calendar", None)
|
||||
return CalendarEntryDTO(
|
||||
id=entry.id,
|
||||
calendar_id=entry.calendar_id,
|
||||
name=entry.name,
|
||||
start_at=entry.start_at,
|
||||
state=entry.state,
|
||||
cost=entry.cost,
|
||||
end_at=entry.end_at,
|
||||
user_id=entry.user_id,
|
||||
session_id=entry.session_id,
|
||||
order_id=entry.order_id,
|
||||
slot_id=entry.slot_id,
|
||||
ticket_price=entry.ticket_price,
|
||||
ticket_count=entry.ticket_count,
|
||||
calendar_name=cal.name if cal else None,
|
||||
calendar_slug=cal.slug if cal else None,
|
||||
calendar_container_id=cal.container_id if cal else None,
|
||||
calendar_container_type=cal.container_type if cal else None,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
|
||||
entry = getattr(ticket, "entry", None)
|
||||
tt = getattr(ticket, "ticket_type", None)
|
||||
cal = getattr(entry, "calendar", None) if entry else None
|
||||
# Price: ticket type cost if available, else entry ticket_price
|
||||
price = None
|
||||
if tt and tt.cost is not None:
|
||||
price = tt.cost
|
||||
elif entry and entry.ticket_price is not None:
|
||||
price = entry.ticket_price
|
||||
return TicketDTO(
|
||||
id=ticket.id,
|
||||
code=ticket.code,
|
||||
state=ticket.state,
|
||||
entry_name=entry.name if entry else "",
|
||||
entry_start_at=entry.start_at if entry else ticket.created_at,
|
||||
entry_end_at=entry.end_at if entry else None,
|
||||
ticket_type_name=tt.name if tt else None,
|
||||
calendar_name=cal.name if cal else None,
|
||||
created_at=ticket.created_at,
|
||||
checked_in_at=ticket.checked_in_at,
|
||||
entry_id=entry.id if entry else None,
|
||||
ticket_type_id=ticket.ticket_type_id,
|
||||
price=price,
|
||||
order_id=ticket.order_id,
|
||||
calendar_container_id=cal.container_id if cal else None,
|
||||
)
|
||||
|
||||
|
||||
class SqlCalendarService:
|
||||
|
||||
# -- reads ----------------------------------------------------------------
|
||||
|
||||
async def calendars_for_container(
|
||||
self, session: AsyncSession, container_type: str, container_id: int,
|
||||
) -> list[CalendarDTO]:
|
||||
result = await session.execute(
|
||||
select(Calendar).where(
|
||||
Calendar.container_type == container_type,
|
||||
Calendar.container_id == container_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
).order_by(Calendar.name.asc())
|
||||
)
|
||||
return [_cal_to_dto(c) for c in result.scalars().all()]
|
||||
|
||||
async def pending_entries(
|
||||
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "pending",
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CalendarEntry.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(*filters)
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
return [_entry_to_dto(e) for e in result.scalars().all()]
|
||||
|
||||
async def entries_for_page(
|
||||
self, session: AsyncSession, page_id: int, *,
|
||||
user_id: int | None, session_id: str | None,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
cal_ids = select(Calendar.id).where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == page_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
|
||||
filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "pending",
|
||||
CalendarEntry.calendar_id.in_(cal_ids),
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CalendarEntry.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(*filters)
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
return [_entry_to_dto(e) for e in result.scalars().all()]
|
||||
|
||||
async def entry_by_id(self, session: AsyncSession, entry_id: int) -> CalendarEntryDTO | None:
|
||||
entry = (
|
||||
await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return _entry_to_dto(entry) if entry else None
|
||||
|
||||
async def entry_ids_for_content(
|
||||
self, session: AsyncSession, content_type: str, content_id: int,
|
||||
) -> set[int]:
|
||||
"""Get entry IDs associated with a content item (e.g. post)."""
|
||||
result = await session.execute(
|
||||
select(CalendarEntryPost.entry_id).where(
|
||||
CalendarEntryPost.content_type == content_type,
|
||||
CalendarEntryPost.content_id == content_id,
|
||||
CalendarEntryPost.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return set(result.scalars().all())
|
||||
|
||||
async def visible_entries_for_period(
|
||||
self, session: AsyncSession, calendar_id: int,
|
||||
period_start: datetime, period_end: datetime,
|
||||
*, user_id: int | None, is_admin: bool, session_id: str | None,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
"""Return visible entries for a calendar in a date range.
|
||||
|
||||
Visibility rules:
|
||||
- Everyone sees confirmed entries.
|
||||
- Current user/session sees their own entries (any state).
|
||||
- Admins also see ordered + provisional entries for all users.
|
||||
"""
|
||||
# User/session entries (any state)
|
||||
user_entries: list[CalendarEntry] = []
|
||||
if user_id or session_id:
|
||||
conditions = [
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
]
|
||||
if user_id:
|
||||
conditions.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id:
|
||||
conditions.append(CalendarEntry.session_id == session_id)
|
||||
result = await session.execute(
|
||||
select(CalendarEntry).where(*conditions)
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
user_entries = list(result.scalars().all())
|
||||
|
||||
# Confirmed entries for everyone
|
||||
result = await session.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,
|
||||
).options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
confirmed_entries = list(result.scalars().all())
|
||||
|
||||
# Admin: ordered + provisional for everyone
|
||||
admin_entries: list[CalendarEntry] = []
|
||||
if is_admin:
|
||||
result = await session.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,
|
||||
).options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
admin_entries = list(result.scalars().all())
|
||||
|
||||
# Merge, deduplicate, sort
|
||||
entries_by_id: dict[int, CalendarEntry] = {}
|
||||
for e in confirmed_entries:
|
||||
entries_by_id[e.id] = e
|
||||
for e in admin_entries:
|
||||
entries_by_id[e.id] = e
|
||||
for e in user_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
merged = sorted(entries_by_id.values(), key=lambda e: e.start_at or period_start)
|
||||
return [_entry_to_dto(e) for e in merged]
|
||||
|
||||
async def upcoming_entries_for_container(
|
||||
self, session: AsyncSession,
|
||||
container_type: str | None = None, container_id: int | None = None,
|
||||
*, page: int = 1, per_page: int = 20,
|
||||
) -> tuple[list[CalendarEntryDTO], bool]:
|
||||
"""Upcoming confirmed entries. Optionally scoped to a container."""
|
||||
filters = [
|
||||
CalendarEntry.state == "confirmed",
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= func.now(),
|
||||
]
|
||||
|
||||
if container_type is not None and container_id is not None:
|
||||
cal_ids = select(Calendar.id).where(
|
||||
Calendar.container_type == container_type,
|
||||
Calendar.container_id == container_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
filters.append(CalendarEntry.calendar_id.in_(cal_ids))
|
||||
else:
|
||||
# Still exclude entries from deleted calendars
|
||||
cal_ids = select(Calendar.id).where(
|
||||
Calendar.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
filters.append(CalendarEntry.calendar_id.in_(cal_ids))
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(*filters)
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
has_more = len(entries) == per_page
|
||||
return [_entry_to_dto(e) for e in entries], has_more
|
||||
|
||||
async def associated_entries(
|
||||
self, session: AsyncSession, content_type: str, content_id: int, page: int,
|
||||
) -> tuple[list[CalendarEntryDTO], bool]:
|
||||
"""Get paginated confirmed entries associated with a content item."""
|
||||
per_page = 10
|
||||
entry_ids_result = await session.execute(
|
||||
select(CalendarEntryPost.entry_id).where(
|
||||
CalendarEntryPost.content_type == content_type,
|
||||
CalendarEntryPost.content_id == content_id,
|
||||
CalendarEntryPost.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
entry_ids = set(entry_ids_result.scalars().all())
|
||||
if not entry_ids:
|
||||
return [], False
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id.in_(entry_ids),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "confirmed",
|
||||
)
|
||||
.order_by(CalendarEntry.start_at.desc())
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
has_more = len(entries) == per_page
|
||||
return [_entry_to_dto(e) for e in entries], has_more
|
||||
|
||||
async def toggle_entry_post(
|
||||
self, session: AsyncSession, entry_id: int, content_type: str, content_id: int,
|
||||
) -> bool:
|
||||
"""Toggle association; returns True if now associated, False if removed."""
|
||||
existing = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.content_type == content_type,
|
||||
CalendarEntryPost.content_id == content_id,
|
||||
CalendarEntryPost.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
existing.deleted_at = func.now()
|
||||
await session.flush()
|
||||
return False
|
||||
else:
|
||||
assoc = CalendarEntryPost(
|
||||
entry_id=entry_id,
|
||||
content_type=content_type,
|
||||
content_id=content_id,
|
||||
)
|
||||
session.add(assoc)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
async def get_entries_for_order(
|
||||
self, session: AsyncSession, order_id: int,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.order_id == order_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
return [_entry_to_dto(e) for e in result.scalars().all()]
|
||||
|
||||
async def user_tickets(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[TicketDTO]:
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.state != "cancelled",
|
||||
)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
.options(
|
||||
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
||||
selectinload(Ticket.ticket_type),
|
||||
)
|
||||
)
|
||||
return [_ticket_to_dto(t) for t in result.scalars().all()]
|
||||
|
||||
async def user_bookings(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.user_id == user_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state.in_(("ordered", "provisional", "confirmed")),
|
||||
)
|
||||
.order_by(CalendarEntry.start_at.desc())
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
return [_entry_to_dto(e) for e in result.scalars().all()]
|
||||
|
||||
# -- batch reads (not in protocol — convenience for blog service) ---------
|
||||
|
||||
async def confirmed_entries_for_posts(
|
||||
self, session: AsyncSession, post_ids: list[int],
|
||||
) -> dict[int, list[CalendarEntryDTO]]:
|
||||
"""Return confirmed entries grouped by post_id for a batch of posts."""
|
||||
if not post_ids:
|
||||
return {}
|
||||
|
||||
result = await session.execute(
|
||||
select(CalendarEntry, CalendarEntryPost.content_id)
|
||||
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
.where(
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id.in_(post_ids),
|
||||
CalendarEntryPost.deleted_at.is_(None),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "confirmed",
|
||||
)
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
)
|
||||
|
||||
entries_by_post: dict[int, list[CalendarEntryDTO]] = {}
|
||||
for entry, post_id in result:
|
||||
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
|
||||
return entries_by_post
|
||||
|
||||
# -- writes ---------------------------------------------------------------
|
||||
|
||||
async def adopt_entries_for_user(
|
||||
self, session: AsyncSession, user_id: int, session_id: str,
|
||||
) -> None:
|
||||
"""Adopt anonymous calendar entries for a logged-in user.
|
||||
|
||||
Only deletes stale *pending* entries for the user — confirmed/ordered
|
||||
entries must be preserved.
|
||||
"""
|
||||
await session.execute(
|
||||
update(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.user_id == user_id,
|
||||
CalendarEntry.state == "pending",
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
cal_result = await session.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
|
||||
|
||||
async def claim_entries_for_order(
|
||||
self, session: AsyncSession, order_id: int, user_id: int | None,
|
||||
session_id: str | None, page_post_id: int | None,
|
||||
) -> None:
|
||||
"""Mark pending CalendarEntries as 'ordered' and set order_id."""
|
||||
filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "pending",
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CalendarEntry.session_id == session_id)
|
||||
|
||||
if page_post_id is not None:
|
||||
cal_ids = select(Calendar.id).where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == page_post_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
filters.append(CalendarEntry.calendar_id.in_(cal_ids))
|
||||
|
||||
await session.execute(
|
||||
update(CalendarEntry)
|
||||
.where(*filters)
|
||||
.values(state="ordered", order_id=order_id)
|
||||
)
|
||||
|
||||
async def confirm_entries_for_order(
|
||||
self, session: AsyncSession, order_id: int, user_id: int | None,
|
||||
session_id: str | None,
|
||||
) -> None:
|
||||
"""Mark ordered CalendarEntries as 'provisional'."""
|
||||
filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "ordered",
|
||||
CalendarEntry.order_id == order_id,
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CalendarEntry.session_id == session_id)
|
||||
|
||||
await session.execute(
|
||||
update(CalendarEntry)
|
||||
.where(*filters)
|
||||
.values(state="provisional")
|
||||
)
|
||||
|
||||
# -- ticket methods -------------------------------------------------------
|
||||
|
||||
def _ticket_query_options(self):
|
||||
return [
|
||||
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
||||
selectinload(Ticket.ticket_type),
|
||||
]
|
||||
|
||||
async def pending_tickets(
|
||||
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
|
||||
) -> list[TicketDTO]:
|
||||
"""Reserved tickets for the given identity (cart line items)."""
|
||||
filters = [Ticket.state == "reserved"]
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(*filters)
|
||||
.order_by(Ticket.created_at.asc())
|
||||
.options(*self._ticket_query_options())
|
||||
)
|
||||
return [_ticket_to_dto(t) for t in result.scalars().all()]
|
||||
|
||||
async def tickets_for_page(
|
||||
self, session: AsyncSession, page_id: int, *,
|
||||
user_id: int | None, session_id: str | None,
|
||||
) -> list[TicketDTO]:
|
||||
"""Reserved tickets scoped to a page (via entry → calendar → container_id)."""
|
||||
cal_ids = select(Calendar.id).where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == page_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
|
||||
entry_ids = select(CalendarEntry.id).where(
|
||||
CalendarEntry.calendar_id.in_(cal_ids),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
|
||||
filters = [
|
||||
Ticket.state == "reserved",
|
||||
Ticket.entry_id.in_(entry_ids),
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(*filters)
|
||||
.order_by(Ticket.created_at.asc())
|
||||
.options(*self._ticket_query_options())
|
||||
)
|
||||
return [_ticket_to_dto(t) for t in result.scalars().all()]
|
||||
|
||||
async def claim_tickets_for_order(
|
||||
self, session: AsyncSession, order_id: int, user_id: int | None,
|
||||
session_id: str | None, page_post_id: int | None,
|
||||
) -> None:
|
||||
"""Set order_id on reserved tickets at checkout."""
|
||||
filters = [Ticket.state == "reserved"]
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
|
||||
if page_post_id is not None:
|
||||
cal_ids = select(Calendar.id).where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == page_post_id,
|
||||
Calendar.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
entry_ids = select(CalendarEntry.id).where(
|
||||
CalendarEntry.calendar_id.in_(cal_ids),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
).scalar_subquery()
|
||||
filters.append(Ticket.entry_id.in_(entry_ids))
|
||||
|
||||
await session.execute(
|
||||
update(Ticket).where(*filters).values(order_id=order_id)
|
||||
)
|
||||
|
||||
async def confirm_tickets_for_order(
|
||||
self, session: AsyncSession, order_id: int,
|
||||
) -> None:
|
||||
"""Reserved → confirmed on payment."""
|
||||
await session.execute(
|
||||
update(Ticket)
|
||||
.where(Ticket.order_id == order_id, Ticket.state == "reserved")
|
||||
.values(state="confirmed")
|
||||
)
|
||||
|
||||
async def get_tickets_for_order(
|
||||
self, session: AsyncSession, order_id: int,
|
||||
) -> list[TicketDTO]:
|
||||
"""Tickets for a given order (checkout return display)."""
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(Ticket.order_id == order_id)
|
||||
.order_by(Ticket.created_at.asc())
|
||||
.options(*self._ticket_query_options())
|
||||
)
|
||||
return [_ticket_to_dto(t) for t in result.scalars().all()]
|
||||
|
||||
async def adopt_tickets_for_user(
|
||||
self, session: AsyncSession, user_id: int, session_id: str,
|
||||
) -> None:
|
||||
"""Migrate anonymous reserved tickets to user on login."""
|
||||
result = await session.execute(
|
||||
select(Ticket).where(
|
||||
Ticket.session_id == session_id,
|
||||
Ticket.state == "reserved",
|
||||
)
|
||||
)
|
||||
for ticket in result.scalars().all():
|
||||
ticket.user_id = user_id
|
||||
|
||||
async def adjust_ticket_quantity(
|
||||
self, session: AsyncSession, entry_id: int, count: int, *,
|
||||
user_id: int | None, session_id: str | None,
|
||||
ticket_type_id: int | None = None,
|
||||
) -> int:
|
||||
"""Adjust reserved ticket count to target. Returns new count."""
|
||||
import uuid
|
||||
|
||||
count = max(count, 0)
|
||||
|
||||
# Current reserved count
|
||||
filters = [
|
||||
Ticket.entry_id == entry_id,
|
||||
Ticket.state == "reserved",
|
||||
]
|
||||
if user_id is not None:
|
||||
filters.append(Ticket.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(Ticket.session_id == session_id)
|
||||
else:
|
||||
return 0
|
||||
if ticket_type_id is not None:
|
||||
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||
|
||||
current = await session.scalar(
|
||||
select(func.count(Ticket.id)).where(*filters)
|
||||
) or 0
|
||||
|
||||
if count > current:
|
||||
# Create tickets
|
||||
for _ in range(count - current):
|
||||
ticket = Ticket(
|
||||
entry_id=entry_id,
|
||||
ticket_type_id=ticket_type_id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
code=uuid.uuid4().hex,
|
||||
state="reserved",
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
elif count < current:
|
||||
# Cancel newest tickets
|
||||
to_cancel = current - count
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(*filters)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
.limit(to_cancel)
|
||||
)
|
||||
for ticket in result.scalars().all():
|
||||
ticket.state = "cancelled"
|
||||
await session.flush()
|
||||
|
||||
return count
|
||||
Reference in New Issue
Block a user