Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
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:
294
cart/bp/cart/global_routes.py
Normal file
294
cart/bp/cart/global_routes.py
Normal 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
|
||||
31
cart/bp/cart/overview_routes.py
Normal file
31
cart/bp/cart/overview_routes.py
Normal 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
129
cart/bp/cart/page_routes.py
Normal 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
|
||||
13
cart/bp/cart/services/__init__.py
Normal file
13
cart/bp/cart/services/__init__.py
Normal 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,
|
||||
)
|
||||
|
||||
45
cart/bp/cart/services/calendar_cart.py
Normal file
45
cart/bp/cart/services/calendar_cart.py
Normal 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))
|
||||
43
cart/bp/cart/services/check_sumup_status.py
Normal file
43
cart/bp/cart/services/check_sumup_status.py
Normal 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()
|
||||
248
cart/bp/cart/services/checkout.py
Normal file
248
cart/bp/cart/services/checkout.py
Normal 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()
|
||||
37
cart/bp/cart/services/clear_cart_for_order.py
Normal file
37
cart/bp/cart/services/clear_cart_for_order.py
Normal 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())
|
||||
)
|
||||
25
cart/bp/cart/services/get_cart.py
Normal file
25
cart/bp/cart/services/get_cart.py
Normal 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()
|
||||
4
cart/bp/cart/services/identity.py
Normal file
4
cart/bp/cart/services/identity.py
Normal 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"]
|
||||
212
cart/bp/cart/services/page_cart.py
Normal file
212
cart/bp/cart/services/page_cart.py
Normal 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
|
||||
43
cart/bp/cart/services/ticket_groups.py
Normal file
43
cart/bp/cart/services/ticket_groups.py
Normal 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())
|
||||
13
cart/bp/cart/services/total.py
Normal file
13
cart/bp/cart/services/total.py
Normal 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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user