feat: decouple events from shared_lib, add app-owned models

Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Events-owned models in events/models/ (calendars with all related models)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- Calendar uses container_type/container_id instead of post_id FK
- CalendarEntryPost uses content_type/content_id (generic content refs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:46:36 +00:00
parent 95d954fdb6
commit 154f968296
37 changed files with 506 additions and 93 deletions

0
__init__.py Normal file
View File

16
app.py
View File

@@ -7,9 +7,9 @@ from quart import g, abort
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select from sqlalchemy import select
from shared.factory import create_base_app from shared.infrastructure.factory import create_base_app
from suma_browser.app.bp import register_calendars, register_markets, register_payments from bp import register_calendars, register_markets, register_payments
async def events_context() -> dict: async def events_context() -> dict:
@@ -19,8 +19,8 @@ async def events_context() -> dict:
- menu_items: fetched from coop internal API - menu_items: fetched from coop internal API
- cart_count/cart_total: fetched from cart internal API - cart_count/cart_total: fetched from cart internal API
""" """
from shared.context import base_context from shared.infrastructure.context import base_context
from shared.internal_api import get as api_get, dictobj from shared.infrastructure.internal_api import get as api_get, dictobj
ctx = await base_context() ctx = await base_context()
@@ -41,9 +41,9 @@ async def events_context() -> dict:
def create_app() -> "Quart": def create_app() -> "Quart":
from models.ghost_content import Post from blog.models.ghost_content import Post
from models.calendars import Calendar from models.calendars import Calendar
from models.market_place import MarketPlace from market.models.market_place import MarketPlace
app = create_base_app("events", context_fn=events_context) app = create_base_app("events", context_fn=events_context)
@@ -118,14 +118,14 @@ def create_app() -> "Quart":
calendars = ( calendars = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()
markets = ( markets = (
await g.s.execute( await g.s.execute(
select(MarketPlace) select(MarketPlace)
.where(MarketPlace.post_id == post_id, MarketPlace.deleted_at.is_(None)) .where(MarketPlace.container_type == "page", MarketPlace.container_id == post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc()) .order_by(MarketPlace.name.asc())
) )
).scalars().all() ).scalars().all()

View File

@@ -5,8 +5,8 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
@@ -17,7 +17,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(calendar_slug: str, **kwargs): async def admin(calendar_slug: str, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -11,7 +11,7 @@ from sqlalchemy import select
from models.calendars import Calendar from models.calendars import Calendar
from sqlalchemy.orm import selectinload, with_loader_criteria from sqlalchemy.orm import selectinload, with_loader_criteria
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from .admin.routes import register as register_admin from .admin.routes import register as register_admin
from .services import get_visible_entries_for_period from .services import get_visible_entries_for_period
@@ -23,17 +23,17 @@ from .services.calendar_view import (
get_calendar_by_slug, get_calendar_by_slug,
update_calendar_description, update_calendar_description,
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from ..slots.routes import register as register_slots from ..slots.routes import register as register_slots
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
from suma_browser.app.bp.calendars.services.calendars import soft_delete from bp.calendars.services.calendars import soft_delete
from suma_browser.app.bp.day.routes import register as register_day from bp.day.routes import register as register_day
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from sqlalchemy import select from sqlalchemy import select
@@ -213,7 +213,7 @@ def register():
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
async def delete(calendar_slug: str, **kwargs): async def delete(calendar_slug: str, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
cal = g.calendar cal = g.calendar
cal.deleted_at = datetime.now(timezone.utc) cal.deleted_at = datetime.now(timezone.utc)
@@ -230,7 +230,7 @@ def register():
cals = ( cals = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()

View File

@@ -1,5 +1,6 @@
from sqlalchemy import select, update from sqlalchemy import select, update
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
from sqlalchemy import func from sqlalchemy import func
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None: async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:

View File

@@ -11,7 +11,6 @@ from sqlalchemy.orm import selectinload, with_loader_criteria
from models.calendars import Calendar, CalendarSlot from models.calendars import Calendar, CalendarSlot
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]: def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
"""Parse an integer query parameter from the request.""" """Parse an integer query parameter from the request."""
val = request.args.get(name, "").strip() val = request.args.get(name, "").strip()
@@ -71,7 +70,8 @@ async def get_calendar_by_post_and_slug(
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)), with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
) )
.where( .where(
Calendar.post_id == post_id, Calendar.container_type == "page",
Calendar.container_id == post_id,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None), Calendar.deleted_at.is_(None),
) )

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
class SlotError(ValueError): class SlotError(ValueError):
pass pass

View File

@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
@dataclass @dataclass
class VisibleEntries: class VisibleEntries:
""" """

View File

@@ -11,18 +11,20 @@ from sqlalchemy import update
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
from .services.entries import ( from .services.entries import (
add_entry as svc_add_entry, add_entry as svc_add_entry,
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from suma_browser.app.bp.calendar_entry.routes import register as register_calendar_entry from bp.calendar_entry.routes import register as register_calendar_entry
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
from sqlalchemy import select from sqlalchemy import select

View File

@@ -7,9 +7,10 @@ from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar, CalendarEntry from models.calendars import Calendar, CalendarEntry
from datetime import datetime from datetime import datetime
from suma_browser.app.errors import AppError from shared.browser.app.errors import AppError
class CalendarError(AppError): class CalendarError(AppError):
"""Base error for calendar service operations.""" """Base error for calendar service operations."""
@@ -120,7 +121,8 @@ async def list_entries(
await sess.execute( await sess.execute(
select(Calendar.id) select(Calendar.id)
.where( .where(
Calendar.post_id == post_id, Calendar.container_type == "page",
Calendar.container_id == post_id,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None), Calendar.deleted_at.is_(None),
) )

View File

@@ -5,7 +5,7 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
def register(): def register():
@@ -15,7 +15,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(entry_id: int, **kwargs): async def admin(entry_id: int, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -5,8 +5,8 @@ from sqlalchemy import select, update
from models.calendars import CalendarEntry, CalendarSlot from models.calendars import CalendarEntry, CalendarSlot
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select from sqlalchemy import select
@@ -142,7 +142,7 @@ def register():
calendars = ( calendars = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post.id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post.id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()
@@ -199,7 +199,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def get(entry_id: int, **rest): async def get(entry_id: int, **rest):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX # TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
# For now, render full template for both HTMX and normal requests # For now, render full template for both HTMX and normal requests

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.sql import func from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost from models.calendars import CalendarEntry, CalendarEntryPost
from models.ghost_content import Post from blog.models.ghost_content import Post
async def add_post_to_entry( async def add_post_to_entry(
@@ -38,7 +38,8 @@ async def add_post_to_entry(
existing = await session.scalar( existing = await session.scalar(
select(CalendarEntryPost).where( select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id, CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None) CalendarEntryPost.deleted_at.is_(None)
) )
) )
@@ -49,7 +50,8 @@ async def add_post_to_entry(
# Create association # Create association
association = CalendarEntryPost( association = CalendarEntryPost(
entry_id=entry_id, entry_id=entry_id,
post_id=post_id content_type="post",
content_id=post_id
) )
session.add(association) session.add(association)
await session.flush() await session.flush()
@@ -70,7 +72,8 @@ async def remove_post_from_entry(
association = await session.scalar( association = await session.scalar(
select(CalendarEntryPost).where( select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id, CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None) CalendarEntryPost.deleted_at.is_(None)
) )
) )

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
async def update_ticket_config( async def update_ticket_config(
session: AsyncSession, session: AsyncSession,
entry_id: int, entry_id: int,

View File

@@ -7,16 +7,17 @@ from sqlalchemy import select
from models.calendars import Calendar from models.calendars import Calendar
from .services.calendars import ( from .services.calendars import (
create_calendar as svc_create_calendar, create_calendar as svc_create_calendar,
) )
from ..calendar.routes import register as register_calendar from ..calendar.routes import register as register_calendar
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():
@@ -78,7 +79,7 @@ def register():
cals = ( cals = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()

View File

@@ -4,7 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar from models.calendars import Calendar
from models.ghost_content import Post # for FK existence checks from blog.models.ghost_content import Post # for FK existence checks
import unicodedata import unicodedata
import re import re
@@ -12,7 +12,7 @@ import re
class CalendarError(ValueError): class CalendarError(ValueError):
"""Base error for calendar service operations.""" """Base error for calendar service operations."""
from suma_browser.app.utils import ( from shared.browser.app.utils import (
utcnow utcnow
) )
@@ -51,7 +51,8 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
cal = ( cal = (
await sess.execute( await sess.execute(
select(Calendar) select(Calendar)
.join(Post, Calendar.post_id == Post.id) .join(Post, Calendar.container_id == Post.id)
.where(Calendar.container_type == "page")
.where( .where(
Post.slug == post_slug, Post.slug == post_slug,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
@@ -89,7 +90,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
# Look for existing (including soft-deleted) # Look for existing (including soft-deleted)
q = await sess.execute( q = await sess.execute(
select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name) select(Calendar).where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.name == name)
) )
existing = q.scalar_one_or_none() existing = q.scalar_one_or_none()
@@ -100,7 +101,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
return existing return existing
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.') raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
cal = Calendar(post_id=post_id, name=name, slug=slug) cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
sess.add(cal) sess.add(cal)
await sess.flush() await sess.flush()
return cal return cal

View File

@@ -5,7 +5,7 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
def register(): def register():
@@ -15,7 +15,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(year: int, month: int, day: int, **kwargs): async def admin(year: int, month: int, day: int, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -5,17 +5,18 @@ from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, render_template, make_response, Blueprint, g, abort, session as qsession
) )
from suma_browser.app.bp.calendar.services import get_visible_entries_for_period from bp.calendar.services import get_visible_entries_for_period
from suma_browser.app.bp.calendar_entries.routes import register as register_calendar_entries from bp.calendar_entries.routes import register as register_calendar_entries
from .admin.routes import register as register_admin from .admin.routes import register as register_admin
from suma_browser.app.redis_cacher import cache_page from shared.browser.app.redis_cacher import cache_page
from models.calendars import CalendarSlot # add this import from models.calendars import CalendarSlot # add this import
from sqlalchemy import select from sqlalchemy import select
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -5,16 +5,16 @@ from quart import (
) )
from sqlalchemy import select from sqlalchemy import select
from models.market_place import MarketPlace from market.models.market_place import MarketPlace
from .services.markets import ( from .services.markets import (
create_market as svc_create_market, create_market as svc_create_market,
soft_delete as svc_soft_delete, soft_delete as svc_soft_delete,
) )
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -6,9 +6,9 @@ import unicodedata
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.market_place import MarketPlace from market.models.market_place import MarketPlace
from models.ghost_content import Post from blog.models.ghost_content import Post
from suma_browser.app.utils import utcnow from shared.browser.app.utils import utcnow
class MarketError(ValueError): class MarketError(ValueError):
@@ -47,7 +47,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
# Look for existing (including soft-deleted) # Look for existing (including soft-deleted)
existing = (await sess.execute( existing = (await sess.execute(
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug) select(MarketPlace).where(MarketPlace.container_type == "page", MarketPlace.container_id == post_id, MarketPlace.slug == slug)
)).scalar_one_or_none() )).scalar_one_or_none()
if existing: if existing:
@@ -58,7 +58,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
return existing return existing
raise MarketError(f'Market with slug "{slug}" already exists for this page.') raise MarketError(f'Market with slug "{slug}" already exists for this page.')
market = MarketPlace(post_id=post_id, name=name, slug=slug) market = MarketPlace(container_type="page", container_id=post_id, name=name, slug=slug)
sess.add(market) sess.add(market)
await sess.flush() await sess.flush()
return market return market
@@ -68,7 +68,8 @@ async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> b
market = ( market = (
await sess.execute( await sess.execute(
select(MarketPlace) select(MarketPlace)
.join(Post, MarketPlace.post_id == Post.id) .join(Post, MarketPlace.container_id == Post.id)
.where(MarketPlace.container_type == "page")
.where( .where(
Post.slug == post_slug, Post.slug == post_slug,
MarketPlace.slug == market_slug, MarketPlace.slug == market_slug,

View File

@@ -5,10 +5,10 @@ from quart import (
) )
from sqlalchemy import select from sqlalchemy import select
from models.page_config import PageConfig from cart.models.page_config import PageConfig
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():
@@ -26,7 +26,7 @@ def register():
return {} return {}
pc = (await g.s.execute( pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id) select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none() )).scalar_one_or_none()
return { return {
@@ -55,10 +55,10 @@ def register():
return await make_response("Post not found", 404) return await make_response("Post not found", 404)
pc = (await g.s.execute( pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id) select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none() )).scalar_one_or_none()
if pc is None: if pc is None:
pc = PageConfig(post_id=post_id, features={}) pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc) g.s.add(pc)
await g.s.flush() await g.s.flush()

View File

@@ -6,8 +6,8 @@ from quart import (
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.slot import ( from .services.slot import (
update_slot as svc_update_slot, update_slot as svc_update_slot,
@@ -19,11 +19,11 @@ from ..slots.services.slots import (
list_slots as svc_list_slots, list_slots as svc_list_slots,
) )
from suma_browser.app.utils import ( from shared.browser.app.utils import (
parse_time, parse_time,
parse_cost parse_cost
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
class SlotError(ValueError): class SlotError(ValueError):
pass pass

View File

@@ -5,8 +5,8 @@ from quart import (
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.slots import ( from .services.slots import (
list_slots as svc_list_slots, list_slots as svc_list_slots,
@@ -15,11 +15,11 @@ from .services.slots import (
from ..slot.routes import register as register_slot from ..slot.routes import register as register_slot
from suma_browser.app.utils import ( from shared.browser.app.utils import (
parse_time, parse_time,
parse_cost parse_cost
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
class SlotError(ValueError): class SlotError(ValueError):
pass pass

View File

@@ -18,8 +18,8 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType from models.calendars import CalendarEntry, Ticket, TicketType
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from ..tickets.services.tickets import ( from ..tickets.services.tickets import (
get_ticket_by_code, get_ticket_by_code,
@@ -37,7 +37,7 @@ def register() -> Blueprint:
@require_admin @require_admin
async def dashboard(): async def dashboard():
"""Ticket admin dashboard with QR scanner and recent tickets.""" """Ticket admin dashboard with QR scanner and recent tickets."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Get recent tickets # Get recent tickets
result = await g.s.execute( result = await g.s.execute(
@@ -89,7 +89,7 @@ def register() -> Blueprint:
@require_admin @require_admin
async def entry_tickets(entry_id: int): async def entry_tickets(entry_id: int):
"""List all tickets for a specific calendar entry.""" """List all tickets for a specific calendar entry."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
entry = await g.s.scalar( entry = await g.s.scalar(
select(CalendarEntry) select(CalendarEntry)

View File

@@ -4,8 +4,8 @@ from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, render_template, make_response, Blueprint, g, jsonify
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.ticket import ( from .services.ticket import (
get_ticket_type as svc_get_ticket_type, get_ticket_type as svc_get_ticket_type,
@@ -16,7 +16,7 @@ from .services.ticket import (
from ..ticket_types.services.tickets import ( from ..ticket_types.services.tickets import (
list_ticket_types as svc_list_ticket_types, list_ticket_types as svc_list_ticket_types,
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import TicketType from models.calendars import TicketType
from datetime import datetime, timezone from datetime import datetime, timezone

View File

@@ -4,8 +4,8 @@ from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, render_template, make_response, Blueprint, g, jsonify
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.tickets import ( from .services.tickets import (
list_ticket_types as svc_list_ticket_types, list_ticket_types as svc_list_ticket_types,
@@ -14,7 +14,7 @@ from .services.tickets import (
from ..ticket_type.routes import register as register_ticket_type from ..ticket_type.routes import register as register_ticket_type
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import TicketType from models.calendars import TicketType
from datetime import datetime, timezone from datetime import datetime, timezone

View File

@@ -17,8 +17,8 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
from shared.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.tickets import ( from .services.tickets import (
create_ticket, create_ticket,
@@ -37,7 +37,7 @@ def register() -> Blueprint:
@bp.get("/") @bp.get("/")
async def my_tickets(): async def my_tickets():
"""List all tickets for the current user/session.""" """List all tickets for the current user/session."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
ident = current_cart_identity() ident = current_cart_identity()
tickets = await get_user_tickets( tickets = await get_user_tickets(
@@ -62,7 +62,7 @@ def register() -> Blueprint:
@bp.get("/<code>/") @bp.get("/<code>/")
async def ticket_detail(code: str): async def ticket_detail(code: str):
"""View a single ticket with QR code.""" """View a single ticket with QR code."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
ticket = await get_ticket_by_code(g.s, code) ticket = await get_ticket_by_code(g.s, code)
if not ticket: if not ticket:

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import selectinload
from models.calendars import Ticket, TicketType, CalendarEntry from models.calendars import Ticket, TicketType, CalendarEntry
async def create_ticket( async def create_ticket(
session: AsyncSession, session: AsyncSession,
*, *,

83
config/app-config.yaml Normal file
View File

@@ -0,0 +1,83 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
coop_root: /market
coop_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
coop: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

View File

@@ -11,7 +11,7 @@ from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Calendar, Ticket from models.calendars import CalendarEntry, Calendar, Ticket
from suma_browser.app.csrf import csrf_exempt from shared.browser.app.csrf import csrf_exempt
def register() -> Blueprint: def register() -> Blueprint:

4
models/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .calendars import (
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

304
models/calendars.py Normal file
View File

@@ -0,0 +1,304 @@
from __future__ import annotations
from sqlalchemy import (
Column, Integer, String, DateTime, ForeignKey, CheckConstraint,
Index, text, Text, Boolean, Time, Numeric
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
# Adjust this import to match where your Base lives
from shared.db.base import Base
from datetime import datetime, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Calendar(Base):
__tablename__ = "calendars"
id = Column(Integer, primary_key=True)
container_type = Column(String(32), nullable=False, server_default=text("'page'"))
container_id = Column(Integer, nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
slug = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
# relationships
entries = relationship(
"CalendarEntry",
back_populates="calendar",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="CalendarEntry.start_at",
)
slots = relationship(
"CalendarSlot",
back_populates="calendar",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="CalendarSlot.time_start",
)
# Indexes / constraints
__table_args__ = (
Index("ix_calendars_container", "container_type", "container_id"),
Index("ix_calendars_name", "name"),
Index("ix_calendars_slug", "slug"),
# Soft-delete-aware uniqueness: one active calendar per container/slug
Index(
"ux_calendars_container_slug_active",
"container_type",
"container_id",
func.lower(slug),
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
)
class CalendarEntry(Base):
__tablename__ = "calendar_entries"
id = Column(Integer, primary_key=True)
calendar_id = Column(
Integer,
ForeignKey("calendars.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# NEW: ownership + order link
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True)
# NEW: slot link
slot_id = Column(Integer, ForeignKey("calendar_slots.id", ondelete="SET NULL"), nullable=True, index=True)
# details
name = Column(String(255), nullable=False)
start_at = Column(DateTime(timezone=True), nullable=False, index=True)
end_at = Column(DateTime(timezone=True), nullable=True)
# NEW: booking state + cost
state = Column(
String(20),
nullable=False,
server_default=text("'pending'"),
)
cost = Column(Numeric(10, 2), nullable=False, server_default=text("10"))
# Ticket configuration
ticket_price = Column(Numeric(10, 2), nullable=True) # Price per ticket (NULL = no tickets)
ticket_count = Column(Integer, nullable=True) # Total available tickets (NULL = unlimited)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"(end_at IS NULL) OR (end_at >= start_at)",
name="ck_calendar_entries_end_after_start",
),
Index("ix_calendar_entries_name", "name"),
Index("ix_calendar_entries_start_at", "start_at"),
Index("ix_calendar_entries_user_id", "user_id"),
Index("ix_calendar_entries_session_id", "session_id"),
Index("ix_calendar_entries_state", "state"),
Index("ix_calendar_entries_order_id", "order_id"),
Index("ix_calendar_entries_slot_id", "slot_id"),
)
calendar = relationship("Calendar", back_populates="entries")
slot = relationship("CalendarSlot", back_populates="entries", lazy="selectin")
# Optional, but handy:
order = relationship("Order", back_populates="calendar_entries", lazy="selectin")
posts = relationship("CalendarEntryPost", back_populates="entry", cascade="all, delete-orphan")
ticket_types = relationship(
"TicketType",
back_populates="entry",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="TicketType.name",
)
DAY_LABELS = [
("mon", "Mon"),
("tue", "Tue"),
("wed", "Wed"),
("thu", "Thu"),
("fri", "Fri"),
("sat", "Sat"),
("sun", "Sun"),
]
class CalendarSlot(Base):
__tablename__ = "calendar_slots"
id = Column(Integer, primary_key=True)
calendar_id = Column(
Integer,
ForeignKey("calendars.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
mon = Column(Boolean, nullable=False, default=False)
tue = Column(Boolean, nullable=False, default=False)
wed = Column(Boolean, nullable=False, default=False)
thu = Column(Boolean, nullable=False, default=False)
fri = Column(Boolean, nullable=False, default=False)
sat = Column(Boolean, nullable=False, default=False)
sun = Column(Boolean, nullable=False, default=False)
# NEW: whether bookings can be made at flexible times within this band
flexible = Column(
Boolean,
nullable=False,
server_default=text("false"),
default=False,
)
@property
def days_display(self) -> str:
days = [label for attr, label in DAY_LABELS if getattr(self, attr)]
if len(days) == len(DAY_LABELS):
# all days selected
return "All" # or "All days" if you prefer
return ", ".join(days) if days else ""
time_start = Column(Time(timezone=False), nullable=False)
time_end = Column(Time(timezone=False), nullable=False)
cost = Column(Numeric(10, 2), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"(time_end > time_start)",
name="ck_calendar_slots_time_end_after_start",
),
Index("ix_calendar_slots_calendar_id", "calendar_id"),
Index("ix_calendar_slots_time_start", "time_start"),
)
calendar = relationship("Calendar", back_populates="slots")
entries = relationship("CalendarEntry", back_populates="slot")
class TicketType(Base):
__tablename__ = "ticket_types"
id = Column(Integer, primary_key=True)
entry_id = Column(
Integer,
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(String(255), nullable=False)
cost = Column(Numeric(10, 2), nullable=False)
count = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_ticket_types_entry_id", "entry_id"),
Index("ix_ticket_types_name", "name"),
)
entry = relationship("CalendarEntry", back_populates="ticket_types")
class Ticket(Base):
__tablename__ = "tickets"
id = Column(Integer, primary_key=True)
entry_id = Column(
Integer,
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ticket_type_id = Column(
Integer,
ForeignKey("ticket_types.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
order_id = Column(
Integer,
ForeignKey("orders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
code = Column(String(64), unique=True, nullable=False) # QR/barcode value
state = Column(
String(20),
nullable=False,
server_default=text("'reserved'"),
) # reserved, confirmed, checked_in, cancelled
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
checked_in_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_tickets_entry_id", "entry_id"),
Index("ix_tickets_ticket_type_id", "ticket_type_id"),
Index("ix_tickets_user_id", "user_id"),
Index("ix_tickets_session_id", "session_id"),
Index("ix_tickets_order_id", "order_id"),
Index("ix_tickets_code", "code", unique=True),
Index("ix_tickets_state", "state"),
)
entry = relationship("CalendarEntry", backref="tickets")
ticket_type = relationship("TicketType", backref="tickets")
order = relationship("Order", backref="tickets")
class CalendarEntryPost(Base):
"""Junction between calendar entries and content (posts, etc.)."""
__tablename__ = "calendar_entry_posts"
id = Column(Integer, primary_key=True, autoincrement=True)
entry_id = Column(Integer, ForeignKey("calendar_entries.id", ondelete="CASCADE"), nullable=False)
content_type = Column(String(32), nullable=False, server_default=text("'post'"))
content_id = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_entry_posts_entry_id", "entry_id"),
Index("ix_entry_posts_content", "content_type", "content_id"),
)
entry = relationship("CalendarEntry", back_populates="posts")
__all__ = ["Calendar", "CalendarEntry", "CalendarSlot", "TicketType", "Ticket", "CalendarEntryPost"]

View File

@@ -1,7 +1,9 @@
import sys import sys
import os import os
# Add the shared library submodule to the Python path _app_dir = os.path.dirname(os.path.abspath(__file__))
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib") _project_root = os.path.dirname(_app_dir)
if _shared not in sys.path:
sys.path.insert(0, _shared) for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)