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:
76
events/bp/calendar/admin/routes.py
Normal file
76
events/bp/calendar/admin/routes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g
|
||||
)
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(calendar_slug: str, **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/calendar/admin/index.html")
|
||||
else:
|
||||
# HTMX request: main panel + OOB elements
|
||||
html = await render_template("_types/calendar/admin/_oob_elements.html")
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/description/")
|
||||
@require_admin
|
||||
async def calendar_description_edit(calendar_slug: str, **kwargs):
|
||||
# g.post and g.calendar should already be set by the parent calendar bp
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description_edit.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.post("/description/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def calendar_description_save(calendar_slug: str, **kwargs):
|
||||
form = await request.form
|
||||
description = (form.get("description") or "").strip() or None
|
||||
|
||||
# simple inline update, or call a service if you prefer
|
||||
g.calendar.description = description
|
||||
await g.s.flush()
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
oob=True
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/description/view/")
|
||||
@require_admin
|
||||
async def calendar_description_view(calendar_slug: str, **kwargs):
|
||||
# just render the display version without touching the DB (used by Cancel)
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
251
events/bp/calendar/routes.py
Normal file
251
events/bp/calendar/routes.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
||||
)
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from models.calendars import Calendar
|
||||
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
from shared.browser.app.authz import require_admin
|
||||
|
||||
from .admin.routes import register as register_admin
|
||||
from .services import get_visible_entries_for_period
|
||||
from .services.calendar_view import (
|
||||
parse_int_arg,
|
||||
add_months,
|
||||
build_calendar_weeks,
|
||||
get_calendar_by_post_and_slug,
|
||||
get_calendar_by_slug,
|
||||
update_calendar_description,
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
from ..slots.routes import register as register_slots
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
from bp.calendars.services.calendars import soft_delete
|
||||
|
||||
from bp.day.routes import register as register_day
|
||||
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
import calendar as pycalendar
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("calendar", __name__, url_prefix='/<calendar_slug>')
|
||||
|
||||
bp.register_blueprint(
|
||||
register_admin(),
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_slots(),
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_day()
|
||||
)
|
||||
|
||||
@bp.url_value_preprocessor
|
||||
def pull(endpoint, values):
|
||||
g.calendar_slug = values.get("calendar_slug")
|
||||
|
||||
@bp.before_request
|
||||
async def hydrate_calendar_data():
|
||||
calendar_slug = getattr(g, "calendar_slug", None)
|
||||
|
||||
# Standalone mode (events app): no post context
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
|
||||
else:
|
||||
cal = await get_calendar_by_slug(g.s, calendar_slug)
|
||||
|
||||
if not cal:
|
||||
abort(404)
|
||||
return
|
||||
|
||||
g.calendar = cal
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
|
||||
return {
|
||||
"calendar": getattr(g, "calendar", None),
|
||||
}
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
@bp.get("/")
|
||||
@cache_page(tag="calendars")
|
||||
async def get(calendar_slug: str, **kwargs):
|
||||
"""
|
||||
Show a month-view calendar for this calendar.
|
||||
|
||||
- One month at a time
|
||||
- Outer arrows: +/- 1 year
|
||||
- Inner arrows: +/- 1 month
|
||||
"""
|
||||
|
||||
# --- Determine year & month from query params ---
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
month = parse_int_arg("month")
|
||||
year = parse_int_arg("year")
|
||||
|
||||
if year is None:
|
||||
year = today.year
|
||||
if month is None or not (1 <= month <= 12):
|
||||
month = today.month
|
||||
|
||||
# --- Helpers to move between months ---
|
||||
prev_month_year, prev_month = add_months(year, month, -1)
|
||||
next_month_year, next_month = add_months(year, month, +1)
|
||||
prev_year = year - 1
|
||||
next_year = year + 1
|
||||
|
||||
# --- Build weeks grid (list of weeks, each week = 7 days) ---
|
||||
weeks = build_calendar_weeks(year, month)
|
||||
month_name = pycalendar.month_name[month]
|
||||
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
|
||||
|
||||
# --- Period boundaries for this calendar view ---
|
||||
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
next_y, next_m = add_months(year, month, +1)
|
||||
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
|
||||
|
||||
# --- Identity & admin flag ---
|
||||
user = getattr(g, "user", None)
|
||||
session_id = qsession.get("calendar_sid")
|
||||
|
||||
visible = await get_visible_entries_for_period(
|
||||
sess=g.s,
|
||||
calendar_id=g.calendar.id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
month_entries = visible.merged_entries
|
||||
user_entries = visible.user_entries
|
||||
confirmed_entries = visible.confirmed_entries
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/calendar/index.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/_oob_elements.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def put(calendar_slug: str, **kwargs):
|
||||
"""
|
||||
Idempotent update for calendar configuration.
|
||||
Accepts HTMX form (POST/PUT) and optional JSON.
|
||||
"""
|
||||
# Try JSON first
|
||||
data = await request.get_json(silent=True)
|
||||
description = None
|
||||
|
||||
if data and isinstance(data, dict):
|
||||
description = (data.get("description") or "").strip()
|
||||
else:
|
||||
form = await request.form
|
||||
description = (form.get("description") or "").strip()
|
||||
|
||||
await update_calendar_description(g.calendar, description)
|
||||
html = await render_template("_types/calendar/admin/index.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
@bp.delete("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def delete(calendar_slug: str, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
cal = g.calendar
|
||||
cal.deleted_at = datetime.now(timezone.utc)
|
||||
await g.s.flush()
|
||||
|
||||
# If we have post context (blog-embedded mode), update nav
|
||||
post_data = getattr(g, "post_data", None)
|
||||
html = await render_template("_types/calendars/index.html")
|
||||
|
||||
if post_data:
|
||||
from ..post.services.entry_associations import get_associated_entries
|
||||
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cals = (
|
||||
await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=cals,
|
||||
post=post_data["post"],
|
||||
)
|
||||
html = html + nav_oob
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
return bp
|
||||
1
events/bp/calendar/services/__init__.py
Normal file
1
events/bp/calendar/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .visiblity import get_visible_entries_for_period
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import select, update
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
# (Optional) Mark any existing entries for this user as deleted to avoid duplicates
|
||||
await session.execute(
|
||||
update(CalendarEntry)
|
||||
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
# Reassign anonymous entries to the user
|
||||
result = await session.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.session_id == session_id
|
||||
)
|
||||
)
|
||||
anon_entries = result.scalars().all()
|
||||
for entry in anon_entries:
|
||||
entry.user_id = user_id
|
||||
# No commit here; caller will commit
|
||||
28
events/bp/calendar/services/calendar.py
Normal file
28
events/bp/calendar/services/calendar.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from models.calendars import Calendar
|
||||
from ...calendars.services.calendars import CalendarError
|
||||
|
||||
async def update_calendar_config(sess, calendar_id: int, *, description: str | None, slots: list | None):
|
||||
"""Update description and slots for a calendar."""
|
||||
cal = await sess.get(Calendar, calendar_id)
|
||||
if not cal:
|
||||
raise CalendarError(f"Calendar {calendar_id} not found.")
|
||||
cal.description = (description or '').strip() or None
|
||||
# Validate slots shape a bit
|
||||
norm_slots: list[dict] = []
|
||||
if slots:
|
||||
for s in slots:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
norm_slots.append({
|
||||
"days": str(s.get("days", ""))[:7].lower(),
|
||||
"time_from": str(s.get("time_from", ""))[:5],
|
||||
"time_to": str(s.get("time_to", ""))[:5],
|
||||
"cost_name": (s.get("cost_name") or "")[:64],
|
||||
"description": (s.get("description") or "")[:255],
|
||||
})
|
||||
cal.slots = norm_slots or None
|
||||
await sess.flush()
|
||||
return cal
|
||||
109
events/bp/calendar/services/calendar_view.py
Normal file
109
events/bp/calendar/services/calendar_view.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import calendar as pycalendar
|
||||
|
||||
from quart import request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
|
||||
from models.calendars import Calendar, CalendarSlot
|
||||
|
||||
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
|
||||
"""Parse an integer query parameter from the request."""
|
||||
val = request.args.get(name, "").strip()
|
||||
if not val:
|
||||
return default
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def add_months(year: int, month: int, delta: int) -> tuple[int, int]:
|
||||
"""Add (or subtract) months to a given year/month, handling year overflow."""
|
||||
new_month = month + delta
|
||||
new_year = year + (new_month - 1) // 12
|
||||
new_month = ((new_month - 1) % 12) + 1
|
||||
return new_year, new_month
|
||||
|
||||
|
||||
def build_calendar_weeks(year: int, month: int) -> list[list[dict]]:
|
||||
"""
|
||||
Build a calendar grid for the given year and month.
|
||||
Returns a list of weeks, where each week is a list of 7 day dictionaries.
|
||||
"""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
cal = pycalendar.Calendar(firstweekday=0) # 0 = Monday
|
||||
weeks: list[list[dict]] = []
|
||||
|
||||
for week in cal.monthdatescalendar(year, month):
|
||||
week_days = []
|
||||
for d in week:
|
||||
week_days.append(
|
||||
{
|
||||
"date": d,
|
||||
"in_month": (d.month == month),
|
||||
"is_today": (d == today),
|
||||
}
|
||||
)
|
||||
weeks.append(week_days)
|
||||
|
||||
return weeks
|
||||
|
||||
|
||||
async def get_calendar_by_post_and_slug(
|
||||
session: AsyncSession,
|
||||
post_id: int,
|
||||
calendar_slug: str,
|
||||
) -> Optional[Calendar]:
|
||||
"""
|
||||
Fetch a calendar by post_id and slug, with slots eagerly loaded.
|
||||
Returns None if not found.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Calendar)
|
||||
.options(
|
||||
selectinload(Calendar.slots),
|
||||
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
||||
)
|
||||
.where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post_id,
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_calendar_by_slug(
|
||||
session: AsyncSession,
|
||||
calendar_slug: str,
|
||||
) -> Optional[Calendar]:
|
||||
"""
|
||||
Fetch a calendar by slug only (for standalone events service).
|
||||
With slots eagerly loaded. Returns None if not found.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Calendar)
|
||||
.options(
|
||||
selectinload(Calendar.slots),
|
||||
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
||||
)
|
||||
.where(
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_calendar_description(
|
||||
calendar: Calendar,
|
||||
description: Optional[str],
|
||||
) -> None:
|
||||
"""Update calendar description (in-place on the calendar object)."""
|
||||
calendar.description = description or None
|
||||
118
events/bp/calendar/services/slots.py
Normal file
118
events/bp/calendar/services/slots.py
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import time
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
def _b(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
s = str(v).lower()
|
||||
return s in {"1","true","t","yes","y","on"}
|
||||
|
||||
async def list_slots(sess: AsyncSession, calendar_id: int) -> Sequence[CalendarSlot]:
|
||||
res = await sess.execute(
|
||||
select(CalendarSlot)
|
||||
.where(CalendarSlot.calendar_id == calendar_id, CalendarSlot.deleted_at.is_(None))
|
||||
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
|
||||
)
|
||||
return res.scalars().all()
|
||||
|
||||
async def create_slot(sess: AsyncSession, calendar_id: int, *, name: str, description: str | None,
|
||||
days: dict, time_start: time, time_end: time, cost: float | None):
|
||||
if not name:
|
||||
raise SlotError("name is required")
|
||||
if not time_start or not time_end or time_end <= time_start:
|
||||
raise SlotError("time range invalid")
|
||||
slot = CalendarSlot(
|
||||
calendar_id=calendar_id,
|
||||
name=name,
|
||||
description=(description or None),
|
||||
mon=_b(days.get("mon")), tue=_b(days.get("tue")), wed=_b(days.get("wed")),
|
||||
thu=_b(days.get("thu")), fri=_b(days.get("fri")), sat=_b(days.get("sat")), sun=_b(days.get("sun")),
|
||||
time_start=time_start, time_end=time_end, cost=cost,
|
||||
)
|
||||
sess.add(slot)
|
||||
await sess.flush()
|
||||
return slot
|
||||
|
||||
async def update_slot(
|
||||
sess: AsyncSession,
|
||||
slot_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
days: dict | None = None,
|
||||
time_start: time | None = None,
|
||||
time_end: time | None = None,
|
||||
cost: float | None = None,
|
||||
flexible: bool | None = None, # NEW
|
||||
):
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot or slot.deleted_at is not None:
|
||||
raise SlotError("slot not found")
|
||||
|
||||
if name is not None:
|
||||
slot.name = name
|
||||
|
||||
if description is not None:
|
||||
slot.description = description or None
|
||||
|
||||
if days is not None:
|
||||
slot.mon = _b(days.get("mon", slot.mon))
|
||||
slot.tue = _b(days.get("tue", slot.tue))
|
||||
slot.wed = _b(days.get("wed", slot.wed))
|
||||
slot.thu = _b(days.get("thu", slot.thu))
|
||||
slot.fri = _b(days.get("fri", slot.fri))
|
||||
slot.sat = _b(days.get("sat", slot.sat))
|
||||
slot.sun = _b(days.get("sun", slot.sun))
|
||||
|
||||
if time_start is not None:
|
||||
slot.time_start = time_start
|
||||
if time_end is not None:
|
||||
slot.time_end = time_end
|
||||
|
||||
if (time_start or time_end) and slot.time_end <= slot.time_start:
|
||||
raise SlotError("time range invalid")
|
||||
|
||||
if cost is not None:
|
||||
slot.cost = cost
|
||||
|
||||
# NEW: update flexible flag only if explicitly provided
|
||||
if flexible is not None:
|
||||
slot.flexible = flexible
|
||||
|
||||
await sess.flush()
|
||||
return slot
|
||||
|
||||
async def soft_delete_slot(sess: AsyncSession, slot_id: int):
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot or slot.deleted_at is not None:
|
||||
return
|
||||
from datetime import datetime, timezone
|
||||
slot.deleted_at = datetime.now(timezone.utc)
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def get_slot(sess: AsyncSession, slot_id: int) -> CalendarSlot | None:
|
||||
return await sess.get(CalendarSlot, slot_id)
|
||||
|
||||
async def update_slot_description(
|
||||
sess: AsyncSession,
|
||||
slot_id: int,
|
||||
description: str | None,
|
||||
) -> CalendarSlot:
|
||||
slot = await sess.get(CalendarSlot, slot_id)
|
||||
if not slot:
|
||||
raise SlotError("slot not found")
|
||||
slot.description = description or None
|
||||
await sess.flush()
|
||||
return slot
|
||||
116
events/bp/calendar/services/visiblity.py
Normal file
116
events/bp/calendar/services/visiblity.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisibleEntries:
|
||||
"""
|
||||
Result of applying calendar visibility rules for a given period.
|
||||
"""
|
||||
user_entries: list[CalendarEntry]
|
||||
confirmed_entries: list[CalendarEntry]
|
||||
admin_other_entries: list[CalendarEntry]
|
||||
merged_entries: list[CalendarEntry] # sorted, deduped
|
||||
|
||||
|
||||
async def get_visible_entries_for_period(
|
||||
sess: AsyncSession,
|
||||
calendar_id: int,
|
||||
period_start: datetime,
|
||||
period_end: datetime,
|
||||
user: Optional[object],
|
||||
session_id: Optional[str],
|
||||
) -> VisibleEntries:
|
||||
"""
|
||||
Visibility rules (same as your fixed month view):
|
||||
|
||||
- Non-admin:
|
||||
- sees all *confirmed* entries in the period (any user)
|
||||
- sees all entries for current user/session in the period (any state)
|
||||
- Admin:
|
||||
- sees all confirmed + provisional + ordered entries in the period (all users)
|
||||
- sees pending only for current user/session
|
||||
"""
|
||||
|
||||
user_id = user.id if user else None
|
||||
is_admin = bool(user and getattr(user, "is_admin", False))
|
||||
|
||||
# --- Entries for current user/session (any state, in period) ---
|
||||
user_entries: list[CalendarEntry] = []
|
||||
if user_id or session_id:
|
||||
conditions_user = [
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
]
|
||||
if user_id:
|
||||
conditions_user.append(CalendarEntry.user_id == user_id)
|
||||
elif session_id:
|
||||
conditions_user.append(CalendarEntry.session_id == session_id)
|
||||
|
||||
result_user = await sess.execute(select(CalendarEntry).where(*conditions_user))
|
||||
user_entries = result_user.scalars().all()
|
||||
|
||||
# --- Confirmed entries for everyone in period ---
|
||||
result_conf = await sess.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.state == "confirmed",
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
)
|
||||
)
|
||||
confirmed_entries = result_conf.scalars().all()
|
||||
|
||||
# --- For admins: ordered + provisional for everyone in period ---
|
||||
admin_other_entries: list[CalendarEntry] = []
|
||||
if is_admin:
|
||||
result_admin = await sess.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.calendar_id == calendar_id,
|
||||
CalendarEntry.state.in_(("ordered", "provisional")),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.start_at >= period_start,
|
||||
CalendarEntry.start_at < period_end,
|
||||
)
|
||||
)
|
||||
admin_other_entries = result_admin.scalars().all()
|
||||
|
||||
# --- Merge with de-duplication and keep chronological order ---
|
||||
entries_by_id: dict[int, CalendarEntry] = {}
|
||||
|
||||
# Everyone's confirmed
|
||||
for e in confirmed_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
# Admin-only: everyone's ordered/provisional
|
||||
if is_admin:
|
||||
for e in admin_other_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
# Always include current user/session entries (includes their pending)
|
||||
for e in user_entries:
|
||||
entries_by_id[e.id] = e
|
||||
|
||||
merged_entries = sorted(
|
||||
entries_by_id.values(),
|
||||
key=lambda e: e.start_at or period_start,
|
||||
)
|
||||
|
||||
return VisibleEntries(
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
admin_other_entries=admin_other_entries,
|
||||
merged_entries=merged_entries,
|
||||
)
|
||||
Reference in New Issue
Block a user