feat(perf): borner les vues de liste, et choisir le motif une bonne fois

MNT-14. Chaque vue de liste faisait .all() sur sa table. L'audit evaluait
l'impact a nul -- justement, a l'echelle d'une association etudiante -- et
recommandait de choisir le motif maintenant plutot que de le retro-adapter
plus tard. C'est ce que ceci est.

Applique a list_users, list_evaluations et team_matches.list_matches. Pour
cette derniere, la pagination borne aussi la boucle sur les participants,
qui est le N+1 que le constat designait comme le premier a se degrader.

Trois decisions, parce que ce sont celles qui se prennent deux fois
differemment sinon.

error_out=False : les numeros de page arrivent par l'URL, donc ?page=999 est
une chose qu'on tape ou qu'un signet perime contient. Le defaut de
Flask-SQLAlchemy y repond par un 404, ce qui est deroutant pour quelqu'un qui
est simplement alle une page trop loin.

Un plafond sur per_page : c'est aussi un parametre d'URL, et sans plafond
?per_page=100000 redonne a la main exactement la requete non bornee que la
pagination existe pour empecher.

page_url est un global Jinja plutot qu'une valeur que chaque vue passe. Ce
qui se rate avec des liens de pagination, c'est le reste de la chaine de
requete : la liste d'evaluations porte sort et order, celle des matchs
d'equipe porte team_id, et un lien qui les perd reinitialise silencieusement
la vue que la personne regardait. Les deux tests qui l'epinglent tombent si
page_url cesse de les recopier -- verifie par mutation.

Les tris sont completes par une cle unique : une requete paginee sans ORDER
BY stable peut montrer la meme ligne deux fois et jamais une autre.

Au passage, huit entrees fuzzy corrigees dans les catalogues, dont deux
laissees par le commit SEC-16 : une entree fuzzy est ignoree a l'execution,
donc ces messages retombaient en anglais.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-11 20:20:42 -04:00
co-authored by Claude Opus 5
parent 9166d8abeb
commit 838b247649
14 changed files with 662 additions and 269 deletions
+64
View File
@@ -0,0 +1,64 @@
"""Bounding what a list view loads (MNT-14).
Every list view ran `.all()` on its table and handed the whole thing to a
template. The audit rated the impact as nil — correctly, at the scale of a
student club — and recommended choosing the pattern now rather than
retro-fitting one later. This is that pattern, in one place, so that the
next list added to the application has something to copy.
Two decisions worth stating, because both are the kind that get made twice
differently otherwise.
**`error_out=False`.** Page numbers arrive in the URL, so `?page=999` is a
thing a person can type or a stale bookmark can hold. Flask-SQLAlchemy's
default answers it with a 404, which is a confusing thing to show someone
who has simply gone one page too far. An empty page is honest and the
controls take them back.
**A cap on `per_page`.** It is also a URL parameter, and without a ceiling
`?per_page=100000` re-creates by hand exactly the unbounded query this
module exists to prevent — the sort of thing that turns a listing into a
cheap way to make the server work hard.
"""
from flask import request, url_for
#: Rows per page when nothing asks otherwise.
DEFAULT_PER_PAGE = 50
#: Ceiling on the `per_page` query parameter. Generous enough that anyone
#: wanting "everything" on one screen gets it for any realistic table, low
#: enough that the query stays bounded.
MAX_PER_PAGE = 200
def paginate(query, per_page=DEFAULT_PER_PAGE):
"""Return one page of `query`, honouring `?page=` and `?per_page=`.
Args:
query: A SQLAlchemy query, already ordered. Ordering matters: a
paginated query without ORDER BY may return the same row on two
pages and never return another.
per_page: Default page size for this listing.
Returns:
flask_sqlalchemy.pagination.Pagination
"""
page = request.args.get('page', 1, type=int) or 1
requested = request.args.get('per_page', per_page, type=int) or per_page
size = max(1, min(requested, MAX_PER_PAGE))
return query.paginate(page=max(1, page), per_page=size, error_out=False)
def page_url(page):
"""URL of the current listing at another page number.
Rebuilt from the live request rather than composed in the template,
because the part that gets forgotten is the rest of the query string:
the evaluations list carries `sort` and `order`, the team matches list
carries `team_id`. A pagination link that drops them silently resets the
view the person was looking at.
"""
args = request.args.to_dict()
args.pop('page', None)
return url_for(request.endpoint, page=page, **(request.view_args or {}), **args)