From 154f968296500e0715bc4961c395f3048cc5009a Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Feb 2026 12:46:36 +0000 Subject: [PATCH] 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 --- __init__.py | 0 app.py | 16 +- bp/calendar/admin/routes.py | 6 +- bp/calendar/routes.py | 14 +- .../adopt_session_entries_for_user.py | 1 + bp/calendar/services/calendar_view.py | 4 +- bp/calendar/services/slots.py | 1 + bp/calendar/services/visiblity.py | 1 + bp/calendar_entries/routes.py | 8 +- bp/calendar_entries/services/entries.py | 6 +- bp/calendar_entry/admin/routes.py | 4 +- bp/calendar_entry/routes.py | 8 +- .../services/post_associations.py | 11 +- .../services/ticket_operations.py | 1 + bp/calendars/routes.py | 9 +- bp/calendars/services/calendars.py | 11 +- bp/day/admin/routes.py | 4 +- bp/day/routes.py | 9 +- bp/markets/routes.py | 8 +- bp/markets/services/markets.py | 13 +- bp/payments/routes.py | 12 +- bp/slot/routes.py | 8 +- bp/slot/services/slot.py | 1 + bp/slots/routes.py | 8 +- bp/slots/services/slots.py | 1 + bp/ticket_admin/routes.py | 8 +- bp/ticket_type/routes.py | 6 +- bp/ticket_type/services/ticket.py | 1 + bp/ticket_types/routes.py | 6 +- bp/ticket_types/services/tickets.py | 1 + bp/tickets/routes.py | 8 +- bp/tickets/services/tickets.py | 1 + config/app-config.yaml | 83 +++++ events_api.py | 2 +- models/__init__.py | 4 + models/calendars.py | 304 ++++++++++++++++++ path_setup.py | 10 +- 37 files changed, 506 insertions(+), 93 deletions(-) create mode 100644 __init__.py create mode 100644 config/app-config.yaml create mode 100644 models/__init__.py create mode 100644 models/calendars.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index 4309243..2b8208d 100644 --- a/app.py +++ b/app.py @@ -7,9 +7,9 @@ from quart import g, abort from jinja2 import FileSystemLoader, ChoiceLoader 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: @@ -19,8 +19,8 @@ async def events_context() -> dict: - menu_items: fetched from coop internal API - cart_count/cart_total: fetched from cart internal API """ - from shared.context import base_context - from shared.internal_api import get as api_get, dictobj + from shared.infrastructure.context import base_context + from shared.infrastructure.internal_api import get as api_get, dictobj ctx = await base_context() @@ -41,9 +41,9 @@ async def events_context() -> dict: def create_app() -> "Quart": - from models.ghost_content import Post + from blog.models.ghost_content import Post 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) @@ -118,14 +118,14 @@ def create_app() -> "Quart": calendars = ( await g.s.execute( 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()) ) ).scalars().all() markets = ( await g.s.execute( 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()) ) ).scalars().all() diff --git a/bp/calendar/admin/routes.py b/bp/calendar/admin/routes.py index cc3ae24..3d042ff 100644 --- a/bp/calendar/admin/routes.py +++ b/bp/calendar/admin/routes.py @@ -5,8 +5,8 @@ from quart import ( ) -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache @@ -17,7 +17,7 @@ def register(): @bp.get("/") @require_admin 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 if not is_htmx_request(): diff --git a/bp/calendar/routes.py b/bp/calendar/routes.py index 907a690..4bd544f 100644 --- a/bp/calendar/routes.py +++ b/bp/calendar/routes.py @@ -11,7 +11,7 @@ from sqlalchemy import select from models.calendars import Calendar 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 .services import get_visible_entries_for_period @@ -23,17 +23,17 @@ from .services.calendar_view import ( get_calendar_by_slug, 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 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 @@ -213,7 +213,7 @@ def register(): @require_admin @clear_cache(tag="calendars", tag_scope="all") 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.deleted_at = datetime.now(timezone.utc) @@ -230,7 +230,7 @@ def register(): cals = ( await g.s.execute( 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()) ) ).scalars().all() diff --git a/bp/calendar/services/adopt_session_entries_for_user.py b/bp/calendar/services/adopt_session_entries_for_user.py index c44b2fc..8e0fa2f 100644 --- a/bp/calendar/services/adopt_session_entries_for_user.py +++ b/bp/calendar/services/adopt_session_entries_for_user.py @@ -1,5 +1,6 @@ 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: diff --git a/bp/calendar/services/calendar_view.py b/bp/calendar/services/calendar_view.py index edacbf4..71fe331 100644 --- a/bp/calendar/services/calendar_view.py +++ b/bp/calendar/services/calendar_view.py @@ -11,7 +11,6 @@ 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() @@ -71,7 +70,8 @@ async def get_calendar_by_post_and_slug( with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)), ) .where( - Calendar.post_id == post_id, + Calendar.container_type == "page", + Calendar.container_id == post_id, Calendar.slug == calendar_slug, Calendar.deleted_at.is_(None), ) diff --git a/bp/calendar/services/slots.py b/bp/calendar/services/slots.py index 31c0e76..4c40445 100644 --- a/bp/calendar/services/slots.py +++ b/bp/calendar/services/slots.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import CalendarSlot + class SlotError(ValueError): pass diff --git a/bp/calendar/services/visiblity.py b/bp/calendar/services/visiblity.py index 9c804f1..5c5776a 100644 --- a/bp/calendar/services/visiblity.py +++ b/bp/calendar/services/visiblity.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import CalendarEntry + @dataclass class VisibleEntries: """ diff --git a/bp/calendar_entries/routes.py b/bp/calendar_entries/routes.py index 8057c30..af3a379 100644 --- a/bp/calendar_entries/routes.py +++ b/bp/calendar_entries/routes.py @@ -11,18 +11,20 @@ from sqlalchemy import update from models.calendars import CalendarEntry + from .services.entries import ( add_entry as svc_add_entry, ) -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +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 sqlalchemy import select diff --git a/bp/calendar_entries/services/entries.py b/bp/calendar_entries/services/entries.py index e1ad623..cbdfc66 100644 --- a/bp/calendar_entries/services/entries.py +++ b/bp/calendar_entries/services/entries.py @@ -7,9 +7,10 @@ from sqlalchemy import select, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import Calendar, CalendarEntry + from datetime import datetime -from suma_browser.app.errors import AppError +from shared.browser.app.errors import AppError class CalendarError(AppError): """Base error for calendar service operations.""" @@ -120,7 +121,8 @@ async def list_entries( await sess.execute( select(Calendar.id) .where( - Calendar.post_id == post_id, + Calendar.container_type == "page", + Calendar.container_id == post_id, Calendar.slug == calendar_slug, Calendar.deleted_at.is_(None), ) diff --git a/bp/calendar_entry/admin/routes.py b/bp/calendar_entry/admin/routes.py index 76d8f96..fb422a2 100644 --- a/bp/calendar_entry/admin/routes.py +++ b/bp/calendar_entry/admin/routes.py @@ -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(): @@ -15,7 +15,7 @@ def register(): @bp.get("/") @require_admin 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 if not is_htmx_request(): diff --git a/bp/calendar_entry/routes.py b/bp/calendar_entry/routes.py index 9ccea07..b119dae 100644 --- a/bp/calendar_entry/routes.py +++ b/bp/calendar_entry/routes.py @@ -5,8 +5,8 @@ from sqlalchemy import select, update from models.calendars import CalendarEntry, CalendarSlot -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache from sqlalchemy import select @@ -142,7 +142,7 @@ def register(): calendars = ( await g.s.execute( 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()) ) ).scalars().all() @@ -199,7 +199,7 @@ def register(): @bp.get("/") @require_admin 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 # For now, render full template for both HTMX and normal requests diff --git a/bp/calendar_entry/services/post_associations.py b/bp/calendar_entry/services/post_associations.py index 4a0193a..023e758 100644 --- a/bp/calendar_entry/services/post_associations.py +++ b/bp/calendar_entry/services/post_associations.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.sql import func 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( @@ -38,7 +38,8 @@ async def add_post_to_entry( existing = await session.scalar( select(CalendarEntryPost).where( CalendarEntryPost.entry_id == entry_id, - CalendarEntryPost.post_id == post_id, + CalendarEntryPost.content_type == "post", + CalendarEntryPost.content_id == post_id, CalendarEntryPost.deleted_at.is_(None) ) ) @@ -49,7 +50,8 @@ async def add_post_to_entry( # Create association association = CalendarEntryPost( entry_id=entry_id, - post_id=post_id + content_type="post", + content_id=post_id ) session.add(association) await session.flush() @@ -70,7 +72,8 @@ async def remove_post_from_entry( association = await session.scalar( select(CalendarEntryPost).where( CalendarEntryPost.entry_id == entry_id, - CalendarEntryPost.post_id == post_id, + CalendarEntryPost.content_type == "post", + CalendarEntryPost.content_id == post_id, CalendarEntryPost.deleted_at.is_(None) ) ) diff --git a/bp/calendar_entry/services/ticket_operations.py b/bp/calendar_entry/services/ticket_operations.py index c949eb2..eb25a3d 100644 --- a/bp/calendar_entry/services/ticket_operations.py +++ b/bp/calendar_entry/services/ticket_operations.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import CalendarEntry + async def update_ticket_config( session: AsyncSession, entry_id: int, diff --git a/bp/calendars/routes.py b/bp/calendars/routes.py index 6c05695..ebae1f7 100644 --- a/bp/calendars/routes.py +++ b/bp/calendars/routes.py @@ -7,16 +7,17 @@ from sqlalchemy import select from models.calendars import Calendar + from .services.calendars import ( create_calendar as svc_create_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 suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.authz import require_admin +from shared.browser.app.utils.htmx import is_htmx_request def register(): @@ -78,7 +79,7 @@ def register(): cals = ( await g.s.execute( 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()) ) ).scalars().all() diff --git a/bp/calendars/services/calendars.py b/bp/calendars/services/calendars.py index 4b6fc62..445720a 100644 --- a/bp/calendars/services/calendars.py +++ b/bp/calendars/services/calendars.py @@ -4,7 +4,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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 re @@ -12,7 +12,7 @@ import re class CalendarError(ValueError): """Base error for calendar service operations.""" -from suma_browser.app.utils import ( +from shared.browser.app.utils import ( utcnow ) @@ -51,7 +51,8 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> cal = ( await sess.execute( select(Calendar) - .join(Post, Calendar.post_id == Post.id) + .join(Post, Calendar.container_id == Post.id) + .where(Calendar.container_type == "page") .where( Post.slug == post_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) 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() @@ -100,7 +101,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend return existing 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) await sess.flush() return cal diff --git a/bp/day/admin/routes.py b/bp/day/admin/routes.py index 4c1d04b..b14cfe7 100644 --- a/bp/day/admin/routes.py +++ b/bp/day/admin/routes.py @@ -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(): @@ -15,7 +15,7 @@ def register(): @bp.get("/") @require_admin 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 if not is_htmx_request(): diff --git a/bp/day/routes.py b/bp/day/routes.py index c92ed63..50202c3 100644 --- a/bp/day/routes.py +++ b/bp/day/routes.py @@ -5,17 +5,18 @@ from quart import ( 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 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 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(): diff --git a/bp/markets/routes.py b/bp/markets/routes.py index 86471dc..ee5f628 100644 --- a/bp/markets/routes.py +++ b/bp/markets/routes.py @@ -5,16 +5,16 @@ from quart import ( ) from sqlalchemy import select -from models.market_place import MarketPlace +from market.models.market_place import MarketPlace from .services.markets import ( create_market as svc_create_market, soft_delete as svc_soft_delete, ) -from suma_browser.app.redis_cacher import cache_page, clear_cache -from suma_browser.app.authz import require_admin -from suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.redis_cacher import cache_page, clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.utils.htmx import is_htmx_request def register(): diff --git a/bp/markets/services/markets.py b/bp/markets/services/markets.py index 269b895..76acd4d 100644 --- a/bp/markets/services/markets.py +++ b/bp/markets/services/markets.py @@ -6,9 +6,9 @@ import unicodedata from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from models.market_place import MarketPlace -from models.ghost_content import Post -from suma_browser.app.utils import utcnow +from market.models.market_place import MarketPlace +from blog.models.ghost_content import Post +from shared.browser.app.utils import utcnow 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) 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() if existing: @@ -58,7 +58,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl return existing 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) await sess.flush() return market @@ -68,7 +68,8 @@ async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> b market = ( await sess.execute( select(MarketPlace) - .join(Post, MarketPlace.post_id == Post.id) + .join(Post, MarketPlace.container_id == Post.id) + .where(MarketPlace.container_type == "page") .where( Post.slug == post_slug, MarketPlace.slug == market_slug, diff --git a/bp/payments/routes.py b/bp/payments/routes.py index 8820103..a5c6a84 100644 --- a/bp/payments/routes.py +++ b/bp/payments/routes.py @@ -5,10 +5,10 @@ from quart import ( ) 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 suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.authz import require_admin +from shared.browser.app.utils.htmx import is_htmx_request def register(): @@ -26,7 +26,7 @@ def register(): return {} 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() return { @@ -55,10 +55,10 @@ def register(): return await make_response("Post not found", 404) 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() 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) await g.s.flush() diff --git a/bp/slot/routes.py b/bp/slot/routes.py index 356a7df..d3011fd 100644 --- a/bp/slot/routes.py +++ b/bp/slot/routes.py @@ -6,8 +6,8 @@ from quart import ( from sqlalchemy.exc import IntegrityError -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache from .services.slot import ( update_slot as svc_update_slot, @@ -19,11 +19,11 @@ from ..slots.services.slots import ( list_slots as svc_list_slots, ) -from suma_browser.app.utils import ( +from shared.browser.app.utils import ( parse_time, parse_cost ) -from suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.utils.htmx import is_htmx_request def register(): diff --git a/bp/slot/services/slot.py b/bp/slot/services/slot.py index 5069c97..169facd 100644 --- a/bp/slot/services/slot.py +++ b/bp/slot/services/slot.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import CalendarSlot + class SlotError(ValueError): pass diff --git a/bp/slots/routes.py b/bp/slots/routes.py index 5e72718..cd655cb 100644 --- a/bp/slots/routes.py +++ b/bp/slots/routes.py @@ -5,8 +5,8 @@ from quart import ( ) from sqlalchemy.exc import IntegrityError -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache from .services.slots import ( list_slots as svc_list_slots, @@ -15,11 +15,11 @@ from .services.slots import ( from ..slot.routes import register as register_slot -from suma_browser.app.utils import ( +from shared.browser.app.utils import ( parse_time, parse_cost ) -from suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.utils.htmx import is_htmx_request def register(): diff --git a/bp/slots/services/slots.py b/bp/slots/services/slots.py index bc130f9..bd9827f 100644 --- a/bp/slots/services/slots.py +++ b/bp/slots/services/slots.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import CalendarSlot + class SlotError(ValueError): pass diff --git a/bp/ticket_admin/routes.py b/bp/ticket_admin/routes.py index 7f3905c..3168a29 100644 --- a/bp/ticket_admin/routes.py +++ b/bp/ticket_admin/routes.py @@ -18,8 +18,8 @@ from sqlalchemy import select, func from sqlalchemy.orm import selectinload from models.calendars import CalendarEntry, Ticket, TicketType -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache from ..tickets.services.tickets import ( get_ticket_by_code, @@ -37,7 +37,7 @@ def register() -> Blueprint: @require_admin async def dashboard(): """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 result = await g.s.execute( @@ -89,7 +89,7 @@ def register() -> Blueprint: @require_admin async def entry_tickets(entry_id: int): """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( select(CalendarEntry) diff --git a/bp/ticket_type/routes.py b/bp/ticket_type/routes.py index 552af5c..8f807b3 100644 --- a/bp/ticket_type/routes.py +++ b/bp/ticket_type/routes.py @@ -4,8 +4,8 @@ from quart import ( request, render_template, make_response, Blueprint, g, jsonify ) -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache from .services.ticket import ( get_ticket_type as svc_get_ticket_type, @@ -16,7 +16,7 @@ from .services.ticket import ( from ..ticket_types.services.tickets import ( 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(): diff --git a/bp/ticket_type/services/ticket.py b/bp/ticket_type/services/ticket.py index fb87002..b53a657 100644 --- a/bp/ticket_type/services/ticket.py +++ b/bp/ticket_type/services/ticket.py @@ -4,6 +4,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import TicketType + from datetime import datetime, timezone diff --git a/bp/ticket_types/routes.py b/bp/ticket_types/routes.py index a37b73f..0041eb1 100644 --- a/bp/ticket_types/routes.py +++ b/bp/ticket_types/routes.py @@ -4,8 +4,8 @@ from quart import ( request, render_template, make_response, Blueprint, g, jsonify ) -from suma_browser.app.authz import require_admin -from suma_browser.app.redis_cacher import clear_cache +from shared.browser.app.authz import require_admin +from shared.browser.app.redis_cacher import clear_cache from .services.tickets import ( 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 suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.utils.htmx import is_htmx_request def register(): diff --git a/bp/ticket_types/services/tickets.py b/bp/ticket_types/services/tickets.py index c848704..0be361e 100644 --- a/bp/ticket_types/services/tickets.py +++ b/bp/ticket_types/services/tickets.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from models.calendars import TicketType + from datetime import datetime, timezone diff --git a/bp/tickets/routes.py b/bp/tickets/routes.py index 118203c..ecedeae 100644 --- a/bp/tickets/routes.py +++ b/bp/tickets/routes.py @@ -17,8 +17,8 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from models.calendars import CalendarEntry -from shared.cart_identity import current_cart_identity -from suma_browser.app.redis_cacher import clear_cache +from shared.infrastructure.cart_identity import current_cart_identity +from shared.browser.app.redis_cacher import clear_cache from .services.tickets import ( create_ticket, @@ -37,7 +37,7 @@ def register() -> Blueprint: @bp.get("/") async def my_tickets(): """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() tickets = await get_user_tickets( @@ -62,7 +62,7 @@ def register() -> Blueprint: @bp.get("//") async def ticket_detail(code: str): """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) if not ticket: diff --git a/bp/tickets/services/tickets.py b/bp/tickets/services/tickets.py index b83bdca..3f398d2 100644 --- a/bp/tickets/services/tickets.py +++ b/bp/tickets/services/tickets.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import selectinload from models.calendars import Ticket, TicketType, CalendarEntry + async def create_ticket( session: AsyncSession, *, diff --git a/config/app-config.yaml b/config/app-config.yaml new file mode 100644 index 0000000..227cc2e --- /dev/null +++ b/config/app-config.yaml @@ -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-' + diff --git a/events_api.py b/events_api.py index 777777a..2e93186 100644 --- a/events_api.py +++ b/events_api.py @@ -11,7 +11,7 @@ from sqlalchemy import select, update, func from sqlalchemy.orm import selectinload 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: diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..4006b10 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from .calendars import ( + Calendar, CalendarEntry, CalendarSlot, + TicketType, Ticket, CalendarEntryPost, +) diff --git a/models/calendars.py b/models/calendars.py new file mode 100644 index 0000000..90f8767 --- /dev/null +++ b/models/calendars.py @@ -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"] diff --git a/path_setup.py b/path_setup.py index 1d4c9ab..c7166f7 100644 --- a/path_setup.py +++ b/path_setup.py @@ -1,7 +1,9 @@ import sys import os -# Add the shared library submodule to the Python path -_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib") -if _shared not in sys.path: - sys.path.insert(0, _shared) +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p)