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:
257
events/bp/calendar_entries/routes.py
Normal file
257
events/bp/calendar_entries/routes.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from quart import (
|
||||
request, render_template, render_template_string, make_response,
|
||||
Blueprint, g, redirect, url_for, jsonify,
|
||||
)
|
||||
|
||||
|
||||
from sqlalchemy import update, func as sa_func
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
from .services.entries import (
|
||||
|
||||
add_entry as svc_add_entry,
|
||||
)
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
|
||||
from 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
|
||||
|
||||
# Count pending calendar entries from local session (sees the just-added entry)
|
||||
user_id = getattr(g, "user", None) and g.user.id
|
||||
cal_filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "pending",
|
||||
]
|
||||
if user_id:
|
||||
cal_filters.append(CalendarEntry.user_id == user_id)
|
||||
|
||||
cal_count = await g.s.scalar(
|
||||
select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters)
|
||||
) or 0
|
||||
|
||||
# Get product cart count via service (same DB, no HTTP needed)
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.services.registry import services
|
||||
ident = current_cart_identity()
|
||||
cart_summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
product_count = cart_summary.count
|
||||
total_count = product_count + cal_count
|
||||
|
||||
html = await render_template("_types/day/_main_panel.html")
|
||||
mini_html = await render_template_string(
|
||||
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||
'{{ mini(oob="true") }}',
|
||||
cart_count=total_count,
|
||||
)
|
||||
return await make_response(html + mini_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
|
||||
278
events/bp/calendar_entries/services/entries.py
Normal file
278
events/bp/calendar_entries/services/entries.py
Normal file
@@ -0,0 +1,278 @@
|
||||
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 shared.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()
|
||||
|
||||
# Publish to federation inline
|
||||
if entry.user_id:
|
||||
from shared.services.federation_publish import try_publish
|
||||
await try_publish(
|
||||
sess,
|
||||
user_id=entry.user_id,
|
||||
activity_type="Create",
|
||||
object_type="Event",
|
||||
object_data={
|
||||
"name": entry.name or "",
|
||||
"startTime": entry.start_at.isoformat() if entry.start_at else "",
|
||||
"endTime": entry.end_at.isoformat() if entry.end_at else "",
|
||||
},
|
||||
source_type="CalendarEntry",
|
||||
source_id=entry.id,
|
||||
)
|
||||
|
||||
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.container_type == "page",
|
||||
Calendar.container_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
|
||||
Reference in New Issue
Block a user