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

Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Cart-owned models in cart/models/ (order, page_config)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- PageConfig uses container_type/container_id instead of post_id FK

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:46:34 +00:00
parent 8ce8fc5380
commit 5d0653bf2e
25 changed files with 345 additions and 84 deletions

0
__init__.py Normal file
View File

19
app.py
View File

@@ -7,22 +7,22 @@ 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 (
from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_cart_api,
register_orders,
)
from suma_browser.app.bp.cart.services import (
from bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
)
from suma_browser.app.bp.cart.services.page_cart import (
from bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
)
@@ -45,8 +45,8 @@ async def cart_context() -> dict:
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
"""
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()
@@ -83,7 +83,7 @@ async def cart_context() -> dict:
def create_app() -> "Quart":
from models.ghost_content import Post
from blog.models.ghost_content import Post
from models.page_config import PageConfig
app = create_base_app(
@@ -128,7 +128,10 @@ def create_app() -> "Quart":
g.page_post = post
g.page_config = (
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()

View File

@@ -10,12 +10,12 @@ from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload
from models.market import CartItem
from models.market_place import MarketPlace
from models.calendars import CalendarEntry, Calendar
from models.ghost_content import Post
from suma_browser.app.csrf import csrf_exempt
from shared.cart_identity import current_cart_identity
from market.models.market import CartItem
from market.models.market_place import MarketPlace
from events.models.calendars import CalendarEntry, Calendar
from blog.models.ghost_content import Post
from shared.browser.app.csrf import csrf_exempt
from shared.infrastructure.cart_identity import current_cart_identity
def register() -> Blueprint:
@@ -55,7 +55,8 @@ def register() -> Blueprint:
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.post_id == page_post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
@@ -84,7 +85,8 @@ def register() -> Blueprint:
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.post_id == page_post_id,
Calendar.container_type == "page",
Calendar.container_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
cal_q = cal_q.where(CalendarEntry.calendar_id.in_(cal_ids))

View File

@@ -6,7 +6,7 @@ from quart import Blueprint, g, request, render_template, redirect, url_for, mak
from sqlalchemy import select
from models.order import Order
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
from .services import (
current_cart_identity,
get_cart,
@@ -26,8 +26,8 @@ from .services.checkout import (
validate_webhook_secret,
get_order_with_details,
)
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
def register(url_prefix: str) -> Blueprint:

View File

@@ -6,7 +6,7 @@ from quart import g, session as qsession
from sqlalchemy import select
from typing import Optional
from models.market import CartItem
from market.models.market import CartItem
async def merge_anonymous_cart_into_user(user_id: int) -> None:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from quart import Blueprint, render_template, make_response
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
from .services import get_cart_grouped_by_page

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from .services import (
total,
clear_cart_for_order,

View File

@@ -6,9 +6,9 @@ from quart import Blueprint, g, request, render_template, redirect, url_for, mak
from sqlalchemy import select, update
from sqlalchemy.orm import selectinload
from models.market import Product, CartItem
from market.models.market import Product, CartItem
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from .services import (
current_cart_identity,
get_cart,
@@ -28,9 +28,9 @@ from .services.checkout import (
validate_webhook_secret,
get_order_with_details,
)
from config import config
from models.calendars import CalendarEntry # NEW
from suma_browser.app.utils.htmx import is_htmx_request
from shared.config import config
from events.models.calendars import CalendarEntry # NEW
from shared.browser.app.utils.htmx import is_htmx_request
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart", __name__, url_prefix=url_prefix)

View File

@@ -1,6 +1,6 @@
from sqlalchemy import select, update, func
from models.market import CartItem
from market.models.market import CartItem
async def adopt_session_cart_for_user(session, user_id: int, session_id: str | None) -> None:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from events.models.calendars import CalendarEntry
from .identity import current_cart_identity

View File

@@ -1,6 +1,6 @@
from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from sqlalchemy import update
from models.calendars import CalendarEntry
from events.models.calendars import CalendarEntry
async def check_sumup_status(session, order):

View File

@@ -7,12 +7,12 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.market import Product, CartItem
from market.models.market import Product, CartItem
from models.order import Order, OrderItem
from models.calendars import CalendarEntry, Calendar
from events.models.calendars import CalendarEntry, Calendar
from models.page_config import PageConfig
from models.market_place import MarketPlace
from config import config
from market.models.market_place import MarketPlace
from shared.config import config
async def find_or_create_cart_item(
@@ -76,13 +76,13 @@ async def resolve_page_config(
if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id)
if mp:
post_ids.add(mp.post_id)
post_ids.add(mp.container_id)
# From calendar entries via calendar
for entry in calendar_entries:
cal = await session.get(Calendar, entry.calendar_id)
if cal and cal.post_id:
post_ids.add(cal.post_id)
if cal and cal.container_id:
post_ids.add(cal.container_id)
if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages")
@@ -92,7 +92,10 @@ async def resolve_page_config(
post_id = post_ids.pop()
pc = (await session.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 pc
@@ -158,7 +161,8 @@ async def create_order_from_cart(
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.post_id == page_post_id,
Calendar.container_type == "page",
Calendar.container_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
calendar_filters.append(CalendarEntry.calendar_id.in_(cal_ids))

View File

@@ -1,7 +1,7 @@
from sqlalchemy import update, func, select
from models.market import CartItem
from models.market_place import MarketPlace
from market.models.market import CartItem
from market.models.market_place import MarketPlace
from models.order import Order
@@ -24,7 +24,8 @@ async def clear_cart_for_order(session, order: Order, *, page_post_id: int | Non
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.post_id == page_post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
filters.append(CartItem.market_place_id.in_(mp_ids))

View File

@@ -1,7 +1,7 @@
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.market import CartItem
from market.models.market import CartItem
from .identity import current_cart_identity
async def get_cart(session):

View File

@@ -1,4 +1,4 @@
# Re-export from canonical shared location
from shared.cart_identity import CartIdentity, current_cart_identity
from shared.infrastructure.cart_identity import CartIdentity, current_cart_identity
__all__ = ["CartIdentity", "current_cart_identity"]

View File

@@ -2,7 +2,8 @@
Page-scoped cart queries.
Groups cart items and calendar entries by their owning page (Post),
determined via CartItem.market_place.post_id and CalendarEntry.calendar.post_id.
determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id
(where container_type == "page").
"""
from __future__ import annotations
@@ -11,21 +12,22 @@ from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.market import CartItem
from models.market_place import MarketPlace
from models.calendars import CalendarEntry, Calendar
from models.ghost_content import Post
from market.models.market import CartItem
from market.models.market_place import MarketPlace
from events.models.calendars import CalendarEntry, Calendar
from blog.models.ghost_content import Post
from models.page_config import PageConfig
from .identity import current_cart_identity
async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
"""Return cart items scoped to a specific page (via MarketPlace.post_id)."""
"""Return cart items scoped to a specific page (via MarketPlace.container_id)."""
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
MarketPlace.post_id == post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
]
if ident["user_id"] is not None:
@@ -47,13 +49,14 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
async def get_calendar_entries_for_page(session, post_id: int) -> list[CalendarEntry]:
"""Return pending calendar entries scoped to a specific page (via Calendar.post_id)."""
"""Return pending calendar entries scoped to a specific page (via Calendar.container_id)."""
ident = current_cart_identity()
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
Calendar.post_id == post_id,
Calendar.container_type == "page",
Calendar.container_id == post_id,
Calendar.deleted_at.is_(None),
]
if ident["user_id"] is not None:
@@ -99,7 +102,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session)
# Group by post_id
# Group by container_id (all current data has container_type="page")
groups: dict[int | None, dict] = defaultdict(lambda: {
"post_id": None,
"cart_items": [],
@@ -107,16 +110,16 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
})
for ci in cart_items:
if ci.market_place and ci.market_place.post_id:
pid = ci.market_place.post_id
if ci.market_place and ci.market_place.container_id:
pid = ci.market_place.container_id
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["cart_items"].append(ci)
for ce in cal_entries:
if ce.calendar and ce.calendar.post_id:
pid = ce.calendar.post_id
if ce.calendar and ce.calendar.container_id:
pid = ce.calendar.container_id
else:
pid = None
groups[pid]["post_id"] = pid
@@ -135,10 +138,13 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
posts_by_id[p.id] = p
pc_result = await session.execute(
select(PageConfig).where(PageConfig.post_id.in_(post_ids))
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id.in_(post_ids),
)
)
for pc in pc_result.scalars().all():
configs_by_post[pc.post_id] = pc
configs_by_post[pc.container_id] = pc
# Build result list (pages first, orphan last)
result = []

View File

@@ -3,8 +3,8 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import KEEP, build_qs
from suma_browser.app.filters.query_types import OrderQuery
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:

View File

@@ -5,14 +5,14 @@ from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from models.market import Product
from market.models.market import Product
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from suma_browser.app.bp.cart.services import check_sumup_status
from suma_browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from .filters.qs import makeqs_factory, decode

View File

@@ -3,8 +3,8 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import KEEP, build_qs
from suma_browser.app.filters.query_types import OrderQuery
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:

View File

@@ -5,15 +5,15 @@ from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from models.market import Product
from market.models.market import Product
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from suma_browser.app.bp.cart.services import check_sumup_status
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.bp import register_order
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from bp import register_order
from .filters.qs import makeqs_factory, decode

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

2
models/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .order import Order, OrderItem
from .page_config import PageConfig

119
models/order.py Normal file
View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional, List
from sqlalchemy import Integer, String, DateTime, ForeignKey, Numeric, func, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from shared.db.base import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
session_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
page_config_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("page_configs.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="pending",
server_default="pending",
)
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="GBP")
total_amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
# free-form description for the order
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, index=True)
# SumUp reference string (what we send as checkout_reference)
sumup_reference: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
index=True,
)
# SumUp integration fields
sumup_checkout_id: Mapped[Optional[str]] = mapped_column(
String(128),
nullable=True,
index=True,
)
sumup_status: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
sumup_hosted_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
items: Mapped[List["OrderItem"]] = relationship(
"OrderItem",
back_populates="order",
cascade="all, delete-orphan",
lazy="selectin",
)
calendar_entries: Mapped[List["CalendarEntry"]] = relationship(
"CalendarEntry",
back_populates="order",
lazy="selectin",
)
page_config: Mapped[Optional["PageConfig"]] = relationship(
"PageConfig",
foreign_keys=[page_config_id],
lazy="selectin",
)
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(
ForeignKey("orders.id", ondelete="CASCADE"),
nullable=False,
)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id"),
nullable=False,
)
product_title: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="GBP")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
order: Mapped["Order"] = relationship(
"Order",
back_populates="items",
)
# NEW: link each order item to its product
product: Mapped["Product"] = relationship(
"Product",
back_populates="order_items",
lazy="selectin",
)

39
models/page_config.py Normal file
View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, String, Text, DateTime, func, JSON, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from shared.db.base import Base
class PageConfig(Base):
__tablename__ = "page_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
container_type: Mapped[str] = mapped_column(
String(32), nullable=False, server_default=text("'page'"),
)
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
features: Mapped[dict] = mapped_column(
JSON, nullable=False, server_default="{}"
)
# Per-page SumUp credentials (NULL until configured)
sumup_merchant_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
sumup_api_key: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
sumup_checkout_prefix: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

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