Fix performance issue for users with many orders

This commit is contained in:
2026-05-24 12:50:52 +02:00
parent 7856123bc0
commit 3591e0f33a
6 changed files with 136 additions and 22 deletions

View File

@@ -4,5 +4,6 @@ lint:
reset_db: reset_db:
rm -f aps_db.db rm -f aps_db.db
uv run alembic upgrade heads uv run alembic upgrade 958d7aee2b21
for file in db_fixtures/*.sql; do sqlite3 aps_db.db < "$file"; done for file in db_fixtures/*.sql; do sqlite3 aps_db.db < "$file"; done
uv run alembic upgrade heads

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Request from fastapi import APIRouter, File, Form, HTTPException, Request
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from starlette import status from starlette import status
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
@@ -12,6 +13,7 @@ from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.models import ( from allmende_payment_system.models import (
Account, Account,
Area, Area,
Order,
Permission, Permission,
Product, Product,
ProductDetails, ProductDetails,
@@ -310,7 +312,13 @@ async def get_accounts(
templates = get_jinja_renderer() templates = get_jinja_renderer()
accounts = session.scalars(select(Account)).all() accounts = session.scalars(
select(Account).options(
selectinload(Account.transactions)
.selectinload(Transaction.order)
.selectinload(Order.items)
)
).all()
users = session.scalars(select(User)).all() users = session.scalars(select(User)).all()
return templates.TemplateResponse( return templates.TemplateResponse(

View File

@@ -85,8 +85,8 @@ async def add_to_cart(request: Request, session: SessionDep, user: UserDep):
order_item = OrderItem( order_item = OrderItem(
product=product, quantity=quantity, total_amount=total_amount product=product, quantity=quantity, total_amount=total_amount
) )
session.add(order_item)
cart = user.shopping_cart cart = user.shopping_cart
session.add(order_item)
cart.items.append(order_item) cart.items.append(order_item)
session.flush() session.flush()

View File

@@ -81,8 +81,13 @@ def import_csv(filepath: str):
lambda: defaultdict(lambda: Order(user_id=1)) lambda: defaultdict(lambda: Order(user_id=1))
) )
for row in rows: for i, row in enumerate(rows, start=2):
transaction_type = row["typ"].lower() transaction_type = row["typ"].lower()
if transaction_type == "einkauf foodcoop":
print(
"Skipping 'Einkauf Foodcoop' transaction as it's not relevant for the payment system."
)
continue
if transaction_type not in [ if transaction_type not in [
"essen", "essen",
"essen kind", "essen kind",
@@ -90,8 +95,9 @@ def import_csv(filepath: str):
"einkauf", "einkauf",
"auszahlung", "auszahlung",
]: ]:
# TODO: Handle other types raise ValueError(
continue f"Skipping row {i} with unknown transaction type: '{transaction_type}'"
)
# find or create account for "Partei/Konto" # find or create account for "Partei/Konto"
account_name = row["partei_konto"] account_name = row["partei_konto"]
@@ -123,7 +129,7 @@ def import_csv(filepath: str):
if transaction_type == "essen" if transaction_type == "essen"
else kids_meal_product else kids_meal_product
), ),
total_amount=row["betrag"], total_amount=row["betrag"] * -1,
quantity=quantity, quantity=quantity,
) )
) )

View File

@@ -2,7 +2,7 @@ import datetime
import decimal import decimal
import typing import typing
from sqlalchemy import Column, ForeignKey, Numeric, Table, and_, func, select from sqlalchemy import Column, ForeignKey, Numeric, Table, and_, exists, func, select
from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import ( from sqlalchemy.orm import (
DeclarativeBase, DeclarativeBase,
@@ -35,8 +35,6 @@ class Account(Base):
@property @property
def balance(self): def balance(self):
for t in self.transactions:
print(t)
return sum(t.total_amount for t in self.transactions) return sum(t.total_amount for t in self.transactions)
@@ -49,6 +47,7 @@ user_account_association = Table(
class User(Base): class User(Base):
__tablename__ = TABLE_PREFIX + "user" __tablename__ = TABLE_PREFIX + "user"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(nullable=False, unique=True) username: Mapped[str] = mapped_column(nullable=False, unique=True)
@@ -57,7 +56,11 @@ class User(Base):
accounts: Mapped[list["Account"]] = relationship( accounts: Mapped[list["Account"]] = relationship(
"Account", secondary=user_account_association, back_populates="users" "Account", secondary=user_account_association, back_populates="users"
) )
orders: Mapped[list["Order"]] = relationship("Order", back_populates="user") orders: Mapped[list["Order"]] = relationship(
"Order",
back_populates="user",
lazy="selectin",
)
user_groups: Mapped[list["UserGroup"]] = relationship( user_groups: Mapped[list["UserGroup"]] = relationship(
"UserGroup", "UserGroup",
@@ -65,18 +68,29 @@ class User(Base):
back_populates="users", back_populates="users",
) )
_shopping_cart: Mapped["Order"] = relationship(
"Order",
uselist=False,
primaryjoin=lambda: and_(
User.id == Order.user_id,
~exists().where(Transaction.order_id == Order.id),
),
viewonly=True,
)
@property @property
def shopping_cart(self): def shopping_cart(self):
for order in self.orders: if self._shopping_cart is None:
if order.transaction is None:
cart = order
break
else:
cart = Order(user=self)
session = object_session(self) session = object_session(self)
session.add(cart) assert session is not None, "User object is not attached to a session."
return cart cart = Order(user_id=self.id)
self.orders.append(cart)
session.add(cart)
session.flush()
session.refresh(self, ["_shopping_cart"])
return self._shopping_cart
def has_permission(self, scope: str, action: str) -> bool: def has_permission(self, scope: str, action: str) -> bool:
for group in self.user_groups: for group in self.user_groups:
@@ -236,13 +250,15 @@ class Order(Base):
# create a transaction for the order # create a transaction for the order
transaction = Transaction( transaction = Transaction(
type="order", type="order",
total_amount=-self.total_amount,
order=self, order=self,
account=account, account=account,
) )
session = object_session(self) session = object_session(self)
session.add(transaction) session.add(transaction)
# expire the shopping cart relationship to force it to be reloaded and show that the order is no longer in the shopping cart
session.expire(self.user, ["_shopping_cart"])
class OrderItem(Base): class OrderItem(Base):
__tablename__ = TABLE_PREFIX + "order_item" __tablename__ = TABLE_PREFIX + "order_item"
@@ -296,5 +312,13 @@ class Transaction(Base):
@property @property
def total_amount(self): def total_amount(self):
if self.type == "order" and self.order is not None: if self.type == "order" and self.order is not None:
return self.order.total_amount return self.order.total_amount * -1
return self._total_amount return self._total_amount
@total_amount.setter
def total_amount(self, value):
if self.type == "order":
raise AttributeError(
"Cannot set total_amount of an order transaction directly."
)
self._total_amount = value

View File

@@ -30,3 +30,78 @@ def get_jinja_renderer() -> Jinja2Templates:
renderer.env.globals["production_mode"] = "APS_PRODUCTION_MODE" in os.environ renderer.env.globals["production_mode"] = "APS_PRODUCTION_MODE" in os.environ
return renderer return renderer
def timer_context(name: str):
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
def __exit__(self, exc_type, exc_val, exc_tb):
end = time.perf_counter()
print(f"{name} took {end - self.start:.4f} seconds")
return Timer()
import cProfile, pstats, io
from contextlib import contextmanager
@contextmanager
def profile(top_n: int = 20, sort_by: str = "cumulative", min_time: float = 0.001):
pr = cProfile.Profile()
pr.enable()
try:
yield
finally:
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).strip_dirs()
ps.sort_stats(sort_by)
ps.print_stats()
rows = []
for line in s.getvalue().splitlines():
parts = line.split(None, 5)
if len(parts) < 6:
continue
try:
cum = float(parts[3])
if cum >= min_time:
rows.append(
(
int(parts[0].split("/")[0]),
float(parts[1]),
cum,
float(parts[4]),
parts[5].strip(),
)
)
except (ValueError, IndexError):
continue
rows = rows[:top_n]
if not rows:
print("[profiler] nothing exceeded min_time threshold")
return
w = 55
print(f"\n── profile ({sort_by}, ≥{min_time}s) ──────────────────────────────")
print(f"{'calls':>7} {'tot':>8} {'cum':>8} {'percall':>8} {'function':<{w}}")
print("" * (7 + 8 + 8 + 8 + w + 6))
for calls, tot, cum, percall, loc in rows:
c = (
f"\033[91m{cum:8.4f}\033[0m"
if cum > 1
else f"\033[93m{cum:8.4f}\033[0m" if cum > 0.1 else f"{cum:8.4f}"
)
print(
f"{calls:>7} {tot:8.4f} {c} {percall:8.4f} {loc[-w:] if len(loc) > w else loc:<{w}}"
)
print(f"" * (7 + 8 + 8 + 8 + w + 6))
print(
f" {len(rows)} functions | tot accounted: {sum(r[1] for r in rows):.4f}s\n"
)