ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une quarantaine de champs sur request.form a la main et les croyaient tous. Ce que ca produisait n etait pas bruyant : - edit_match attrapait une heure invalide et faisait start_time = None, puis annoncait que le match etait mis a jour. Le match perdait son heure et le calendrier l affichait a minuit ; - match_type etait accepte tel quel. Une valeur inconnue creait un match auquel aucun joueur n etait rattache, sans un mot ; - une fin avant le debut etait enregistree telle quelle ; - title est NOT NULL dans le modele et n etait pas verifie dans la route, donc un titre vide etait un 500 ; - 'a,b' dans la selection de joueurs arrivait sur int() sans garde. app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans users/_shared.py parce que c est la qu elles avaient d abord servi. Elles y restent re-exportees, donc aucun des trente appels n a bouge. Le mixin des schemas lit desormais un champ vide comme un champ absent. C est ce qui rendait ces formulaires invalidables : un formulaire HTML envoie tout ce qu il affiche, donc une date optionnelle non remplie arrive comme '' et non comme rien. Seuls les champs declares optionnels sont concernes ; un champ requis laisse vide doit toujours echouer. Deux duplications absorbees au passage, toutes deux nommees par l audit : la boucle de creation des participants, ecrite deux fois et deja divergee — la copie de edit_match gardait ses identifiants en chaines et appelait int() une ligne plus loin — et le contexte de re-affichage du formulaire, dont les versions courtes faisaient mourir un refus dans tojson sur un Undefined : un message de validation devenait un 500. Limite connue et consignee : le formulaire revient rempli avec les valeurs enregistrees, pas avec la saisie refusee. Reafficher la soumission demande de toucher aux gabarits, c est un autre changement. 19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total.
86 lines
3.0 KiB
Python
86 lines
3.0 KiB
Python
"""Helpers used by more than one route module in this package.
|
|
|
|
Nothing here touches the blueprint: these are plain functions, so a test
|
|
can call them with a request context and nothing else.
|
|
"""
|
|
|
|
from flask import request
|
|
from flask_babel import gettext as _
|
|
|
|
from app.extensions import db
|
|
|
|
# Re-exported: these two moved to app/forms.py once the match and tryout
|
|
# routes needed them as well (ARCH-005). Importing them from here still
|
|
# works, so the thirty call sites in this package did not have to move.
|
|
from app.forms import flash_validation_errors, form_payload # noqa: F401
|
|
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
|
|
|
|
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
|
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
|
|
|
#: Every PDF starts with this. Checking the name alone accepted a file
|
|
#: called anything.pdf holding anything at all.
|
|
PDF_SIGNATURE = b'%PDF-'
|
|
|
|
#: USER_TYPE → model class, for create_user.
|
|
USER_CLASS_MAP = {
|
|
'admin': Admin,
|
|
'manager': Manager,
|
|
'coach': Coach,
|
|
'player': Player,
|
|
'scout': Scout,
|
|
}
|
|
|
|
|
|
def pdf_upload_error(file, allowed_extensions):
|
|
"""Why this upload is not an acceptable PDF, or None if it is.
|
|
|
|
upload_signed_contract checked nothing beyond a non-empty filename —
|
|
ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player
|
|
could put an arbitrary file on the server under a name the application
|
|
later hands back for download (SEC-021).
|
|
|
|
Args:
|
|
file: The uploaded FileStorage, or None.
|
|
allowed_extensions: Extensions to accept, lowercase and without dot.
|
|
|
|
Returns:
|
|
str | None: A message to flash, or None when the file is acceptable.
|
|
"""
|
|
if file is None or not file.filename:
|
|
return _('No file selected.')
|
|
|
|
stem, dot, extension = file.filename.rpartition('.')
|
|
if not (stem and dot) or extension.lower() not in allowed_extensions:
|
|
return _('Only PDF files are allowed for contracts.')
|
|
|
|
head = file.stream.read(len(PDF_SIGNATURE))
|
|
file.stream.seek(0)
|
|
if head != PDF_SIGNATURE:
|
|
return _('That file is not a PDF, whatever its name says.')
|
|
|
|
return None
|
|
|
|
|
|
def update_user_gamertags(user, selected_games):
|
|
"""Update gamertags for a user based on form input."""
|
|
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
|
for game in selected_games:
|
|
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
|
platform = (
|
|
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
|
)
|
|
existing = existing_gamertags.get(game)
|
|
if gamertag:
|
|
if existing:
|
|
existing.gamertag = gamertag
|
|
existing.platform = platform
|
|
else:
|
|
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
|
|
db.session.add(gt)
|
|
elif existing:
|
|
db.session.delete(existing)
|
|
for game in existing_gamertags:
|
|
if game not in selected_games:
|
|
db.session.delete(existing_gamertags[game])
|