Monorepo: consolidate 7 repos into one
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:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

View 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

View 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

View File

@@ -0,0 +1 @@
from .visiblity import get_visible_entries_for_period

View File

@@ -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

View 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

View 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

View 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

View 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,
)