Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s

Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

6
cart/bp/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .order.routes import register as register_order
from .orders.routes import register as register_orders
from .fragments import register_fragments

View File

@@ -0,0 +1,294 @@
# bp/cart/global_routes.py — Global cart routes (webhook, return, add)
from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select
from shared.models.market import CartItem
from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.services.registry import services
from .services import (
current_cart_identity,
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
check_sumup_status,
)
from .services.checkout import (
find_or_create_cart_item,
create_order_from_cart,
resolve_page_config,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
validate_webhook_secret,
get_order_with_details,
)
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.browser.app.csrf import csrf_exempt
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_global", __name__, url_prefix=url_prefix)
@bp.post("/add/<int:product_id>/")
async def add_to_cart(product_id: int):
ident = current_cart_identity()
cart_item = await find_or_create_cart_item(
g.s,
product_id,
ident["user_id"],
ident["session_id"],
)
if not cart_item:
return await make_response("Product not found", 404)
if request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX
return redirect(url_for("cart_overview.overview"))
return redirect(url_for("cart_overview.overview"))
@bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int):
ident = current_cart_identity()
form = await request.form
count = int(form.get("count", 0))
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
existing = await g.s.scalar(select(CartItem).where(*filters))
if existing:
existing.quantity = max(count, 0)
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/ticket-quantity/")
async def update_ticket_quantity():
"""Adjust reserved ticket count (+/- pattern, like products)."""
ident = current_cart_identity()
form = await request.form
entry_id = int(form.get("entry_id", 0))
count = max(int(form.get("count", 0)), 0)
tt_raw = (form.get("ticket_type_id") or "").strip()
ticket_type_id = int(tt_raw) if tt_raw else None
await services.calendar.adjust_ticket_quantity(
g.s, entry_id, count,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/delete/<int:product_id>/")
async def delete_item(product_id: int):
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
existing = await g.s.scalar(select(CartItem).where(*filters))
if existing:
await g.s.delete(existing)
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/checkout/")
async def checkout():
"""Legacy global checkout (for orphan items without page scope)."""
cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s)
tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.overview"))
product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0
ticket_amount = ticket_total(tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("cart_overview.overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e:
html = await render_template(
"_types/cart/checkout_error.html",
order=None,
error=str(e),
)
return await make_response(html, 400)
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
calendar_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
)
if page_config:
order.page_config_id = page_config.id
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
error="No hosted checkout URL returned from SumUp.",
)
return await make_response(html, 500)
return redirect(hosted_url)
@csrf_exempt
@bp.post("/checkout/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int):
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
if not validate_webhook_secret(request.args.get("token")):
return "", 204
try:
payload = await request.get_json()
except Exception:
payload = None
if not isinstance(payload, dict):
return "", 204
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
return "", 204
checkout_id = payload.get("id")
if not checkout_id:
return "", 204
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return "", 204
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
return "", 204
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return "", 204
@bp.get("/checkout/return/<int:order_id>/")
async def checkout_return(order_id: int):
"""Handle the browser returning from SumUp after payment."""
order = await get_order_with_details(g.s, order_id)
if not order:
html = await render_template(
"_types/cart/checkout_return.html",
order=None,
status="missing",
calendar_entries=[],
)
return await make_response(html)
# Resolve page/market slugs so product links render correctly
if order.page_config:
post = await services.blog.get_post_by_id(g.s, order.page_config.container_id)
if post:
g.page_slug = post.slug
result = await g.s.execute(
select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post.id,
MarketPlace.deleted_at.is_(None),
).limit(1)
)
mp = result.scalar_one_or_none()
if mp:
g.market_slug = mp.slug
if order.sumup_checkout_id:
try:
await check_sumup_status(g.s, order)
except Exception:
pass
status = (order.status or "pending").lower()
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id)
await g.s.flush()
html = await render_template(
"_types/cart/checkout_return.html",
order=order,
status=status,
calendar_entries=calendar_entries,
order_tickets=order_tickets,
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,31 @@
# bp/cart/overview_routes.py — Cart overview (list of page carts)
from __future__ import annotations
from quart import Blueprint, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from .services import get_cart_grouped_by_page
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.get("/")
async def overview():
from quart import g
page_groups = await get_cart_grouped_by_page(g.s)
if not is_htmx_request():
html = await render_template(
"_types/cart/overview/index.html",
page_groups=page_groups,
)
else:
html = await render_template(
"_types/cart/overview/_oob_elements.html",
page_groups=page_groups,
)
return await make_response(html)
return bp

129
cart/bp/cart/page_routes.py Normal file
View File

@@ -0,0 +1,129 @@
# bp/cart/page_routes.py — Per-page cart (view + checkout)
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for
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,
calendar_total,
ticket_total,
)
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
from .services.ticket_groups import group_tickets
from .services.checkout import (
create_order_from_cart,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
)
from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.get("/")
async def page_view():
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
tpl_ctx = dict(
page_post=post,
page_config=getattr(g, "page_config", None),
cart=cart,
calendar_cart_entries=cal_entries,
ticket_cart_entries=page_tickets,
ticket_groups=ticket_groups,
total=total,
calendar_total=calendar_total,
ticket_total=ticket_total,
)
if not is_htmx_request():
html = await render_template("_types/cart/page/index.html", **tpl_ctx)
else:
html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx)
return await make_response(html)
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
page_config = getattr(g, "page_config", None)
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
ticket_amount = ticket_total(page_tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.page_view"))
# Create order scoped to this page
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
cal_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
page_post_id=post.id,
)
# Set page_config on order
if page_config:
order.page_config_id = page_config.id
# Build SumUp checkout details — webhook/return use global routes
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
error="No hosted checkout URL returned from SumUp.",
)
return await make_response(html, 500)
return redirect(hosted_url)
return bp

View File

@@ -0,0 +1,13 @@
from .get_cart import get_cart
from .identity import current_cart_identity
from .total import total
from .clear_cart_for_order import clear_cart_for_order
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
from .check_sumup_status import check_sumup_status
from .page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_tickets_for_page,
get_cart_grouped_by_page,
)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from decimal import Decimal
from shared.services.registry import services
from .identity import current_cart_identity
async def get_calendar_cart_entries(session):
"""
Return all *pending* calendar entries (as CalendarEntryDTOs) for the
current cart identity (user or anonymous session).
"""
ident = current_cart_identity()
return await services.calendar.pending_entries(
session,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
def calendar_total(entries) -> Decimal:
"""
Total cost of pending calendar entries.
"""
return sum(
(Decimal(str(e.cost)) if e.cost else Decimal(0))
for e in entries
if e.cost is not None
)
async def get_ticket_cart_entries(session):
"""Return all reserved tickets (as TicketDTOs) for the current identity."""
ident = current_cart_identity()
return await services.calendar.pending_tickets(
session,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
def ticket_total(tickets) -> Decimal:
"""Total cost of reserved tickets."""
return sum((Decimal(str(t.price)) if t.price else Decimal(0) for t in tickets), Decimal(0))

View File

@@ -0,0 +1,43 @@
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_activity
from shared.services.registry import services
from .clear_cart_for_order import clear_cart_for_order
async def check_sumup_status(session, order):
# Use order's page_config for per-page SumUp credentials
page_config = getattr(order, "page_config", None)
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
order.sumup_status = checkout_data.get("status") or order.sumup_status
sumup_status = (order.sumup_status or "").upper()
if sumup_status == "PAID":
if order.status != "paid":
order.status = "paid"
await services.calendar.confirm_entries_for_order(
session, order.id, order.user_id, order.session_id
)
await services.calendar.confirm_tickets_for_order(session, order.id)
# Clear cart only after payment is confirmed
page_post_id = page_config.container_id if page_config else None
await clear_cart_for_order(session, order, page_post_id=page_post_id)
await emit_activity(
session,
activity_type="rose:OrderPaid",
actor_uri="internal:cart",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": order.user_id,
},
source_type="order",
source_id=order.id,
)
elif sumup_status == "FAILED":
order.status = "failed"
else:
order.status = sumup_status.lower() or order.status
await session.flush()

View File

@@ -0,0 +1,248 @@
from __future__ import annotations
from typing import Optional
from urllib.parse import urlencode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models.market import Product, CartItem
from shared.models.order import Order, OrderItem
from shared.models.page_config import PageConfig
from shared.models.market_place import MarketPlace
from shared.config import config
from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_activity
from shared.services.registry import services
async def find_or_create_cart_item(
session: AsyncSession,
product_id: int,
user_id: Optional[int],
session_id: Optional[str],
) -> Optional[CartItem]:
"""
Find an existing cart item for this product/identity, or create a new one.
Returns None if the product doesn't exist.
Increments quantity if item already exists.
"""
# Make sure product exists
product = await session.scalar(
select(Product).where(Product.id == product_id)
)
if not product:
return None
# Look for existing cart item
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
else:
filters.append(CartItem.session_id == session_id)
existing = await session.scalar(select(CartItem).where(*filters))
if existing:
existing.quantity += 1
return existing
else:
cart_item = CartItem(
user_id=user_id,
session_id=session_id,
product_id=product.id,
quantity=1,
)
session.add(cart_item)
return cart_item
async def resolve_page_config(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntryDTO],
tickets=None,
) -> Optional["PageConfig"]:
"""Determine the PageConfig for this order.
Returns PageConfig or None (use global credentials).
Raises ValueError if items span multiple pages.
"""
post_ids: set[int] = set()
# From cart items via market_place
for ci in cart:
if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id)
if mp:
post_ids.add(mp.container_id)
# From calendar entries via calendar
for entry in calendar_entries:
if entry.calendar_container_id:
post_ids.add(entry.calendar_container_id)
# From tickets via calendar_container_id
for tk in (tickets or []):
if tk.calendar_container_id:
post_ids.add(tk.calendar_container_id)
if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages")
if not post_ids:
return None # global credentials
post_id = post_ids.pop()
pc = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post_id,
)
)).scalar_one_or_none()
return pc
async def create_order_from_cart(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntryDTO],
user_id: Optional[int],
session_id: Optional[str],
product_total: float,
calendar_total: float,
*,
ticket_total: float = 0,
page_post_id: int | None = None,
) -> Order:
"""
Create an Order and OrderItems from the current cart + calendar entries + tickets.
When *page_post_id* is given, only calendar entries/tickets whose calendar
belongs to that page are marked as "ordered". Otherwise all pending
entries are updated (legacy behaviour).
"""
cart_total = product_total + calendar_total + ticket_total
# Determine currency from first product
first_product = cart[0].product if cart else None
currency = (first_product.regular_price_currency if first_product else None) or "GBP"
# Create order
order = Order(
user_id=user_id,
session_id=session_id,
status="pending",
currency=currency,
total_amount=cart_total,
)
session.add(order)
await session.flush()
# Create order items from cart
for ci in cart:
price = ci.product.special_price or ci.product.regular_price or 0
oi = OrderItem(
order=order,
product_id=ci.product.id,
product_title=ci.product.title,
quantity=ci.quantity,
unit_price=price,
currency=currency,
)
session.add(oi)
# Mark pending calendar entries as "ordered" via calendar service
await services.calendar.claim_entries_for_order(
session, order.id, user_id, session_id, page_post_id
)
# Claim reserved tickets for this order
await services.calendar.claim_tickets_for_order(
session, order.id, user_id, session_id, page_post_id
)
await emit_activity(
session,
activity_type="Create",
actor_uri="internal:cart",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
},
source_type="order",
source_id=order.id,
)
return order
def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str:
"""Build a human-readable description for SumUp checkout."""
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
item_count = sum(ci.quantity for ci in cart)
parts = []
if titles:
if len(titles) <= 3:
parts.append(", ".join(titles))
else:
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
if ticket_count:
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
summary = ", ".join(parts) if parts else "order items"
total_count = item_count + ticket_count
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
def build_sumup_reference(order_id: int, page_config=None) -> str:
"""Build a SumUp reference with configured prefix."""
if page_config and page_config.sumup_checkout_prefix:
prefix = page_config.sumup_checkout_prefix
else:
sumup_cfg = config().get("sumup", {}) or {}
prefix = sumup_cfg.get("checkout_reference_prefix", "")
return f"{prefix}{order_id}"
def build_webhook_url(base_url: str) -> str:
"""Add webhook secret token to URL if configured."""
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if webhook_secret:
sep = "&" if "?" in base_url else "?"
return f"{base_url}{sep}{urlencode({'token': webhook_secret})}"
return base_url
def validate_webhook_secret(token: Optional[str]) -> bool:
"""Validate webhook token against configured secret."""
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if not webhook_secret:
return True # No secret configured, allow all
return token is not None and token == webhook_secret
async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]:
"""Fetch an order with items and calendar entries eagerly loaded."""
result = await session.execute(
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
)
.where(Order.id == order_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,37 @@
from sqlalchemy import update, func, select
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.order import Order
async def clear_cart_for_order(session, order: Order, *, page_post_id: int | None = None) -> None:
"""
Soft-delete CartItem rows belonging to this order's user_id/session_id.
When *page_post_id* is given, only items whose market_place belongs to
that page are cleared. Otherwise all items are cleared (legacy behaviour).
"""
filters = [CartItem.deleted_at.is_(None)]
if order.user_id is not None:
filters.append(CartItem.user_id == order.user_id)
if order.session_id is not None:
filters.append(CartItem.session_id == order.session_id)
if len(filters) == 1:
# no user_id/session_id on order nothing to clear
return
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
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))
await session.execute(
update(CartItem)
.where(*filters)
.values(deleted_at=func.now())
)

View File

@@ -0,0 +1,25 @@
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from .identity import current_cart_identity
async def get_cart(session):
ident = current_cart_identity()
filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()

View File

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

View File

@@ -0,0 +1,212 @@
"""
Page-scoped cart queries.
Groups cart items and calendar entries by their owning page (Post),
determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id
(where container_type == "page").
"""
from __future__ import annotations
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.page_config import PageConfig
from shared.services.registry import services
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.container_id)."""
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.join(MarketPlace, CartItem.market_place_id == MarketPlace.id)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()
async def get_calendar_entries_for_page(session, post_id: int):
"""Return pending calendar entries (DTOs) scoped to a specific page."""
ident = current_cart_identity()
return await services.calendar.entries_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
async def get_tickets_for_page(session, post_id: int):
"""Return reserved tickets (DTOs) scoped to a specific page."""
ident = current_cart_identity()
return await services.calendar.tickets_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
async def get_cart_grouped_by_page(session) -> list[dict]:
"""
Load all cart items + calendar entries for the current identity,
grouped by market_place (one card per market).
Returns a list of dicts:
{
"post": Post | None,
"page_config": PageConfig | None,
"market_place": MarketPlace | None,
"cart_items": [...],
"calendar_entries": [...],
"product_count": int,
"product_total": float,
"calendar_count": int,
"calendar_total": float,
"total": float,
}
Calendar entries (no market concept) attach to a page-level group.
Items without a market_place go in an orphan bucket (post=None).
"""
from .get_cart import get_cart
from .calendar_cart import get_calendar_cart_entries, get_ticket_cart_entries
from .total import total as calc_product_total
from .calendar_cart import calendar_total as calc_calendar_total, ticket_total as calc_ticket_total
cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session)
all_tickets = await get_ticket_cart_entries(session)
# Group cart items by market_place_id
market_groups: dict[int | None, dict] = {}
for ci in cart_items:
mp_id = ci.market_place_id if ci.market_place else None
if mp_id not in market_groups:
market_groups[mp_id] = {
"market_place": ci.market_place,
"post_id": ci.market_place.container_id if ci.market_place else None,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
market_groups[mp_id]["cart_items"].append(ci)
# Attach calendar entries to an existing market group for the same page,
# or create a page-level group if no market group exists for that page.
page_to_market: dict[int | None, int | None] = {}
for mp_id, grp in market_groups.items():
pid = grp["post_id"]
if pid is not None and pid not in page_to_market:
page_to_market[pid] = mp_id
for ce in cal_entries:
pid = ce.calendar_container_id or None
if pid in page_to_market:
market_groups[page_to_market[pid]]["calendar_entries"].append(ce)
else:
# Create a page-level group for calendar-only entries
key = ("cal", pid)
if key not in market_groups:
market_groups[key] = {
"market_place": None,
"post_id": pid,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
if pid is not None:
page_to_market[pid] = key
market_groups[key]["calendar_entries"].append(ce)
# Attach tickets to page groups (via calendar_container_id)
for tk in all_tickets:
pid = tk.calendar_container_id or None
if pid in page_to_market:
market_groups[page_to_market[pid]]["tickets"].append(tk)
else:
key = ("tk", pid)
if key not in market_groups:
market_groups[key] = {
"market_place": None,
"post_id": pid,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
if pid is not None:
page_to_market[pid] = key
market_groups[key]["tickets"].append(tk)
# Batch-load Post DTOs and PageConfig objects
post_ids = list({
grp["post_id"] for grp in market_groups.values()
if grp["post_id"] is not None
})
posts_by_id: dict[int, object] = {}
configs_by_post: dict[int, PageConfig] = {}
if post_ids:
for p in await services.blog.get_posts_by_ids(session, post_ids):
posts_by_id[p.id] = p
pc_result = await session.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id.in_(post_ids),
)
)
for pc in pc_result.scalars().all():
configs_by_post[pc.container_id] = pc
# Build result list (markets with pages first, orphan last)
result = []
for _key, grp in sorted(
market_groups.items(),
key=lambda kv: (kv[1]["post_id"] is None, kv[1]["post_id"] or 0),
):
items = grp["cart_items"]
entries = grp["calendar_entries"]
tks = grp["tickets"]
prod_total = calc_product_total(items) or 0
cal_total = calc_calendar_total(entries) or 0
tk_total = calc_ticket_total(tks) or 0
pid = grp["post_id"]
result.append({
"post": posts_by_id.get(pid) if pid else None,
"page_config": configs_by_post.get(pid) if pid else None,
"market_place": grp["market_place"],
"cart_items": items,
"calendar_entries": entries,
"tickets": tks,
"product_count": sum(ci.quantity for ci in items),
"product_total": prod_total,
"calendar_count": len(entries),
"calendar_total": cal_total,
"ticket_count": len(tks),
"ticket_total": tk_total,
"total": prod_total + cal_total + tk_total,
})
return result

View File

@@ -0,0 +1,43 @@
"""Group individual TicketDTOs by (entry_id, ticket_type_id) for cart display."""
from __future__ import annotations
from collections import OrderedDict
def group_tickets(tickets) -> list[dict]:
"""
Group a flat list of TicketDTOs into aggregate rows.
Returns list of dicts:
{
"entry_id": int,
"entry_name": str,
"entry_start_at": datetime,
"entry_end_at": datetime | None,
"ticket_type_id": int | None,
"ticket_type_name": str | None,
"price": Decimal | None,
"quantity": int,
"line_total": float,
}
"""
groups: OrderedDict[tuple, dict] = OrderedDict()
for tk in tickets:
key = (tk.entry_id, getattr(tk, "ticket_type_id", None))
if key not in groups:
groups[key] = {
"entry_id": tk.entry_id,
"entry_name": tk.entry_name,
"entry_start_at": tk.entry_start_at,
"entry_end_at": tk.entry_end_at,
"ticket_type_id": getattr(tk, "ticket_type_id", None),
"ticket_type_name": tk.ticket_type_name,
"price": tk.price,
"quantity": 0,
"line_total": 0,
}
groups[key]["quantity"] += 1
groups[key]["line_total"] += float(tk.price or 0)
return list(groups.values())

View File

@@ -0,0 +1,13 @@
from decimal import Decimal
def total(cart):
return sum(
(
Decimal(str(item.product.special_price or item.product.regular_price))
* item.quantity
)
for item in cart
if (item.product.special_price or item.product.regular_price) is not None
)

View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

View File

@@ -0,0 +1,70 @@
"""Cart app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Fragments:
cart-mini Cart icon with badge (or logo when empty)
account-nav-item "orders" link for account dashboard
"""
from __future__ import annotations
from quart import Blueprint, Response, request, render_template, g
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# ---------------------------------------------------------------
# Fragment handlers
# ---------------------------------------------------------------
async def _cart_mini():
from shared.services.registry import services
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id,
)
count = summary.count + summary.calendar_count + summary.ticket_count
return await render_template("fragments/cart_mini.html", cart_count=count)
async def _account_nav_item():
from shared.infrastructure.urls import cart_url
href = cart_url("/orders/")
return (
'<div class="relative nav-group">'
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
'orders</a></div>'
)
_handlers = {
"cart-mini": _cart_mini,
"account-nav-item": _account_nav_item,
}
# ---------------------------------------------------------------
# Routing
# ---------------------------------------------------------------
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
return bp

View File

@@ -0,0 +1,74 @@
# suma_browser/app/bp/order/filters/qs.py
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:
"""
Decode current query string into an OrderQuery(page, search).
"""
try:
page = int(request.args.get("page", 1) or 1)
except ValueError:
page = 1
search = request.args.get("search") or None
return OrderQuery(page, search)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Behaviour:
- If filters change and you don't explicitly pass page,
the page is reset to 1 (same pattern as browse/blog).
- You can clear search with search=None.
"""
q = decode()
base_search = q.search or None
base_page = int(q.page or 1)
def makeqs(
*,
clear_filters: bool = False,
search: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
) -> str:
filters_changed = False
# --- search logic ---
if search is KEEP and not clear_filters:
final_search = base_search
else:
filters_changed = True
final_search = (search or None)
# --- page logic ---
if page is None:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# --- build params ---
params: list[tuple[str, str]] = []
if final_search:
params.append(("search", final_search))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

137
cart/bp/order/routes.py Normal file
View File

@@ -0,0 +1,137 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from shared.models.market import Product
from shared.models.order import Order, OrderItem
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
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
def register() -> Blueprint:
bp = Blueprint("order", __name__, url_prefix='/<int:order_id>')
ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference
@bp.before_request
def route():
# this is the crucial bit for the |qs filter
g.makeqs_factory = makeqs_factory
@bp.get("/")
async def order_detail(order_id: int):
"""
Show a single order + items.
"""
result = await g.s.execute(
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product)
)
.where(Order.id == order_id)
)
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/order/index.html", order=order,)
else:
# HTMX navigation (page 1): main panel + OOB elements
html = await render_template("_types/order/_oob_elements.html", order=order,)
return await make_response(html)
@bp.get("/pay/")
async def order_pay(order_id: int):
"""
Re-open the SumUp payment page for this order.
If already paid, just go back to the order detail.
If not, (re)create a SumUp checkout and redirect.
"""
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if order.status == "paid":
# Already paid; nothing to pay
return redirect(url_for("orders.order.order_detail", order_id=order.id))
# Prefer to reuse existing hosted URL if we have one
if order.sumup_hosted_url:
return redirect(order.sumup_hosted_url)
# Otherwise, create a fresh checkout for this order
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
webhook_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
if webhook_secret:
from urllib.parse import urlencode
sep = "&" if "?" in webhook_url else "?"
webhook_url = f"{webhook_url}{sep}{urlencode({'token': webhook_secret})}"
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
error="No hosted checkout URL returned from SumUp when trying to reopen payment.",
)
return await make_response(html, 500)
return redirect(hosted_url)
@bp.post("/recheck/")
async def order_recheck(order_id: int):
"""
Manually re-check this order's status with SumUp.
Useful if the webhook hasn't fired or the user didn't return correctly.
"""
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
# If we don't have a checkout ID yet, nothing to query
if not order.sumup_checkout_id:
return redirect(url_for("orders.order.order_detail", order_id=order.id))
try:
await check_sumup_status(g.s, order)
except Exception:
# In a real app, log the error; here we just fall back to previous status
pass
return redirect(url_for("orders.order.order_detail", order_id=order.id))
return bp

View File

@@ -0,0 +1,77 @@
# suma_browser/app/bp/orders/filters/qs.py
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:
"""
Decode current query string into an OrderQuery(page, search).
"""
try:
page = int(request.args.get("page", 1) or 1)
except ValueError:
page = 1
search = request.args.get("search") or None
return OrderQuery(page, search)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Behaviour:
- If filters change and you don't explicitly pass page,
the page is reset to 1 (same pattern as browse/blog).
- You can clear search with search=None.
"""
q = decode()
base_search = q.search or None
base_page = int(q.page or 1)
def makeqs(
*,
clear_filters: bool = False,
search: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
) -> str:
filters_changed = False
# --- search logic ---
if search is KEEP and not clear_filters:
final_search = base_search
else:
filters_changed = True
if search is KEEP:
final_search = None
else:
final_search = (search or None)
# --- page logic ---
if page is None:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# --- build params ---
params: list[tuple[str, str]] = []
if final_search:
params.append(("search", final_search))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

151
cart/bp/orders/routes.py Normal file
View File

@@ -0,0 +1,151 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from shared.models.market import Product
from shared.models.order import Order, OrderItem
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
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
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("orders", __name__, url_prefix=url_prefix)
bp.register_blueprint(
register_order(),
)
ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference
oob = {
"extends": "_types/root/_index.html",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html",
}
@bp.context_processor
def inject_oob():
return {"oob": oob}
@bp.before_request
def route():
# this is the crucial bit for the |qs filter
g.makeqs_factory = makeqs_factory
@bp.get("/")
async def list_orders():
# --- decode filters from query string (page + search) ---
q = decode()
page, search = q.page, q.search
# sanity clamp page
if page < 1:
page = 1
# --- build where clause for search ---
where_clause = None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1)
.select_from(OrderItem)
.join(Product, Product.id == OrderItem.product_id)
.where(
OrderItem.order_id == Order.id,
or_(
OrderItem.product_title.ilike(term),
Product.title.ilike(term),
Product.description_short.ilike(term),
Product.description_html.ilike(term),
Product.slug.ilike(term),
Product.brand.ilike(term),
),
)
)
)
# allow exact ID match or partial (string) match
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
# --- total count & total pages (respecting search) ---
count_stmt = select(func.count()).select_from(Order)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
# clamp page if beyond range (just in case)
if page > total_pages:
page = total_pages
# --- paginated orders (respecting search) ---
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
context = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count, # For search display
}
# Determine which template to use based on request type and pagination
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/orders/index.html", **context)
elif page > 1:
# HTMX pagination: just table rows + sentinel
html = await render_template("_types/orders/_rows.html", **context)
else:
# HTMX navigation (page 1): main panel + OOB elements
html = await render_template("_types/orders/_oob_elements.html", **context)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp)
return bp