Architecture FletchScore¶
Document vivant, mis à jour à chaque changement touchant à un mécanisme
déjà documenté (voir CLAUDE.md, section “filet de fin de session”).
Pour le détail complet (modèle de données, flux de validation, sécurité des tokens, arborescence du projet), voir le cahier des charges dans la doc Sphinx – ce fichier sert de résumé technique rapide pour qui travaille directement sur le code.
Résumé¶
Stockage : SQLite local, fichier unique, poste organisateur unique (pas d’écriture concurrente en v1). Implémenté (
storage/db.py) : schéma complet, clés étrangères actives, CRUD pour les 10 entités, migrations de schéma séquentielles (tableschema_version, listeMIGRATIONS, appliquées automatiquement parinit_schema()/ouvrir_base()– voir issue #5 et la décision plus bas).Modèle de données : implémenté (
models/) – 10 entités, calcul de catégorie d’âge, code de catégorie combiné (ex.AMBB-R).Import CSV : implémenté (
io/import_csv.py) – clubs et compétiteurs, avec rapport d’erreurs par ligne.Couche
services.py: implémenté – cas d’usage organisateur (créer compétition/épreuve, inscrire, saisir un score final, classement live), validations métier,ErreurMetieravec messages lisibles.Deux vues, un seul outil : GUI organisateur (customtkinter) codée (
gui/, 10 écrans) ; page web compétiteur servie localement (http.server) codée aussi (api/competiteur.py, v0.2 – lecture seule, demande de rattachement, confirmation de code, messagerie).Couche
scoring/: implémenté (scoring/classement.py) – classement par catégorie, départage au X, rangs avec égalités, podium. Isolée de la GUI et du stockage, testable unitairement.scoring/volee.py(normalisation flèche par flèche) a existé un temps puis a été supprimé – voir plus bas, “Révision majeure : saisie au score final”.Sécurité (prévue, pas encore codée) : voir
SECURITY.md– authentification par token côté compétiteur, mot de passe/session locale côté organisateur, HTTPS local.
Décisions à date¶
Divisions Veteran/Senior : paramètre par Compétition, pas global. Le règlement les laisse “optionnelles, non contraignantes” – résolu en ajoutant
Competition.categories_veteran_actives: bool. Sans lui,categorie_age()range tout le monde de 21 ans et plus dans Adult. Résout le point ouvert correspondant du cahier des charges. (models/competiteur.py)Enums en
StrEnum(Python 3.11+), pasclass X(str, Enum). Détecté par Ruff (règle UP042) sur le premier push – les deux sont fonctionnellement équivalents, maisStrEnumest la forme canonique depuis que le projet cible>=3.11. (models/enums.py)Score: upsert sur(inscription_id, numero_volee), pas un insert systématique. Une volée déjà saisie qui est corrigée par l’organisateur remplace la ligne existante plutôt que d’en empiler une nouvelle – correspond au flux “je corrige la saisie”, évite d’avoir à distinguer plus tard “la bonne” ligne parmi plusieurs versions d’une même volée. (storage/db.py::upsert_score)Import CSV : rejet strict, jamais de correction silencieuse. Un
code_club/code_styleinconnu danscompetiteurs.csv, ou unid_federaldéjà en base, rejette la ligne avec un message explicite plutôt que de créer la référence manquante ou d’écraser la fiche existante. Un club déjà présent lors d’un ré-import est en revanche traité comme un no-op (ignorees), pas une erreur – réimporter le mêmeclubs.csvd’une session à l’autre ne doit pas être bloquant. (io/import_csv.py)fletchscore.speca besoin queconfig/existe dans git. PyInstaller échoue si le dossier de données qu’on lui demande d’embarquer est absent du checkout – or git ne suit pas les dossiers vides. D’oùconfig/README.md, qui n’a d’autre rôle que de garder ce dossier suivi par git (voir le commentaire dans le fichier lui-même).scoring/reçoit des objets déjà chargés, jamais une connexion DB.classement_par_categorie()prend une liste de(Competiteur, list[Score])en argument plutôt que d’aller chercher les données elle-même – garde la couche testable sans base de données ni fixture lourde (voir tests/test_scoring_classement.py).Seuls les scores
VALIDEcomptent dans un total ou un classement.total_scores()filtre explicitement sur le statut – une proposition compétiteur non encore validée par l’organisateur ne doit jamais influencer un classement affiché ou exporté (voir docs/cahier-des-charges/securite.rst §7.2).Rang partagé en cas d’égalité, le suivant saute (1, 2, 2, 4). Convention sportive standard – une égalité qui subsiste après le départage au X prévu par le barème n’est PAS départagée davantage : le règlement renvoie ça à l’organisateur, le code n’invente pas de critère supplémentaire (ex. ordre alphabétique).
Bareme.nb_unites/volees_par_uniterenommés ennb_series/volees_par_serie. Vocabulaire confirmé par plusieurs glossaires d’archerie français : une volée est le petit groupe de flèches tirées d’affilée avant d’aller les relever (déjà le bon niveau pourScore.numero_volee, inchangé) ; une série est le regroupement de plusieurs volées tirées dans une même manche/mi-temps du concours – c’est ce que “unité” désignait à tort. Renommé dansmodels/bareme.py,storage/db.py(schéma + CRUD) et les tests – aucune vraie base déployée à ce stade, donc pas de migration nécessaire.Scoreportenumero_serieen plus denumero_volee. Un simplenumero_voleeétait ambigu dès qu’une Épreuve comporte plusieurs séries (Flint Indoor : 2 séries de 7 volées, “volée 1” existe deux fois par inscription) – la contrainte d’unicité SQLite est donc passée de(inscription_id, numero_volee)à(inscription_id, numero_serie, numero_volee). Trouvé en confirmant la hiérarchie Compétition > Épreuve > Série > Volée avec l’utilisateur, pas par un bug remonté – autant corriger le modèle avantgui/que de le découvrir en écrivant l’écran de saisie.Distances par volée (Flint) : pas encore modélisées. Le Flint Indoor a 6 distances différentes sur les 6 premières volées d’une série, et la 7e volée se tire sur 4 distances différentes – une info utile à afficher à l’organisateur pendant la saisie, mais qui n’affecte pas le calcul du score (les valeurs de zones ne dépendent pas de la distance). Reporté à
gui/: à modéliser seulement si l’écran de saisie en a réellement besoin, pas avant.Une couche
services.pyentre la GUI et le reste. Les widgets Tkinter ne contiennent que de l’affichage : tous les cas d’usage (créer une compétition, inscrire, saisir une volée, calculer le classement) vivent dansservices.py, qui valide les entrées et lèveErreurMetieravec un message rédigé pour un bénévole. Motivation directe : la GUI réelle n’est pas vérifiable dans l’environnement de dev (pas d’affichage Tkinter), donc tout ce qui peut être testé sans affichage doit vivre en dehors des widgets. Les identifiants (uuid4) sont générés par cette couche, pas demandés à l’appelant.gui/robustesse.py: nitkinternicustomtkinterimportés. Découvert en voulant tester la gestion de l’absence d’affichage et de l’arrêt utilisateur (Ctrl+C,kill) : l’environnement de dev n’a même pas le paquet systèmepython3-tk(pas seulementcustomtkinter).construire_fenetre()détecte une absence d’affichage par le nom de la classe d’exception (type(erreur).__name__ == "TclError") plutôt que parisinstance(erreur, tkinter.TclError)– évite toute dépendance à tkinter dans ce module, qui reste donc testable ici avec de simples doublures (unittest.mock.Mock+ une classe d’exception factice nomméeTclError).gui/app.py, lui, importe biencustomtkinteret n’est pas testable dans cet environnement – son rendu doit être vérifié en le lançant sur une vraie machine. Ctrl+C etkill(SIGINT/SIGTERM) referment la fenêtre proprement (application.destroy()) avant de fermer la connexion SQLite, plutôt que de laisser le process mourir en plein milieu d’une écriture.parser_date()vit dansservices.py, pas dans un modulegui/. Même raisonnement quegui/robustesse.py: une fonction qui convertit un texte AAAA-MM-JJ en date n’a besoin d’aucune dépendance à customtkinter, donc elle reste testable ici en vivant à côté des autres cas d’usage plutôt qu’à l’intérieur d’un écran.gui/ecran_competitions.py: premier écran réel, non vérifié. Deux colonnes (compétitions / épreuves de la sélection), formulaires de création, erreurs affichées via unCTkLabelrouge alimenté parErreurMetier. Comme toujours, la validation vit entièrement dansservices.py(déjà testée) – ce fichier ne fait qu’agencer des widgets.list_competitions()etlist_baremes()ajoutés àstorage/db.pyà cette occasion (manquaient).gui/ecran_competiteurs.py: import CSV (sélecteur de fichier) + liste.formater_rapport()ajouté àio/import_csv.py(pas dans un modulegui/) pour rester testable – convertit unRapportImporten texte affichable tel quel. Bug de grille repéré et corrigé en relisant avant livraison : le titre “Compétiteurs” et la zone de rapport partageaient la même ligne (row=1) et se seraient chevauchés – aucun moyen de le voir tourner ici pour le confirmer autrement qu’en relisant soigneusement le code.gui/ecran_saisie.py: le plus complexe des quatre écrans. Sélecteur d’épreuve (toutes compétitions confondues), inscription à la volée, formulaire de saisie dont le nombre de champs de flèches se régénère selonbareme.fleches_par_volee. Trois fonctions ajoutées àservices.pypour rester testable :parser_valeurs_fleches()(les champs vides sont ignorés, pas convertis en 0 – c’estnormaliser_voleequi décide de compléter à 0, pas la GUI),lister_epreuves_toutes(),lister_competiteurs_non_inscrits(). Deux bugs de grille repérés en relisant, pas en le lançant : ungrid_rowconfigurerésiduel d’un premier brouillon contredisait la valeur correcte posée plus bas (poids d’extension sur la mauvaise ligne) ; le poids d’extension de la colonne de saisie visait le label d’erreur (ligne 4) au lieu de la liste des volées déjà saisies (ligne 7). Aucun des deux n’aurait été détecté sans relecture attentive – toujours pas de substitut à un vrai lancement.(Révisé depuis : ce formulaire volée par volée et
parser_valeurs_fleches()ont été remplacés par une saisie au score final – voir “Révision majeure : saisie au score final” plus bas.)libelle_epreuve()/libelle_competiteur()déplacées dansservices.py. D’abord écrites en double dansgui/ecran_saisie.pyen le codant ; extraites avant d’écriregui/ecran_classement.pyplutôt que de les dupliquer une 3e fois – même raisonnement queparser_date(toujours en usage).gui/ecran_classement.py: dernier écran degui/. Sélecteur d’épreuve (même liste que la saisie), classement affiché par catégorie triée alphabétiquement, rang/total/X par ligne – le calcul vient entièrement deservices.classement_epreuve(), déjà testé. Layout plus simple que les écrans précédents (une seule colonne, lignes générées dynamiquement par compteur) : pas de bug de grille trouvé cette fois en relisant, mais ça ne remplace pas un vrai lancement pour le confirmer.
Les 4 écrans de gui/ étaient en place (v0.1) sans jamais avoir tourné
une seule fois – l’environnement de dev n’a ni tkinter ni
customtkinter. Premier essai réel effectué par l’utilisateur,
retour positif (pas de détail écran par écran ni d’ergonomie poussée
remonté). Les formulaires d’ajout manuel ci-dessous, ajoutés juste après
ce test, restent donc les seuls de gui/ jamais lancés.
Saisie manuelle de club/compétiteur ajoutée après le premier test réel. Absente du cahier des charges initial – l’import CSV en masse avait été posé comme moyen principal, sans jamais trancher le cas “un archer se présente sans être dans le fichier” ou “je veux corriger une seule fiche”.
services.creer_club()/creer_competiteur()reprennent exactement les règles de validation de l’import CSV (club/style inconnu refusé, jamais créé à la volée ; identifiant déjà pris refusé, jamais écrasé) – pour que les deux chemins (import en masse, saisie au coup par coup) restent cohérents entre eux.podium_par_categorie()filtre par rang, pas par position dans la liste. Une égalité au rang 1 met deux personnes sur le podium ; le rang 2 n’existe alors pour personne (convention 1, 2, 2, 4 déjà utilisée pour le classement complet) – prendre les 3 premiers éléments de la liste aurait silencieusement exclu un ex-aequo.io/export/csv.py: une seule fonction pour classement complet et podium.exporter_classement_csv()ne sait rien du “podium” – elle exporte le dict qu’on lui donne. Le filtrage (top 3 ou classement entier) se décide en amont viapodium_par_categorie(), pas par un paramètre supplémentaire sur la fonction d’export – une fonction, une responsabilité.PDF : fpdf2, pas reportlab. Pur Python (pas de composants C), plus sûr sur Pydroid/Android ; API plus simple, suffisante pour un tableau de classement – pas besoin de la richesse de reportlab pour ce besoin. Choisi sur demande explicite de proposer, faute de préférence tranchée au moment de la décision.
io/export/pdf.pyet ses tests, jamais exécutés nulle part au départ, puis confirmés par la CI. fpdf2 n’est pas installable ici (pas de réseau) – contrairement àgui/robustesse.py(où la dépendance avait pu être évitée entièrement), ici la bibliothèque est le véritable objet testé : impossible de vérifier un PDF produit sans PDF réellement produit. Les tests utilisentunittest.skipUnlessconditionné sur la réussite de l’import – la suite reste propre (OK (skipped=N)) au lieu de faire échouer la collecte de tous les autres tests. Pensé à tort dans un premier temps que la CI, elle, les exécutait pour de vrai (installation viapyproject.toml) – en réalité le jobtestde la CI ne faisait jamaispip installdu tout (voir plus bas). Une fois ce bug corrigé, les 175 tests – fpdf2 compris – tournent réellement et passent en CI, plus aucunskipped: première vraie confirmation que l’export PDF fonctionne, même si toujours pas vérifié dans cet environnement de dev précis.io/export/excel.py: premier export réellement vérifié de bout en bout.openpyxlest installé dans cet environnement (contrairement àcustomtkinter/tkinter/fpdf2) – les 7 tests tournent pour de vrai, et le fichier produit a été inspecté cellule par cellule (pas seulement “le test passe”, le contenu réel a été relu). Une feuille, groupée par catégorie triée alphabétiquement, avec une ligne vide entre catégories et un titre de feuille tronqué à 31 caractères (limite dure d’Excel, sinonopenpyxllève une erreur à l’écriture).
La v0.1 est complète : un FletchScore utilisable en club, sans la partie web/compétiteur. Bon moment pour un test en conditions réelles plus poussé avant d’attaquer la v0.2 (vue compétiteur, lecture seule).
test.yml: le jobtestn’installait jamais le paquet. Passait directement desetup-pythonàpython -m unittest discover, sanspip install– fonctionnait par accident tant qu’aucun test ne dépendait d’une bibliothèque tierce (customtkinter/openpyxl/fpdf2 toutes absentes du runner), et masquait le fait que les tests fpdf2 étaient “skipped” en CI aussi, pas seulement en local. Ajoutépip install -e ".[dev]"avant les tests. Bug trouvé par l’utilisateur (échec réel detest_export_excel.pyen CI), pas par moi – je n’ai pas de moyen de faire tourner cette CI moi-même pour le repérer en amont. Confirmé corrigé : les 175 tests passent en CI sans aucunskipped, fpdf2 compris.EpreuveTemplate: entité séparée d’Epreuve, pas un champ optionnel dessus. Une Épreuve reste toujours liée à une compétition et une date précises ; un modèle n’a ni l’une ni l’autre – seulement ce qui se réutilise (nom, barème).creer_epreuve_depuis_template()appellecreer_epreuve()plutôt que de réimplémenter ses vérifications (compétition clôturée, date hors bornes…) – un modèle ne doit pas ouvrir un chemin de contournement des règles normales de création.ecran_competitions.py: un label d’erreur peut rester vert. Repéré en écrivant le bouton “Enregistrer comme modèle” (message de succès en vert) :CTkLabel.configure(text=...)sans reprécisertext_colorgarde la dernière couleur configurée – un message de succès suivi d’une erreur serait resté vert. Corrigé avec deux méthodes dédiées (_afficher_erreur_epreuve/_afficher_info_epreuve) qui fixent systématiquement la couleur plutôt que de compter sur un état par défaut.Version affichée automatiquement, jamais recopiée à la main.
docs/conf.pylitimportlib.metadata.version("fletchscore")pourrelease/version(thème furo l’affiche dans la barre latérale) ;gui/app.pyréutilisefletchscore.__version__(déjà généré par setuptools_scm, voirpyproject.toml) dans le titre de la fenêtre et un petit label en bas de la barre latérale. Les deux ont un repli propre (0.0.0+inconnue/0.0.0+unknown) si le paquet n’est pas installé – jamais d’erreur bloquante juste pour un numéro de version manquant.docs/conf.pyn’étant jamais importé par le paquet (seul Sphinx l’exécute), un test dédié l’exécute directement pour attraper une erreur avant qu’elle ne cassedocs.ymlen CI.Logo dans
branding/, pas dansweb/nidocs/_static/. Ni donnée de club (commeweb/assets/), ni contenu packagé pour la vue compétiteur (commesrc/fletchscore/web/) – un dossier séparé évite toute confusion.docs/conf.py::html_logopointe dessus directement (../branding/logo.svg) plutôt que de dupliquer le fichier dansdocs/_static/, pour n’avoir qu’une seule source à tenir à jour..icogénéré depuisbranding/logo.png(recadré automatiquement sur le contenu réel d’un PNG 968x703 fourni par l’utilisateur, fond transparent) – contient un vrai 256x256, contrairement à la première version générée depuis un JPG 126x128 (le SVG source n’a toujours pas pu être rastérisé directement ici, faute d’outil disponible sans réseau ; le PNG haute résolution fourni ensuite a rendu ce contournement inutile). Testé que le cheminhtml_logorésout vers un vrai fichier (test_docs_conf.py) – le seul moyen de vérifier ça sans Sphinx installé ici. Pas d’icône de fenêtre GUI pour l’instant (empaqueterbranding/dans l’exécutable et gérer sa résolution de chemin en mode PyInstaller n’en valait pas la complexité pour un gain cosmétique).modifier_competition()/modifier_epreuve(): mêmes règles que la création, plus une protection propre à la modification. Rétrécir les dates d’une compétition sous une épreuve existante est refusé (message nommant l’épreuve en cause) ; changer le barème d’une épreuve après saisie d’une volée est refusé (storage.epreuve_a_des_scores()) – les numéros de série/volée déjà enregistrés ne correspondraient plus forcément au nouveau barème.modifier_competition()ne touche jamais au statut : clôturer une compétition reste une action distincte, pas un champ à corriger dans ce formulaire. Manque signalé par l’utilisateur en cours de test réel (bloqué avec une épreuve mal saisie et aucun moyen de la corriger) – pas anticipé dans le cahier des charges initial.4 nouveaux barèmes (Field, Hunter, International, Expert Field) ajoutés après relecture du règlement, Animal/3-D volontairement exclus. Les quatre premiers s’intègrent tels quels au modèle
Baremeexistant (nombre de flèches fixe par cible, score constant). Animal Round et les rounds 3-D ont un système de score incompatible avec ce modèle (zones “kill”/”wound” à valeur décroissante selon le numéro de la flèche, arrêt du tir dès le premier impact, jusqu’à 3 flèches tentées par cible) – ça demanderait un moteur de score distinct descoring/volee.py, pas seulement un nouveauBareme. (Révisé depuis :scoring/volee.pya été supprimé, et ce blocage avec lui – voir “Révision majeure : saisie au score final” plus bas. Ajouter Animal/3-D ne demande plus qu’unscore_maxcorrect.) Réserve notée surnb_series=1pour Field/Hunter/Expert Field : le règlement ne précise nulle part si un round complet représente 1 ou 2 “unités standard” pour ces rounds-là (contrairement à Flint/IFAA Indoor, explicites sur ce point) – valeur retenue par prudence, pas une certitude.resumer_accueil(): “dernière activité” = épreuve la plus récente par date, pas un horodatage d’action. Aucune table ne trace “quand” une compétition, une épreuve ou un score a été créé/modifié – ajouter ça partout juste pour un écran d’accueil aurait été disproportionné. La date métier de l’épreuve (Epreuve.date, déjà utilisée pour le tri delister_epreuves_toutes()) sert de proxy raisonnable : ce n’est pas littéralement “la dernière action de l’organisateur”, mais c’est l’information la plus proche déjà disponible sans changement de schéma.Écran Aide : contenu statique + un seul bouton externe (
webbrowser.open). Pas de widget hyperlien natif dans customtkinter – un bouton qui ouvre le navigateur par défaut reste plus simple et plus prévisible qu’un label cliquable fait main. Le texte d’aide dans la GUI reste un résumé volontairement court (une phrase par section) ; le détail complet renvoie vers la doc Sphinx en ligne plutôt que d’être dupliqué dans le code.Révision majeure : saisie au score final, pas volée par volée. Proposée par l’utilisateur après un premier jalon de saisie détaillée (série + volée + valeur par flèche) – jugée trop lourde face à l’usage réel : les scores sont déjà totalisés à la main sur la feuille de match pendant le tir, le rôle de FletchScore est d’enregistrer ce résultat et de classer, pas de rejouer le calcul flèche par flèche.
models/score.pysimplifié àtotal+nombre_x(une ligne par Inscription, contrainte UNIQUE) ;scoring/volee.pyetnormaliser_volee()supprimés ;services.saisir_score_final()remplacesaisir_volee(), borné parbareme.score_maxetbareme.total_flèchesplutôt que de valider chaque flèche individuellement.gui/ecran_saisie.pyréécrit : deux champs (total, X) au lieu du formulaire volée par volée avec sélecteurs série/volée. Effet de bord positif : ça lève le blocage sur l’Animal Round et les rounds 3-D (voir docs/cahier-des-charges/regles-metier.rst) – leur système de score complexe (kill/wound, arrêt au premier impact) ne pose plus problème puisque FletchScore n’a plus besoin de le modéliser en détail, juste de connaître le score maximum possible pour borner la saisie. Choix délibéré de garderScorecomme entité séparée (une ligne par Inscription) plutôt que de repliertotal/nombre_x/statutdirectement surInscription– même résultat, empreinte de modification bien plus petite (une seule table/classe à toucher en profondeur au lieu de reporter le changement partout oùInscriptionest utilisée).Export CSV clubs/compétiteurs, symétrique à l’import.
exporter_clubs_csv()/exporter_competiteurs_csv()écrivent exactement les colonnes attendues parimport_clubs/import_competiteurs– vérifié par un vrai test de round-trip (export puis réimport, objet récupéré égal à l’objet exporté), pas seulement “les deux fonctions existent séparément”. Ajoutées dansio/import_csv.pyplutôt qu’un nouveau module – même fichier connaît déjà le format de colonnes des deux référentiels, pas de raison de le dupliquer ailleurs. Manque signalé par l’utilisateur après un premier test réel complet (créer, importer, exporter) – pas anticipé dans le cahier des charges initial, qui ne parlait que d’import.modifier_club()/modifier_competiteur(): identifiant jamais modifiable.code_clubetid_federalsont les clés référencées ailleurs (fiches compétiteur pour l’un, inscriptions/tokens pour l’autre) – les changer casserait ces références, doncstorage.update_club/update_competiteurne touchent jamais à la clé primaire, seulement aux autres champs. Le champ correspondant est grisé (state="disabled") dans le formulaire GUI en mode édition, pas seulement ignoré côté service – évite de laisser croire à l’organisateur qu’il peut le changer. Pas de liste de clubs dédiée dans la GUI : le formulaire club a son propre sélecteur (“choisir un club existant à modifier” + bouton “Modifier”) plutôt que d’ajouter un panneau de liste séparé, pour rester compact. Manque signalé par l’utilisateur après un test réel – pas anticipé dans le cahier des charges initial.gui/dialogue_fichier.py:filedialognatif remplacé par une fenêtre de saisie maison. Bug signalé par l’utilisateur : sur Pydroid/Android,tkinter.filedialog.askopenfilename/asksaveasfilenamebloque l’application dès sa deuxième invocation dans la session, même sur le même bouton – pas reproductible ici (pas d’affichage), mais le symptôme (blocage identique quel que soit le bouton, dès le 2e appel) pointe vers le sélecteur natif lui-même, pas vers la logique d’import/export.demander_chemin()n’utilise que des widgets customtkinter classiques (CTkToplevel+CTkEntry), aucun appel au sélecteur natif de l’OS – contourne le chemin de code suspect entièrement plutôt que d’essayer de le réparer à l’aveugle. Contrepartie assumée : l’utilisateur tape/colle le chemin au lieu de le sélectionner visuellement. Correctif spéculatif, à confirmer – je n’ai aucun moyen de reproduire le bug d’origine ici pour vérifier que ça le résout vraiment.ecran_classement.py: export CSV/Excel/PDF + podium, oublié dans le premier jet. Les fonctions d’export (io/export/) existaient depuis la v0.1 mais n’étaient jamais appelées depuis la GUI – repéré par l’utilisateur. Ajouté avec une case “Podium seulement” qui passe parpodium_par_categorie()avant l’export (le filtrage se décide côté GUI, les fonctions d’export elles-mêmes ne savent toujours rien du concept de podium). Import deexporter_classement_pdfdifféré à l’intérieur de la méthode plutôt qu’en tête de fichier : si fpdf2 n’est pas installé, seul le bouton PDF échoue avec un message clair, pas tout l’écran au chargement. Bug de grille repéré et corrigé avant livraison : le label d’erreur d’export et le cadre des 3 boutons visaient tous les deux la ligne 1 de l’écran – déplacé le label à l’intérieur du cadre plutôt qu’à côté.Classement global sur toute une compétition (plusieurs épreuves). Demande de l’utilisateur : une colonne par épreuve, une colonne total, classement cumulé.
scoring.classement_global()reste volontairement sans départage au X – les épreuves d’une compétition peuvent utiliser des barèmes différents (certains avec zone X, d’autres non), un critère uniforme n’aurait pas de sens garanti, contrairement au classement par épreuve qui connaît un seul barème. Un compétiteur inscrit à une partie seulement des épreuves compte 0 pour les absentes plutôt que d’être exclu ou de lever une erreur – vérifié avec un vrai scénario (compétiteur inscrit à 1 épreuve sur 2) en plus des tests unitaires.services.classement_global_competition()retourne aussi la liste des épreuves utilisées, nécessaire à l’export pour savoir quelle colonne correspond à quelle épreuve (identifiée par nom + date pour éviter une collision si deux épreuves portent le même nom). Export PDF pas encore fait à ce stade – voir l’entrée suivante.exporter_classement_global_pdf(): page en paysage, largeur de colonne avec plancher. Le nombre de colonnes dépend du nombre d’épreuves de la compétition – contrairement au classement par épreuve (toujours 4 colonnes fixes), impossible de fixer des largeurs à l’avance. Paysage plutôt que portrait pour donner plus de place ; largeur par épreuve calculée en divisant l’espace restant, avec un plancher de 20mm pour qu’une compétition à beaucoup d’épreuves se resserre plutôt que de planter (testé explicitement avec 10 épreuves, au-delà de ce que la page peut proprement afficher – pas de gestion de retour à la ligne ni de rotation de texte : au-delà d’une poignée d’épreuves, l’export CSV/Excel reste plus lisible que le PDF). Bouton GUI toujours pas fait pour le classement global (CSV, Excel, PDF) – seul le classement par épreuve est branché dansecran_classement.py.Bouton GUI de l’export global, ajouté après coup. Signalé par l’utilisateur (“sur quel bouton appuyer ?”) – il avait raison, aucun n’existait. Nouvelle section dans
ecran_classement.pyavec son propre sélecteur de compétition (pas d’épreuve – concept différent du reste de l’écran) et ses 3 boutons, un label d’erreur distinct de celui de l’export par épreuve pour ne pas mélanger les deux retours. La liste des compétitions vient deservices.lister_epreuves_toutes()dédupliquée parcompetition.idplutôt qu’une nouvelle fonctionlister_competitions()dédiée – une compétition sans aucune épreuve n’a de toute façon rien à exporter globalement (classement_global_competition()retourne un classement vide), donc la filtrer avant même l’affichage est le bon comportement, pas un raccourci. Déduplication vérifiée réellement (2 épreuves d’une même compétition -> 1 seule entrée dans le sélecteur).docs.yml/build.yml: la première vraie Release a révélé un bug de déclencheur. Publier une Release sur un tag déjà existant (créé via l’UI GitHub après ungit push --tagsséparé) ne déclenche PAS de nouvel événementpush– seulementrelease. Deux conséquences distinctes, corrigées ensemble :docs.ymln’écoutait pas du tout l’événementrelease(seulementpush/workflow_dispatch) – doc jamais construite ni déployée, jamais d’archive jointe à la Release. Ajoutérelease: types: [published]aux déclencheurs, et rendu explicitegithub.event_name == 'release'sur les conditions qui ne comptaient que surstartsWith(github.ref, 'refs/tags/v')– ce dernier devrait être vrai aussi pour un événementrelease(songithub.refpointe vers le tag), mais explicite plutôt que de compter sur ce comportement sans pouvoir le vérifier ici.build.yml::build-executablesexcluait explicitement l’événementrelease(github.event_name != 'release') – supposition fausse que la Release arrive toujours dans la même exécution CI qu’un push de tag. Résultat : aucun exécutable construit sur Release, etarchive-on-release(qui en dépend vianeeds:) restait skip aussi, silencieusement – sans erreur visible, donc sans alerte.Ce qui explique que
build-package/publish-pypiaient bien fonctionné sur cette première Release : ce sont les deux seuls jobs qui écoutaient déjàreleasecorrectement, d’où l’impression trompeuse que “tout” avait tourné alors que 2 workflows sur 2 avaient un trou. Bug trouvé uniquement parce que l’utilisateur a remarqué l’absence concrète des archives, pas détectable depuis ici (impossible de déclencher une vraie Release GitHub pour tester).
Révision : l’ajout de
release:dansdocs.ymln’a pas suffi – retour au fichier FletchTime confirmé fonctionnel. L’hypothèse ci-dessus (Release sur tag existant = pas depush) restait plausible mais non prouvée, et l’utilisateur a confirmé que la doc ne se déployait toujours pas après ce correctif. Plutôt que d’empiler une hypothèse de plus sans preuve,docs.ymla été réaligné fidèlement sur le fichier FletchTime réel (fourni par l’utilisateur, confirmé fonctionner chez lui) – qui n’a PAS de déclencheurrelease:du tout, seulementpush(branches + tags) etworkflow_dispatch. Ajouté au passage un vrai plus par rapport à ma version précédente : une étape “Vérifier la documentation générée” qui grep la version attendue dans le HTML produit – présente dans le fichier FletchTime, absente du mien.build.yml, lui, n’a pas été retouché cette fois (l’utilisateur a confirmé que PyPI fonctionne) – reste à confirmer si les exécutables Windows/Linux sont bien joints à une Release, pas seulement le paquet Python. Piste principale restante si le problème persiste malgré un fichier identique à celui qui fonctionne côté FletchTime : configuration GitHub du dépôt (Settings > Pages > Source, voir docs/roadmap.md), pas le workflow lui-même.v0.2 – vue compétiteur : chaque requête HTTP ouvre sa propre connexion SQLite en lecture seule. Le serveur (
api/competiteur.py) tourne dans un thread séparé pendant que la GUI continue – partager la connexion de la GUI serait dangereux (les connexions sqlite3 ne sont pas conçues pour être utilisées depuis un autre thread que celui qui les a créées). Chaque requête ouvre donc sa propre connexion via l’URIfile:...?mode=ro: lecture seule garantie au niveau SQLite lui-même, pas seulement par convention dans le code Python – même un bug qui tenterait une écriture échouerait proprement plutôt que de corrompre quoi que ce soit. Design cadré par 3 questions posées avant de coder (que voit le compétiteur, démarrage auto ou bouton, mécanique de rafraîchissement) plutôt que de deviner – première brique web du projet, plus de choix structurants que d’habitude. L’état du serveur (instance + thread) vit surFenetrePrincipale, pas sur l’écran GUI qui le pilote : l’écran est détruit et recréé à chaque navigation, mais le serveur doit continuer de tourner en arrière-plan pendant ce temps. Vérifié réellement de bout en bout hors GUI : démarrage, vraie requête HTTP sur un vrai port, arrêt propre – pas seulement les fonctions de génération de page testées isolément.v0.2 – clé secrète serveur stockée hors de la base SQLite.
fletchscore/securite.pygénère et persiste une clé HMAC dansconfig/cle_secrete.txt, jamais dans le fichier.db. Raisonnement : le fichier.dbest ce qui circule le plus facilement par accident (sauvegarde égarée, copie du dossier du club) – si la clé y vivait aussi, la récupérer suffirait à fabriquer de faux tokens valides pour n’importe quel compétiteur. En la stockant ailleurs, une fuite de la seule base ne compromet aucun token._hash_token()relitsecurite.CHEMIN_CLE_PAR_DEFAUTexplicitement plutôt que de laisserobtenir_cle_secrete()utiliser son propre défaut. Piège Python classique découvert en écrivant les tests : un argument par défaut est évalué une seule fois à la définition de la fonction, donc patcher l’attribut du module en test (mock.patch.object(securite, "CHEMIN_CLE_PAR_DEFAUT", ...)) ne change rien à ce défaut déjà figé – un vrai fichierconfig/cle_secrete.txta été créé par erreur dans le dépôt lors du premier passage des tests, repéré et nettoyé avant livraison. Corrigé en passant l’attribut explicitement à chaque appel, pour qu’il soit relu dynamiquement.Token/rattachement : le token n’est jamais généré à la demande, seulement à la validation.
demander_rattachement()ne crée qu’une entrée en file d’attente ;valider_rattachement()est la seule fonction qui appellegenerer_token(), après vérification humaine de l’organisateur – aucun chemin de code ne permet de contourner cette étape.verifier_token()retourneNonepour les trois cas d’échec (code inconnu, secret incorrect, token expiré/révoqué) sans distinguer lequel, pour ne pas donner à un attaquant un signal exploitable sur ce qui a précisément échoué. Vérifié en conditions réelles (pas seulement en tests unitaires) : flux complet demande → validation → vérification avec un vrai secret, puis un mauvais secret bien rejeté.Vue compétiteur restylée à l’identité FletchTime, préférences par cookie plutôt que JavaScript. Demande de l’utilisateur : même style que FletchTime (thème sombre,
theme.cssfourni), bilingue FR/EN.theme.csscopié tel quel danssrc/fletchscore/web/(déjà couvert parpackage-datadanspyproject.toml, aucun changement de packaging nécessaire) – jamais dupliqué dans le code Python, servi directement par le serveur.classement.cssajouté à côté pour les tableaux, absents du fichier source (extrait d’une page de config FletchTime sans tableau) – réutilise les mêmes variables de couleur, ne redéfinit rien. Préférence langue/thème mémorisée par cookie plutôt que par JavaScript : cohérent avec le choix “pas de JS” déjà fait pour cette page en v0.2, et surtout survit naturellement au rechargement automatique périodique – un état JS en mémoire ne survivrait pas à un rechargement complet de page (<meta http-equiv="refresh">), alors qu’un cookie si. Bascule via de simples liens<a>vers un endpoint/preferencequi pose les cookies et redirige (302) – protégé contre l’open redirect (le paramètreretourdoit commencer par/et pas par//, sinon repli sur/). Le stubsrc/fletchscore/web/index.html, jamais utilisé (l’app génère tout le HTML côté serveur, pas un SPA statique), a été retiré plutôt que laissé comme faux indice. 10 nouveaux tests, vérifiés réellement : fichiers statiques servis (contenu relu, pas juste code 200), cookie posé par/preferencepuis respecté sur la requête suivante, et un aperçu HTML complet généré et relu ligne par ligne pour confirmer un rendu cohérent (état “actif” des boutons, bonne langue, chemins de retour corrects).Endpoint de rattachement : vrai formulaire HTML
POST, pas un lienGET. Une demande de rattachement crée une ligne en base – une action qui modifie un état ne devrait pas être déclenchable par un simple lienGET(rechargement de page, prefetch de navigateur, ou simple accident de double-clic pourraient la déclencher sans intention). D’où un vrai<form method="post">, avecdo_POST()ajouté au gestionnaire – première écriture de tout le module.page_rattachement()désactive volontairement le rafraîchissement automatique (rafraichir=False), contrairement aux pages de classement : un compétiteur en train de chercher son nom ou de s’apprêter à cliquer ne doit pas se faire interrompre par un rechargement intempestif au mauvais moment. La recherche se fait parmi tous les compétiteurs inscrits à au moins une épreuve de la compétition (_competiteurs_de_la_competition()), pas par épreuve : le rattachement (comme leToken) est par (compétiteur, compétition), pas par épreuve. Bug de texte repéré en relisant une page générée réellement, pas en test unitaire : le lien de retour affichait “Toutes les compétitions” en pointant en fait vers une compétition précise – texte trompeur, corrigé avec une clé i18n dédiée (retour_competition). Vérifié réellement de bout en bout : un vraiPOSTHTTP crée une vraie ligne en base, relue ensuite par une connexion séparée pour confirmer – pas seulement que la page de confirmation s’affiche côté client.gui/qr_code.py: même mécanismeskipUnlessque fpdf2, pas de logique nouvelle inventée.qrcoden’est pas installable ici (pas de réseau), même situation exactement quefpdf2– réutilisation directe du pattern déjà validé (import protégé partry/except ImportError, drapeauQRCODE_DISPONIBLE, testsskipUnless) plutôt que d’en réinventer un autre. Le code court reste affiché quoi qu’il arrive, avec ou sans QR – jamais le seul moyen d’accès (voir cahier des charges, “QR code + code court en secours”).gui/ecran_rattachement.py: le token affiché dans une fenêtre éphémère (CTkToplevel), pas dans l’écran principal. Le secret encodé dans le QR n’est récupérable qu’une seule fois, au moment deservices.generer_token()– seul son HMAC est stocké ensuite, jamais le secret lui-même (voir la décision Token/rattachement plus haut). Le laisser affiché en permanence dans l’écran principal l’exposerait à quiconque regarde l’écran de l’organisateur bien après la remise au compétiteur ; une fenêtre qu’on ferme après avoir montré le code une fois correspond mieux à l’usage réel (le montrer, puis fermer). Sélecteur de compétition dérivé delister_epreuves_toutes(), même logique de déduplication que la section export global deecran_classement.py– pas de nouvelle fonctionlister_competitions()dédiée, cohérence avec l’existant plutôt qu’une resolution ad hoc.Port du serveur web : persisté dans
ConfigGui, jamais auto-démarré par--http-port. Même mécanisme que le thème (changer_theme()/config/gui.toml) plutôt qu’un système séparé –demarrer_serveur_web(port)persiste le port choisi seulement quand il est explicitement fourni, pour le proposer par défaut au prochain lancement.--http-porten CLI ne fait que préremplir ce champ, il ne démarre jamais le serveur tout seul : la décision “démarrage toujours explicite” prise en v0.2 reste valable, un flag CLI ne doit pas la contourner silencieusement. Un port déjà occupé lève uneOSError(comportement standard dehttp.server.HTTPServer, qui bind() dès sa construction) – affichée proprement côté GUI plutôt que de laisser remonter une trace Python. Vérifié réellement en faisant collisionner deux serveurs sur le même port.Aide et Accueil dans l’appli n’avaient pas suivi les écrans ajoutés depuis. Signalé par l’utilisateur :
gui/ecran_aide.py(l’aide dans l’application, distincte dedocs/guide-utilisateur/qui, elle, avait bien été tenue à jour) ne mentionnait ni la vue compétiteur ni les demandes d’accès, et décrivait encore la saisie comme “volée par volée” – périmé depuis la révision au score final, quelqu’un qui ouvre l’aide dans l’appli plutôt que la doc en ligne aurait lu une information fausse. Même défaut sur les raccourcis de l’écran Accueil. Les deux sources de vérité (doc Sphinx et aide intégrée) décrivent maintenant la même chose – pas de raison qu’elles divergent à nouveau, mais rien ne les synchronise automatiquement : à surveiller au prochain ajout d’écran.Vérification d’identité côté organisateur : reste un acte humain, FletchScore ne fait qu’aider à recouper. Question posée par l’utilisateur (“comment confirmer l’identité ?”) qui a révélé que l’écran n’affichait pas de quoi vraiment recouper (juste nom/prénom/id fédéral). Ajouté date de naissance et club à l’affichage de chaque demande – les deux informations qu’une carte de licence ou une pièce d’identité permettent de confronter en un coup d’œil. Une note explicite en tête d’écran clarifie la limite : aucune vérification automatique n’existe ni n’est prévue, le rôle du logiciel s’arrête à afficher ce qu’il sait déjà.
verifier_code_court(): délibérément plus faible queverifier_token(), documenté comme tel. Le compétiteur doit pouvoir taper son code à la main sans avoir à recopier le secret complet (peu pratique) – mais un code à 6 caractères depuis un alphabet de 32 (~30 bits) est en théorie devinable par force brute, ce que le HMAC du token complet empêche. Acceptable maintenant : v0.2 n’a encore aucune donnée sensible derrière ce chemin (juste une confirmation d’identité, pas un score). Le docstring de la fonction prévient explicitement qu’il faudra revoir ce compromis avant que la v0.3 (proposition de score) n’y transite – plutôt que de découvrir le problème après coup.Rattachement accessible directement depuis l’accueil, pas seulement depuis la page compétition. Demande de l’utilisateur. Les deux points d’entrée cohabitent (le lien reste aussi sur
page_competition) – coût nul, pas de raison de choisir entre les deux quand garder les deux ne crée aucune incohérence.Écran “Demandes d’accès” : deux onglets (
CTkTabview) plutôt que deux listes empilées. Demandes en attente et accès actifs sont deux vues sur des données différentes (DemandeRattachementvsToken) – les séparer en onglets évite un écran qui grandit sans limite au fil d’une compétition avec beaucoup d’inscrits._rafraichir_tout()recharge les deux après une validation (qui fait passer une entrée de l’un à l’autre) ; un rejet ne touche que la liste des demandes, pas celle des accès actifs, donc reste ciblé.Envoi de message : demandé, pas commencé – rien dans le modèle de données ne le supporte. Contrairement aux autres demandes de cette session (qui branchaient du code déjà existant à une couche supérieure), c’est une vraie fonctionnalité neuve : pas de table
messages, pas de mécanisme de livraison pensé. Cadré avec l’utilisateur (3 questions : bandeau + page dédiée, historique persistant, pas de suivi lu/non lu) avant de coder – voir docs/roadmap.md pour le résultat.Cookie de session signé HMAC pour identifier le compétiteur d’une visite à l’autre. Un message ciblé doit arriver à la bonne personne, ce qui suppose que le serveur sache “qui visite” au-delà d’une seule requête – rien dans l’architecture existante ne portait cette notion (les cookies
lang/themesont de simples préférences, jamais pensés pour porter une identité). Un cookieidentiteen clair aurait été trivialement falsifiable : n’importe qui aurait pu lire les messages de n’importe qui en éditant son cookie à la main.services.signer_identite_competiteur()/verifier_identite_signee()réutilisent le même principe HMAC que les tokens (_hash_token), avec la même clé serveur (securite.obtenir_cle_secrete()) – pas un deuxième mécanisme de signature à maintenir en parallèle. La charge signée porteid_federaletcompetition_idensemble (pas l’id seul) : “Mes messages” doit savoir pour quelle compétition afficher l’historique, un compétiteur pouvant en principe avoir accès à plusieurs. Posé uniquement après unPOST /coderéussi (jamais après une simple consultation en lecture seule),HttpOnly(pas lisible en JS, même si cette page n’en a de toute façon aucun – défense en profondeur), 7 jours de durée de vie (le temps d’un week-end de compétition sans avoir à retaper son code à chaque visite). Vérifié par un test d’intégration de bout en bout, pas seulement les fonctions de signature testées isolément : un vraiPOST /codeproduit un vraiSet-Cookie, ce cookie renvoyé sur un vraiGET /mes-messagesdonne accès aux bons messages.Authentification organisateur : PBKDF2-SHA256 (stdlib), pas bcrypt/argon2. Aucune dépendance compilée à faire fonctionner sur Pydroid 3 (même raisonnement que pour
fpdf2/qrcode, mais cette fois pas de contournement possible viaskipUnless– l’authentification doit fonctionner partout, pas seulement là où une lib optionnelle est installée). 200 000 itérations, sel aléatoire à chaque définition (deux mots de passe identiques donnent des fichiers différents, vérifié par test). Protection optionnelle : sansconfig/auth.toml, FletchScore s’ouvre directement – ne casse rien pour qui ne veut pas de ce réglage, cohérent avec le fait que le poste organisateur est déjà souvent physiquement contrôlé (un club n’a pas forcément besoin de ce niveau de friction). La fenêtre de connexion bloque la construction du reste de l’interface (FenetrePrincipale.__init__s’arrête tôt si l’authentification échoue,lancer()détecte l’attributauthentifieet referme proprement) – pas de fenêtre principale visible, même vide, avant qu’un mot de passe correct n’ait été saisi. Changer ou supprimer le mot de passe redemande l’actuel : évite qu’une session organisateur laissée ouverte suffise à désactiver la protection sans le reconfirmer.Ce fichier et
roadmap.mdintégrés à la doc Sphinx, restés en Markdown. Demande de l’utilisateur : ces deux journaux n’étaient visibles que via le dépôt Git, jamais publiés sur le site généré.myst-parserajouté plutôt que de convertir ~1000 lignes cumulées en RST à la main – un travail mécanique long, à risque d’erreurs de formatage impossibles à vérifier sans pouvoir construire la doc ici. Les deux fichiers restent à leur emplacement actuel (docs/directement) : trop de références à leur chemin exact ailleurs dans le code et la doc pour risquer un déplacement. Toctree séparé (“Suivi du développement”) plutôt que mélangés avec le guide utilisateur ou le cahier des charges – public et nature différents (journal de décisions techniques, pas une documentation destinée à l’utilisateur final).Proposition de score :
StatutScore.PROPOSEenfin branché, prévu depuis la v0.1. L’énumération existait déjà (EMIS/PROPOSE/VALIDE/ REJETE ramenés à PROPOSE/VALIDE/REJETE lors de la simplification du score en v0.1) etscoring.total_scores()filtrait déjà surVALIDEuniquement – mais rien n’écrivait jamaisPROPOSEjusqu’à cette version.services.proposer_score()refuse d’écraser un score déjà validé (seule l’organisateur peut le corriger, écran Saisie) mais permet de reproposer librement tant que rien n’est validé – un scorePROPOSEpeut se faire remplacer par un nouveauPROPOSE, jamais par-dessus unVALIDE.valider_score_propose()rappellesaisir_score_final()avec les mêmes valeurs déjà proposées (pas de nouvelle saisie) : la validation ne fait que changer le statut, jamais les valeurs – si l’organisateur doit corriger un chiffre, il le fait depuis l’écran Saisie habituel, après validation.L’id fédéral d’une proposition vient exclusivement du cookie de session signé, jamais d’un champ de formulaire. Même principe de sécurité que la messagerie ciblée (voir plus haut) : un champ caché
id_federaldans le HTML serait modifiable par n’importe qui avant envoi, permettant de proposer un score au nom de quelqu’un d’autre. Le formulaire de proposition n’apparaît d’ailleurs que si le cookie identifie quelqu’un d’inscrit à cette épreuve précise et qui n’a pas déjà de score officiel – trois conditions vérifiées côté serveur avant même d’afficher le formulaire, pas seulement à la soumission. Bug de texte repéré en relisant une page générée réellement, une fois de plus : le lien de retour après une proposition disait “vers la compétition” en pointant en fait vers l’épreuve – corrigé avec une clé i18n dédiée (retour_epreuve), même famille de bug que pour le rattachement (retour_competition) quelques versions plus tôt. Vérifié réellement à deux niveaux : bout en bout services (proposer → lister → valider → le classement passe de 0 à 270 points) et bout en bout HTTP (vraiPOST /code→ vrai cookie → vraiPOST /proposer-score→ vraiScoreen base avec statutpropose).Demande de rattachement : refusée si un accès valide ou une demande en attente existe déjà, mais pas si l’accès a été révoqué. Trou repéré par l’utilisateur : sans ce garde-fou, valider une deuxième demande émettait un second token pour le même (compétiteur, compétition), sans jamais révoquer le premier – deux codes valides simultanés, source de confusion côté organisateur (une demande qui n’aurait jamais dû exister) et côté compétiteur (lequel des deux codes est le bon ?).
_a_deja_un_acces_valide()réutiliseToken.est_valide()– une révocation explicite débloque donc bien une nouvelle demande, volontairement : empêcher indéfiniment quelqu’un dont l’accès a été retiré de le redemander n’aurait aucun sens. Double protection appliquée, pas seulement le backend : le lien “Demander un accès” et le formulaire de recherche disparaissent de l’accueil, de la page compétition et de la page de rattachement elle-même dès que le cookie de session identifie déjà ce compétiteur pour cette compétition précise – remplacés par un simple message “Accès déjà confirmé”. Le backend reste la garde réelle (protège même si l’UI est contournée ou en cache), l’UI n’est qu’un confort pour ne pas laisser cliquer sur une action vouée à échouer.Accueil : formulaire “code” masqué, bienvenue personnalisée, statut par épreuve. Trois demandes de l’utilisateur après un test réel, sur le même principe déjà établi pour le lien de rattachement : ne pas proposer une action qui n’a plus lieu d’être une fois identifié.
_statut_epreuve_pour()fait une requête par épreuve listée (inscription puis score) – volontairement pas optimisé en une seule requête groupée, la volumétrie club (quelques dizaines d’épreuves par compétition tout au plus) ne le justifie pas, et la lisibilité du code (une fonction qui répond à une question précise) prime tant que ça reste largement assez rapide. Statut affiché uniquement pour la compétition à laquelle la session est identifiée (identifie_ici, recalculé par compétition dans la boucle) – jamais le statut de quelqu’un d’autre, ni celui d’une compétition à laquelle le compétiteur n’a pas accès. Vérifié réellement sur une page complète générée avec un compétiteur inscrit à une épreuve (score en attente) et non inscrit à une autre – les deux statuts corrects côte à côte, pas seulement des assertions unitaires isolées.LimiteurDebit: fenêtre glissante en mémoire, horloge injectable pour les tests. Pas de dépendance externe (pas de Redis ni équivalent – disproportionné pour un serveur qui tourne le temps d’une compétition sur un poste local), pas de persistance en base (un redémarrage remet les compteurs à zéro, acceptable dans ce contexte). L’horloge est un paramètre injectable (horloge=time.monotonicpar défaut) précisément pour pouvoir tester une fenêtre glissante de plusieurs minutes sans vraies pauses dans la suite de tests – une horloge factice avance instantanément le temps simulé. Limite plus stricte surPOST /code(10/5 min) que sur les autres écritures (30/5 min) :/codedevine un secret (le code court, ~30 bits, voirservices.verifier_code_court), les autres écritures ne devinent rien, une limite anti-spam plus large suffit. Vérifié réellement avec un vrai serveur : 10 vraies requêtes passent, la 11e reçoit un vrai HTTP 429 – pas seulementLimiteurDebittesté en isolation.HTTPS local :
cryptography, décision explicitement confirmée par l’utilisateur malgré le risque de compatibilité Pydroid. Trois options envisagées :cryptography(génération automatique, risque de compatibilité non vérifiable ici faute de réseau), appeleropensslen CLI (présence incertaine sur Pydroid), ou demander un certificat fourni par l’utilisateur (zéro dépendance, plus de friction). L’utilisateur a choisi la première malgré le risque assumé – bonne surprise en pratique : contrairement àfpdf2/qrcode,cryptographys’est révélée réellement disponible dans cet environnement de développement, ce qui a permis de vérifier tout le chantier HTTPS avec de vrais tests d’intégration (vraie poignée de main TLS, pas seulement des fonctions testées isolément) – une confiance qu’on n’a pas pu avoir pour fpdf2/qrcode/auth.certificat_https.pygénère un certificat auto-signé (RSA 2048, SHA-256, 10 ans – usage local, pas de raison de le faire tourner) une seule fois, réutilisé ensuite ;creer_serveur(..., https=True)enveloppe le socket déjà lié (server_bind/server_activate, faits parHTTPServer.__init__) dans unssl.SSLContext, plutôt qu’une configuration TLS spéciale au niveau de la classe du serveur. Même piège que_hash_tokendéjà rencontré : le premier jet decreer_serveurappelaitobtenir_certificat()sans arguments, utilisant son propre défaut figé à la définition plutôt que de relire l’attribut du module – unmock.patchen test n’avait alors aucun effet. Corrigé en passant les chemins explicitement (même correctif que pour la clé secrète serveur). Fuite de fichier intermittente et non totalement expliquée, observée une fois sur une dizaine de lancements complets de la suite pendant le développement : plutôt que de la laisser sans réponse claire, un filet de sécurité explicite en fin de test supprime tout fichier qui se serait retrouvé au vrai chemin par défaut – confirmé propre sur 5 relances après coup. Le risque réel restait de toute façon nul (ces chemins sont gitignorés, jamais committables), mais autant nettoyer que laisser un mystère.Réorganisation GUI : 10 écrans -> 8, cadrée en plusieurs allers-retours avant de coder. Demande de l’utilisateur, la v0.3 quasiment close. Trois décisions affinées au fil de la discussion (pas devinées d’un coup) : (1) fusionner Vue compétiteur + Demandes d’accès + Propositions de score, proposé par l’assistant ; (2) l’utilisateur a voulu séparer messages et demandes d’accès (“ce ne sont pas les mêmes principes”) ; (3) l’utilisateur a ensuite recollé messages et serveur ensemble (“le serveur et les messages vont ensemble”), et déplacé les propositions de score vers l’écran de saisie plutôt que de les garder avec les demandes d’accès. Résultat final :
gui/ecran_saisie.pygagne un onglet “Propositions en attente” (fusion avec l’ancienecran_propositions.py) ; nouveaugui/ecran_connexions.pyfusionne les anciensecran_vue_competiteur.pyetecran_rattachement.py(contrôles serveur + demandes/accès/messages) ;ecran_securite.pyrenomméecran_mot_de_passe.py(le nom “Sécurité” prêtait à confusion une fois “Connexions compétiteurs” en place). Bug repéré et corrigé en relisant : une fausse syntaxe markdown (**gras**) s’était glissée dans le texte de l’écran Aide –CTkLabeln’interprète aucun texte enrichi, elle se serait affichée en toutes lettres avec les astérisques. Les anciens fichiers fusionnés ont été supprimés plutôt que laissés en doublons morts. Comme toujours pour la GUI, rendu non vérifié – à confirmer par un vrai lancement.Procuration : cadrée avec l’utilisateur avant de coder, pas devinée. Deux questions posées et tranchées : (1) portée – ouvert à n’importe qui inscrit à la compétition, pas restreint à la même épreuve, mais toujours soumis à validation organisateur avant effet ; (2) traçabilité – indispensable, sinon l’organisateur validerait un score sans savoir qui l’a réellement soumis. D’où
Score.propose_par_id_federal, distinct de l’inscription ciblée : une proposition porte toujours deux identités, celle du score (via l’inscription) et celle du proposant réel, jamais confondues. Tableprocurationssans contrainte UNIQUE en base – une contrainte aurait empêché de redemander après un rejet ; la détection de doublon (déjà en attente, déjà validée) vit dansservices.py, cohérent avecdemander_rattachement().proposer_score()garde une signature rétrocompatible (id_federal_cibleoptionnel, absent = soi-même) plutôt que de forcer tous les appelants existants à changer. Changement de schéma surscores(nouvelle colonne) : pas de système de migration sur ce projet, seule option pour une base déjà existante est de la supprimer et relancer – déjà le cas pour les changements de schéma précédents, documenté à nouveau ici plutôt que supposé connu. Vérifié réellement de bout en bout, pas seulement par les tests unitaires : demande de procuration → refus tant qu’elle n’est pas validée → validation → proposition acceptée avec traçabilité correcte → validation organisateur → le compétiteur mandant apparaît bien au classement, dans sa propre catégorie, avec le bon total.Suite du point précédent, résolu par l’issue #5 (2026-08-12) : système de migration ajouté (
storage/db.py::MIGRATIONS, tableschema_version) – pur SQL/Python, pas de dépendance externe (Alembic…), cohérent avec la philosophie “stdlib d’abord” du projet.init_schema()distingue une base neuve (part directement de la dernière version,_SCHEMAcréant déjà tout à jour) d’une base préexistante sansschema_version(part de la version 0, migrations rejouées dans l’ordre). La colonnepropose_par_id_federaldevient la première migration (MIGRATIONS[0]), rétroactivement. Vérifié sur un vrai fichier SQLite (pas seulement:memory:) : base créée avec l’ancien schéma (sans la colonne, sansschema_version), rouverte viaouvrir_base()(le vrai point d’entrée de production) – données préservées, colonne ajoutée, version correcte, stable à une 2e réouverture.Procuration côté web : la cible peut venir du formulaire, le mandataire jamais. Même distinction que pour la proposition de score simple :
id_federal_cible(pour qui) est un choix légitime côté client puisqueservices.proposer_score()revérifie systématiquement l’autorisation avant tout effet – un client malveillant qui forcerait une autre valeur se ferait juste refuser côté serveur. L’id du mandataire (qui demande/qui propose), lui, vient exclusivement du cookie de session signé – jamais un champ de formulaire, qui serait modifiable par n’importe qui avant l’envoi._section_proposer_score()construit la liste des candidats proposables (soi-même si inscrit, chaque mandant avec une procuration validée ET inscrit à cette épreuve précise) puis choisit entre un champ caché (un seul candidat, pas la peine d’un menu) et un<select>(plusieurs) – pas de pré-remplissage du total/X selon la cible choisie, impossible sans JavaScript (choix déjà fait pour toute cette page) ; les lignes de statut au-dessus du formulaire montrent déjà la valeur actuellement proposée pour chacun. Vérifié réellement par un vrai flux HTTP complet :POST /code→ cookie →POST /procuration→ demande en base → validée côté service →POST /proposer-scoreavecid_federal_cible→Scoreen base avec le bonpropose_par_id_federal– pas seulement les fonctions de génération de page testées isolément.Procurations validées invisibles côté GUI, corrigé (issue #1) –
revoquer_procuration()existait côté service dès le départ, maisgui/ecran_connexions.pyn’affichait quelister_procurations_en_attente(): une fois validée, une procuration sortait de la vue sans moyen de la révoquer autrement qu’en base directement.db.list_procurations_validees()/services.lister_procurations_validees()ajoutés sur le même schéma quelist_tokens_by_competition()/lister_tokens_actifs()(déjà utilisé pour l’onglet “Accès actifs”), avec une seconde liste “Procurations actives” + bouton Révoquer dans l’onglet “Procurations”.CompetitionTemplate: même principe qu’EpreuveTemplate, un cran au-dessus (issue #25, 2026-08-14). Un modèle de compétition est un bundle de plusieurs(nom, bareme_id)–CompetitionTemplateEpreuveporte unordreexplicite (pas l’ordre d’insertion en base seul, pas garanti stable) pour préserver l’ordre voulu par l’organisateur en enregistrant le modèle. Changement de schéma purement additif (deux nouvelles tables) – pas besoin d’une entrée dansstorage.db.MIGRATIONS(voir issue #5) :CREATE TABLE IF NOT EXISTSs’applique identiquement à une base neuve ou déjà existante, contrairement à l’ajout d’une colonne sur une table déjà créée.creer_competition_depuis_template()délègue àcreer_competition()puiscreer_epreuve()en boucle – même principe quecreer_epreuve_depuis_template(), ne duplique aucune validation. Chaque épreuve générée prenddate_debutde la compétition comme date par défaut (un modèle ne porte aucune date, même raison que pourEpreuveTemplate) ; pas de mécanisme de dates par épreuve dans le modèle envisagé pour ce cas – l’organisateur ajuste après coup viamodifier_epreuve(), déjà existant, plutôt que d’complexifier le modèle pour un besoin marginal (compétitions sur plusieurs jours avec des épreuves à des dates différentes). GUI : sélecteur de modèle ajouté au formulaire de compétition (ne préremplit rien, contrairement au sélecteur d’épreuve – un modèle de compétition n’a ni nom ni dates à préremplir, juste mémorisé jusqu’à la soumission), désactivé et réinitialisé en mode édition (un modèle n’a de sens qu’à la création). Rendu vérifié réellement (Xvfb + capture d’écran, pas seulement les tests unitaires) : scénario complet démarré via le vrai écran GUI (EcranCompetitions) – enregistrer une compétition à deux épreuves comme modèle, sélectionner ce modèle, soumettre une nouvelle compétition, confirmer que les deux épreuves attendues apparaissent bien dans la colonne de droite avec les bons noms/barèmes/dates.Droit à l’effacement RGPD : anonymisation plutôt que suppression complète (issue #37, 2026-08-14). Décision cadrée directement avec l’utilisateur avant de coder, comme demandé par le ticket : sa crainte concrète était qu’une suppression pure et simple d’un compétiteur déjà classé fasse “remonter” silencieusement les rangs suivants, faussant rétroactivement un classement peut-être déjà publié ou imprimé.
services.anonymiser_competiteur()garde doncScore/Inscriptionintacts – seuls nom/prénom (remplacés parCompétiteur/{id_federal}) et licence sont effacés sur la ficheCompetiteur, qui elle-même reste en base (pas de suppression de la ligne).id_federalconservé comme clé technique référencée partout (tokens, inscriptions…) plutôt que remplacé – documenté explicitement comme une pseudonymisation, pas une anonymisation RGPD stricte : la fédération pourrait toujours faire le lien via ce numéro dans son propre système. Une vraie anonymisation aurait demandé soit de garderid_federal(même limite), soit de le remplacer en cascade dans toutes les tables qui le référencent – jugé disproportionné pour le gain, la cible principale (nom/prénom, les données les plus directement identifiantes) étant déjà atteinte.Tokens, procurations (comme mandataire et comme mandant), demandes de rattachement et messages ciblés (
messages.id_federalégal à cette personne, jamais les messages diffusés à tous où ce champ estNULL) sont en revanche supprimés – l’accès de ce compétiteur doit cesser après une demande d’effacement, aucune raison légitime de garder un token ou une procuration active pour quelqu’un qui a demandé à être oublié.db.anonymiser_competiteur()fait tout ça en une seule transaction (try/except/rollbackautour de plusieursconn.execute(), un seulcommit()final, même pattern que_appliquer_migrations()de l’issue #5) – un état à moitié anonymisé (nom déjà effacé mais token encore valide) serait pire que l’état de départ.GUI (
gui/ecran_competiteurs.py) : bouton 🗑 sur chaque ligne de la liste des compétiteurs, confirmation obligatoire (même pattern queFenetrePrincipale._confirmer_quitter–CTkToplevel+transientgrab_setdifféré +wait_window) avant toute action, irréversible une fois confirmée. 10 tests, dont un qui reproduit exactement le scénario redouté par l’utilisateur (3 compétiteurs classés 1er/2e/3e, le 2e anonymisé, vérifie que le 3e reste 3e – pas de décalage de rang). Vérifié réellement (Xvfb) :_anonymiser_competiteur()invoqué depuis le vrai écran GUI, dialogue retrouvé dans la hiérarchie de widgets réelle (piège découvert en écrivant ce test : unCTkToplevel(self)créé avecself= l’écran comme parent apparaît dansself.winfo_children(), pas dansroot.winfo_children()), bouton “Anonymiser” cliqué via.invoke(), état de la base confirmé après coup (nom/prénom modifiés, score et total inchangés).
Sauvegarde/restauration d’une compétition : format JSON autoportant, pas un simple export des tables demandées (issue #7, 2026-08-14). Le critère d’acceptation initial ne nommait que “épreuves, inscriptions, scores” – insuffisant en pratique pour “transférer d’une machine à une autre” (le besoin explicitement noté dans
docs/roadmap.md, section import/export) : uneInscriptionréférence unid_federalpar clé étrangère, qui doit exister côté cible. Étendu pour embarquer aussi les clubs/compétiteurs/barèmes référencés – sans ça, réimporter sur une machine qui ne les connaît pas déjà échouerait sur des clés étrangères manquantes dès la première ligne.Résolution des conflits, décidée par catégorie d’entité plutôt qu’une règle unique :
Competition(identifiant aléatoire, une seule origine légitime) : refusé si l’id existe déjà côté cible – pas de fusion, un import réussi ou pas du tout (ErreurSauvegardeexplicite : “déjà restaurée précédemment ?”).Club/Competiteur/Bareme(identifiants stables, réels –code_club,id_federal, souvent un barème préconfiguré déjà seedé au démarrage normal) : réutilisés tels quels s’ils existent déjà côté cible, jamais dupliqués ni écrasés – le cas normal étant justement que le club/compétiteur soit déjà connu de la machine cible (même club, même archer).
db.importer_donnees_competition()écrit tout en une seule transaction (try/except/rollback, un seulcommit()final – même pattern quedb.anonymiser_competiteur()de l’issue #37 et_appliquer_migrations()de l’issue #5) : une compétition à moitié restaurée serait pire qu’un échec net. Ordre d’insertion contraint par les clés étrangères (clubs avant compétiteurs, barèmes avant épreuves, compétition avant épreuves, compétiteurs+épreuves avant inscriptions, inscriptions avant scores) – la résolution “réutiliser ou créer” (lecture seule,db.get_club/get_competiteur/get_bareme) vit dansio/sauvegarde_competition.py, en amont de cet appel unique, pour garder la fonctiondb.pysimple (une liste déjà tranchée de ce qu’il faut réellement écrire, rien à décider sur place).Volontairement hors périmètre : tokens, demandes de rattachement, procurations, messages – état d’accès/session propre à la machine d’origine, pas des données “de compétition” à proprement parler (un token exporté serait de toute façon inutilisable, seul son hash est stocké, jamais le secret en clair).
Format JSON choisi plutôt qu’un format binaire ou une copie du fichier SQLite entier – lisible/diffable à la main en cas de souci, pas de dépendance externe (cohérent avec la philosophie “stdlib d’abord” du projet), et surtout scopé à une seule compétition (contrairement à une copie du
.dbcomplet, qui embarquerait tout le reste de la base – pas ce qui était demandé). Champformat_versiondès la v1, pour permettre de faire évoluer le format plus tard sans casser silencieusement la restauration d’anciennes sauvegardes.GUI (
gui/ecran_competitions.py) : bouton 📦 sur chaque compétition listée (export), bouton 📥 Restaurer dans l’en-tête de la colonne Compétitions (pas par ligne – une restauration crée une compétition, elle n’en modifie pas une existante). 8 tests, dont un qui construit une vraie base “cible” sans aucun barème préchargé pour vérifier que le barème vient bien de la sauvegarde et pas d’un référentiel déjà là, et un qui vérifie qu’unclassement_epreuve()fonctionne normalement sur des données fraîchement restaurées (pas seulement que les lignes existent en base). Vérifié réellement (Xvfb) :_sauvegarder_competition()/_restaurer_competition()invoquées depuis les vrais boutons GUI (popup de saisie de chemin simulé par substitution ciblée dedemander_chemin, pas la logique testée elle- même), sur une vraie seconde base construite à la volée pour simuler une autre machine – compétition, score et inscription confirmés après coup.HTTPS activé par défaut sur la vue compétiteur (issue #39, 2026-08-14).
ConfigGui.https_actifpasse deFalseàTrue– article 32 RGPD (mesures techniques “appropriées” pour la sécurité du traitement) : sans HTTPS, ce qui transite sur le wifi du club (noms, scores, cookies de session) est en clair.SECURITY.mddocumentait déjà le certificat auto-signé (avertissement navigateur à accepter une fois) comme comportement attendu – ce ticket ne change que le réglage de départ, pas le mécanisme.Deux pièges trouvés et corrigés avant même de lancer un test, en relisant le mécanisme existant à la lumière du nouveau défaut :
ConfigGui.sauvegarder()n’écrivaithttps_actifdans le TOML que lorsqu’il valaitTrue(if config.https_actif: ...) – inoffensif tant que le défaut étaitFalse(unFalseexplicite et une absence de préférence se confondaient sans dommage), mais aurait silencieusement effacé un désactivement explicite au prochain lancement une fois le défaut passé àTrue(charger()serait retombé surTruefaute de clé écrite). Corrigé en écrivant désormais toujours la clé, comme n’importe quel booléen sans “non défini” légitime (contrairement àhttp_port, oùNonea un sens réel).gui/ecran_connexions.pysélectionnait la case à cocher avant de vérifier sicryptographyest disponible – avec l’ancien défautFalse, la case ne se retrouvait jamais cochée+désactivée en même temps ; avecTrue, l’ordre inversé aurait produit une case cochée puis immédiatement grisée (state="disabled"empêche toute interaction), un blocage sans issue pour l’utilisateur au moment de démarrer le serveur. Corrigé en vérifiant la disponibilité d’abord : case décochée et grisée sicryptographymanque, quel que soithttps_actifenregistré ; sélectionnée seulement dans la branche “disponible”.
Le texte d’aide de l’écran Connexions et le résumé de l’écran Aide (
gui/i18n.py, clésconnexions_https_note/aide_desc_connexions) ainsi quedocs/guide-utilisateur/ecrans.rstprésentaient HTTPS comme une simple option – mis à jour pour refléter le nouveau défaut.TestServeurIntegration(démarre volontairement en HTTP simple pour les tests automatisés) confirmé inchangé – il construit son serveur directement viacreer_serveur(..., https=False), sans passer parConfigGui. Vérifié réellement (Xvfb) : les deux scénarios degui/ecran_connexions.py::EcranConnexionsconstruits avec un vraiCTk(),cryptographysimulée disponible puis indisponible viaunittest.mock.patchsurCRYPTOGRAPHY_DISPONIBLE– état réel de la case (cget("state")/get()) lu sur le widget vivant après un délai de rendu, pas seulement relu dans le code.Bouton “Modifier” -> icône ✏️ seule (issue #47, 2026-08-15). Clé i18n
modifier(une seule valeur FR/EN, c’est une icône, pas un mot) remplacée par"✏️"sur les 4 lignes de liste concernées (compétiteurs, sélecteur de club, compétitions, épreuves), largeur de bouton ramenée de 80 à 36px – même logique que 💾/📦/🗑 déjà en icône seule sur ces mêmes lignes (#7/#37/#42), nécessaire une fois les boutons de suppression du #43 ajoutés dessus. La clé distinctemodifier_avec_nom(titre du formulaire en mode édition) n’est pas concernée, ce n’est pas un bouton.Supprimer un compétiteur qui n’a jamais concouru (issue #43, 2026-08-15). Distinct de
anonymiser_competiteur(#37) : là où l’anonymisation garde la ligneCompetiteurpour ne pas fausser un classement déjà publié,services.supprimer_competiteur()efface réellement la ligne – réservé aux compétiteurs qui n’ont jamais été inscrits nulle part, donc sans aucun classement à protéger. Refusé (ErreurMetier) dès la moindre inscription, même sans score, dans n’importe quelle épreuve –db.list_inscriptions_by_competiteur()(nouvelle, toutes épreuves confondues) sert cette vérification. Le seul chemin pour un compétiteur déjà engagé reste l’anonymisation – décision volontairement non révisée ici, pas de retour en arrière sur ce qui avait été tranché pour le #37.db.supprimer_competiteur()supprime aussi tokens, procurations (mandataire et mandant), demandes de rattachement et messages ciblés – même liste quedb.anonymiser_competiteur(), un accès sans fiche compétiteur derrière n’ayant plus de sens. Transaction unique (même patterntry/except/rollback+ un seulcommit()final).GUI (
gui/ecran_competiteurs.py) : bouton ❌ ajouté à côté du 🗑 existant (anonymisation) sur chaque ligne – symbole volontairement différent pour ne pas laisser croire aux deux actions qu’elles font la même chose, vu que les conséquences divergent complètement (suppression réelle et irréversible vs anonymisation RGPD qui garde le score). Même modèle de confirmation que_confirmer_anonymisation(CTkToplevel+transient+grab_setdifféré +wait_window). 9 tests (TestSupprimerCompetiteur), dont un qui vérifie qu’une inscription dans une épreuve différente de celle testée bloque quand même la suppression (le refus n’est pas scopé à une épreuve). Vérifié réellement (Xvfb) : les deux scénarios (compétiteur jamais inscrit -> suppression réussie ; compétiteur déjà inscrit -> refus, message d’erreur affiché) déclenchés depuis les vrais boutons ❌ et le vrai dialogue de confirmation du GUI, état de la base confirmé après coup dans les deux cas. Piège Tk rencontré en écrivant le scénario de test : le bouton “Supprimer” du dialogue doit être programmé (viaafter()) avant d’invoquer le bouton ❌ qui ouvre ce dialogue –wait_window()bloque tout code placé après l’appel qui l’a déclenché, jusqu’à ce que le dialogue soit détruit.Supprimer une épreuve vide (issue #44, 2026-08-15).
services.supprimer_epreuve()refuse dès qu’un score existe pour l’épreuve, même un seul (db.epreuve_a_des_scores(), déjà utilisée pour bloquer un changement de barème après saisie – réutilisée telle quelle plutôt que dupliquée) – pas de cascade sur les scores. Contrairement au #43 (compétiteur), une inscription sans score est en revanche supprimée avec l’épreuve plutôt que de bloquer aussi dessus : rien d’irréversible n’est en jeu (portée explicitement tranchée avec l’utilisateur avant de coder, le ticket laissait la question ouverte). Un seul score sur une épreuve à plusieurs inscriptions bloque toute la suppression – jamais de suppression partielle.Même règle que
modifier_epreuve()sur une compétition clôturée : pas plus supprimable que modifiable, pour rester cohérent avec une règle métier déjà en place plutôt que d’introduire une incohérence (modification bloquée, suppression permise) sur le même état.db.supprimer_epreuve()supprime l’épreuve et ses inscriptions en une seule transaction (même patterntry/except/rollbackque les autres suppressions de ce lot).GUI (
gui/ecran_competitions.py, colonne Épreuves) : bouton ❌ ajouté à côté de ✏️/💾, même modèle de confirmation que le #43. 6 tests (TestSupprimerEpreuve), dont un qui vérifie explicitement la suppression en cascade des inscriptions sans score, et un qui vérifie qu’une seule inscription notée parmi plusieurs bloque tout. Vérifié réellement (Xvfb) : suppression réussie sur une épreuve vide et refus (message d’erreur affiché) sur une épreuve notée, déclenchés depuis les vrais boutons et le vrai dialogue de confirmation du GUI, état de la base confirmé après coup dans les deux cas.Supprimer une compétition vide (issue #45, 2026-08-15).
services.supprimer_competition()refuse dès qu’un score existe dans n’importe laquelle des épreuves de la compétition, même un seul (réutilisedb.epreuve_a_des_scores()épreuve par épreuve, même fonction que pour le #44) – pas de cascade forcée sur des données notées. En l’absence de score, cascade complète : ses épreuves, leurs inscriptions (aucune n’a de score si on arrive là) et son état d’accès propre (tokens, procurations, demandes de rattachement, messages) – sans objet une fois la compétition partie. Contrairement au #44, le statut de la compétition (clôturée ou non) n’est volontairement pas vérifié :modifier_competition()ne bloque déjà pas dessus (clôturer est une action à part, pas un simple champ, voir sa docstring), pas de raison d’introduire une règle plus stricte côté suppression que celle déjà en place côté modification.db.supprimer_competition()écrit tout en une seule transaction, même pattern que les autres suppressions de ce lot.GUI (
gui/ecran_competitions.py, colonne Compétitions) : bouton ❌ ajouté à côté de ✏️/💾/📦, même modèle de confirmation. Si la compétition supprimée était celle sélectionnée, la colonne Épreuves revient à son état initial (aucune sélection, désactivée) plutôt que de garder une référence à une compétition qui n’existe plus. 8 tests (TestSupprimerCompetition), dont un qui vérifie qu’un score dans une épreuve bloque toute la suppression même si une épreuve sœur de la même compétition est vide. Vérifié réellement (Xvfb) : suppression réussie avec cascade (épreuve + inscription sans score) et refus (message d’erreur affiché, sélection intacte) sur une compétition notée, déclenchés depuis les vrais boutons et le vrai dialogue du GUI, état de la base confirmé après coup dans les deux cas.Annuler une inscription sans score (issue #46, 2026-08-15). Dernier ticket du lot suppression – le plus simple des quatre :
services.annuler_inscription()refuse dès qu’un score existe pour cette inscription (db.get_score_by_inscription(), déjà utilisée ailleurs).db.get_inscription()(nouvelle, recherche par id primaire – seuleget_inscription_par_competiteur_epreuve()existait) etdb.supprimer_inscription()(une seule instruction, pas de transaction dédiée nécessaire contrairement aux suppressions en cascade du reste du lot).GUI (
gui/ecran_saisie.py, onglet Saisie manuelle) : bouton ❌ affiché sur chaque ligne d’inscrit·e – masqué (pas juste désactivé) dès qu’un score existe, plutôt que affiché-puis-erreur : la ligne affiche déjà le score ("-- N pts"), pas besoin d’un clic pour découvrir un état déjà visible d’un coup d’œil. Différent en ça du choix fait pour #43/#44/#45, où l’état bloquant n’était pas visible directement sur la ligne. Réutilise le pattern de confirmation (CTkToplevel+transient+grab_setdifféré +wait_window) des trois autres tickets du lot. Si l’inscription annulée était celle sélectionnée dans le panneau de saisie, la sélection et le panneau de score sont réinitialisés. 4 tests, dont un qui vérifie qu’après annulation le même compétiteur peut être réinscrit sans heurter la contrainteUNIQUE (id_federal, epreuve_id). Vérifié réellement (Xvfb) : absence du bouton ❌ sur une inscription déjà notée, et annulation réussie (avec message de confirmation affiché) sur une inscription sans score, déclenchées depuis le vrai écran GUI.RGPD – politique de conservation par purge d’inactivité (issue #40, 2026-08-15). Ni le RGPD ni la doctrine CNIL spécifique au sport amateur ne fixent de durée précise pour un club (vérifié avant de coder, pas supposé – voir les sources citées dans
docs/cahier-des-charges/securite.rst, section “Conservation des données”) : la page CNIL dédiée aux structures sportives renvoie explicitement à une méthodologie et demande à chaque structure de justifier sa propre durée. Décision prise avec l’utilisateur : 3 ans depuis la dernière inscription, par analogie avec le seul chiffre que la CNIL documente réellement (doctrine fichiers clients/prospects) – compatible avec le besoin exprimé (garder au moins la saison précédente, délai qui se réinitialise à chaque nouvelle inscription plutôt qu’une date figée par fiche).services.lister_competiteurs_inactifs(conn, date_reference, delai_annees=3)–date_referenceen paramètre explicite plutôt quedate.today()interne, pour rester testable de façon déterministe (même principe queCompetiteur.categorie_age()). Ne liste que les compétiteurs ayant déjà concouru au moins une fois (dernière activité calculée viadb.date_derniere_activite_competiteur(), nouvelle –MAX(epreuves.date)sur toutes leurs inscriptions) : sans aucune inscription, un compétiteur est déjà supprimable sans attendre (voirsupprimer_competiteur, #43), pas besoin d’un délai d’inactivité pour lui. Exclut aussi ceux déjà anonymisés (prenom == "") – rien de plus à purger pour eux.Purge = anonymisation (#37), pas suppression physique – garde scores/classements intacts. GUI (
gui/ecran_competiteurs.py) : bouton 🕒 Inactifs (RGPD) ouvrant une fenêtre dédiée (pas un bloc permanent sur l’écran principal, vu que c’est une action occasionnelle) listant les compétiteurs éligibles, chacun avec un bouton 🗑 qui réutilise directement_anonymiser_competiteur()(même confirmation, même mécanisme que le #37) puis rafraîchit la liste de la fenêtre – aucun automatisme, chaque anonymisation reste un clic + une confirmation explicites de l’organisateur. 8 tests (TestListerCompetiteursInactifs), dont un qui vérifie que l’inégalité au seuil est stricte (pile 3 ans après la dernière activité n’est pas encore éligible) et un qui vérifie qu’une inscription plus récente sur une deuxième épreuve “rafraîchit” bien l’activité plutôt que de rester bloqué sur la première. Vérifié réellement (Xvfb) : fenêtre ouverte depuis le vrai bouton, contenu confirmé (seul le compétiteur réellement inactif apparaît, pas celui récemment inscrit), anonymisation déclenchée depuis cette fenêtre et disparition immédiate de la ligne une fois traitée.RGPD – droit d’accès et portabilité (« Mes données ») (issue #38, 2026-08-15). Complémentaire du #37 (droit à l’effacement) : avant de pouvoir demander une suppression ou une correction, un compétiteur doit d’abord pouvoir voir ce qui est enregistré sur lui (article 15), et pouvoir l’emporter dans un format structuré (article 20).
services.rassembler_donnees_personnelles(conn, id_federal)– volontairement pas scopé à la compétition de la session en cours (contrairement àlister_messages_pour(), utilisée parpage_mes_messages) : l’article 15 porte sur l’ensemble des données détenues, pas seulement l’événement du moment, etid_federalest déjà vérifié par le cookie de session – rassembler ses données d’autres compétitions n’expose rien à un tiers. Regroupe identité (nom/prénom/naissance/club/style/licence), toutes ses inscriptions avec le score associé s’il existe (proposé ou validé), et ses procurations (comme mandataire et comme mandant, tout statut, toute compétition – nouveaudb.list_procurations_by_competiteur()).services.donnees_personnelles_en_dict()convertit ce résultat en dict de types JSON natifs pour la portabilité – format volontairement simple, l’article 20 n’impose qu’un format structuré lisible par machine, rien de plus sophistiqué n’est nécessaire à ce volume.Vue compétiteur web (
api/competiteur.py) :page_mes_donnees()(nouvelle page/mes-donnees, même politique d’accès que/mes-messages– identité vérifiée par cookie de session, sinon redirection 302 vers l’accueil) affiche identité, inscriptions/scores et procurations ; lien ⬇ Télécharger mes données (JSON) vers/mes-donnees/export.json, servi avecContent-Disposition: attachment(nouvelle méthode_repondre_json_telechargeable()) pour déclencher un vrai téléchargement plutôt qu’un affichage brut. Lien Mes données ajouté sur l’accueil à côté de “Se déconnecter”, plus une entrée FAQ dédiée sur la page Aide. 21 tests au total (services + génération de page + intégration bout-en-bout avec un vrai serveur HTTP).Vérifié avec un vrai navigateur (Selenium + Chromium headless, pas seulement une lecture du HTML généré) –
playwrightnon installable dans cet environnement (pip install playwrightéchoue, dépôt indisponible), Selenium +chromium-chromedriver(apk add) fonctionnent en revanche très bien pour le même usage ; voirCLAUDE.mdpour la recette complète (flags Chromium nécessaires, sans quoi l’init GPU/Vulkan échoue en boucle et ralentit beaucoup le démarrage). Scénario : lien “Mes données” cliqué depuis un vrai DOM rendu, contenu de la page confirmé (identité, club, épreuve, score), export JSON téléchargé et recoupé avec le contenu HTML affiché – cohérents entre eux.Captures d’écran de la doc, régénérables plutôt que jetables (issue #11, 2026-08-15).
scripts/capture_screenshots_doc.py– une seule base de démo fictive (_construire_base_demo(), même prénoms/noms que la suite de tests) réutilisée pour les deux modes de capture, choisis pour donner à chaque écran quelque chose de réel à montrer (scores validés, une proposition en attente, une demande de rattachement en attente, un message ciblé et un diffusé, une procuration) plutôt qu’une base vide peu représentative.Organisateur (7 écrans – Accueil, Compétitions, Compétiteurs, Saisie, Classement, Connexions compétiteurs, Aide) : même mécanisme Xvfb +
scrotque les vérifications GUI habituelles de ce projet, mais recadré sur la fenêtre réelle (xdotool getwindowgeometry+scrot -a x,y,w,h) plutôt que la capture plein écran utilisée pour les vérifications ponctuelles – une capture destinée à la doc ne doit pas contenir de zone vide autour de la fenêtre.Vue web compétiteur (3 captures – accueil identifié, classement d’une épreuve, formulaire “Proposer mon score”) : Selenium + Chromium headless (voir l’entrée #38 ci-dessus pour le choix par rapport à
playwright),driver.save_screenshot().
Sauvegardées dans
docs/guide-utilisateur/screenshots/, référencées via.. figure::dansecrans.rstetpremiers-pas.rst.ecran_aide.py(aide intégrée à l’application) reste volontairement texte seul – pas d’image embarquée dans le paquet distribué (pip, exécutables PyInstaller) pour un contenu déjà accessible en un clic via “Ouvrir la documentation en ligne”, décision documentée directement dansecrans.rstplutôt que laissée implicite.Le script est committé, pas un one-off jeté après usage – répond explicitement au critère d’acceptation du ticket (“captures à refaire à chaque changement visuel notable d’un écran”), sans quoi la doc se serait remise à dériver du rendu réel dès le premier changement d’écran. Vérifié réellement : script relancé une seconde fois après avoir vidé le dossier de sortie – mêmes 10 fichiers regénérés à l’identique (taille en octets stable, données de démo déterministes), confirmant que la régénération fonctionne pour de vrai, pas seulement testée une fois de façon ad hoc au moment de l’écrire.
Club organisateur d’une compétition (issue #48, 2026-08-16). Repéré en affinant le #9 (export PDF personnalisable) : impossible d’écrire “Organisé par [club]” sur un export tant que
Competitionne sait pas à quel club elle appartient – seuls les compétiteurs avaient uncode_clubjusqu’ici.Competition.code_club: str | None = None– optionnel, distinct du club de chaque compétiteur (une compétition inter-clubs ou fédérale n’a pas forcément de club organisateur unique). Migration_migration_0002_competition_code_club(ALTER TABLE competitions ADD COLUMN code_club, même mécanisme que la migration #1) : toujoursNULLsur une base migrée, jamais deviné rétroactivement – renseignable ensuite via “Modifier”.services.creer_competition()/modifier_competition()/creer_competition_depuis_template()valident que le club référencé existe (même principe que pour un compétiteur), sans quoiErreurMetier.Deux points trouvés en écrivant les tests, pas en écrivant le code initial – la suite de tests a fait exactement ce qu’elle doit faire :
db.importer_donnees_competition()(transaction unique du #7, restauration d’une sauvegarde) écrit la lignecompetitionsvia unINSERTSQL brut distinct dedb.insert_competition()– oublié dans la première passe,code_clubrestaitNULLaprès une restauration même quand la sauvegarde en avait un. Corrigé.io/sauvegarde_competition.py::exporter_competition()ne bundlait que les clubs des compétiteurs ({c.code_club for c in competiteurs}) – un club organisateur différent de tous les clubs de compétiteurs (compétition accueillie par un club dont aucun membre n’y participe) se serait retrouvé référencé parcompetitions.code_clubsans que ce club soit dans le fichier exporté, rendant la restauration impossible sur une machine qui ne le connaît pas déjà (clé étrangère irrésoluble). Corrigé en ajoutant explicitementcompetition.code_clubà l’ensemble des clubs à bundler.
GUI (
gui/ecran_competitions.py) : sélecteur de club optionnel dans le formulaire de compétition, entre le lieu et les dates –"(aucun club organisateur)"est une vraie valeur sélectionnable (code_club=None), pas un simple repli affiché quand la liste est vide comme pour le sélecteur de club (obligatoire) d’un compétiteur. 11 tests (storage, migration, services, sauvegarde/restauration). Vérifié réellement (Xvfb) : valeur par défaut correcte, création sans club, création avec club, et rechargement correct du club existant en repassant en mode édition – les quatre scénarios déclenchés depuis le vrai formulaire GUI, état de la base confirmé après coup à chaque étape.Hors périmètre pour ce ticket (voir aussi le #9) : où afficher cette information une fois disponible (export PDF, écran d’affichage public, vue web) – laissé aux tickets qui la consommeront réellement, pas ajouté ici sans un vrai consommateur pour éviter du code mort.
Sélecteurs langue/thème en
CTkSegmentedButtonplutôt qu’en dropdown (issue #49, 2026-08-16). Vérifié avant de coder : la vue compétiteur web (api/competiteur.py::_controles_haut) utilisait déjà des boutons pour les deux (thème ◐/☀/☾, langue FR/EN) – seule la GUI organisateur (gui/app.py) traînait encore deuxCTkOptionMenutexte. Un seul sous-sujet à traiter, pas deux.Décisions prises avec l’utilisateur : langue gardée en texte “FR”/ “EN” (pas de drapeaux – un drapeau représente un pays, pas une langue) ; thème repris à l’identique de la vue web (mêmes trois icônes ◐/☀/☾,
ICONES_THEME– pas un nouveau jeu de symboles à inventer).ctk.CTkSegmentedButton(déjà disponible dans la version de customtkinter utilisée, aucune dépendance ajoutée) remplace les deuxCTkOptionMenu– widget natif pensé exactement pour “choisir une valeur parmi N, toutes visibles” avec surbrillance de la valeur active intégrée, plutôt que de reconstruire cette logique à la main avec desCTkButtonindépendants._on_language_change()inchangé (reçoit déjà la valeur choisie en paramètre, peu importe le widget d’origine) ; nouveau_on_theme_change()traduit l’icône cliquée vers la valeur interne (“system”/”light”/”dark”) avant de déléguer àchanger_theme(), seule fonction à toucherconfig_gui/ctk.set_appearance_mode()– pas de logique dupliquée entre les deux callbacks. Vérifié réellement (Xvfb) : les deux sélecteurs rendus avec les bonnes valeurs initiales, vrai clic sur le bouton interne du widget (_buttons_dict[valeur].invoke(), pas un appel direct au handler qui aurait contourné le widget) pour changer de langue puis de thème – chrome retraduit,ctk.get_appearance_mode()réellement changé, persistance dansconfig/gui.tomlconfirmée dans les deux cas.Périmètre volontairement limité à FletchScore – ticket miroir fletchtime#15 pour le même changement côté FletchTime, mêmes décisions déjà actées, traité dans la foulée.
Clôture de compétition (issue #50, 2026-08-16). Question directe de l’utilisateur, remarquant que
Competition.statutexistait sans jamais changer dans la GUI – vérifié dans le code avant de répondre : trois garde-fous (creer_epreuve/modifier_epreuve/supprimer_epreuve) vérifiaient déjàstatut == CLOTUREE, mais rien ne le faisait jamais passer à cette valeur. Code mort depuis l’introduction du champ.Trois décisions tranchées avec l’utilisateur avant de coder (le ticket listait explicitement ces points comme “à ne pas deviner”) : clôture réversible (
rouvrir_competition, filet de sécurité en cas d’erreur) ; clôturer bloque aussi désormais la saisie de score, pas seulement la gestion des épreuves ; les tokens actifs sont révoqués à la clôture plutôt que la documentation corrigée pour admettre qu’ils ne le sont jamais.services.cloturer_competition(conn, competition_id)– refuse si déjà clôturée ou compétition introuvable, fait passer le statut puis révoque en une requête tous les tokens actifs de la compétition (db.revoquer_tokens_by_competition, nouveau –UPDATE ... WHERE competition_id = ? AND statut != REVOQUE, plus simple qu’une boucle surlist_tokens_by_competition+revoquer_tokenpar token).services.rouvrir_competition()symétrique, ne restaure pas les tokens révoqués (une révocation reste un choix explicite, même logique querevoquer_acces– pas de résurrection implicite d’un accès qu’un organisateur a choisi de couper).Garde de clôture ajoutée dans
saisir_score_final()plutôt que dupliquée dansproposer_score()/valider_score_propose()/rejeter_score_propose(): les trois passent déjà parsaisir_score_final()en interne (point de passage unique déjà établi avant ce ticket), donc un seul contrôle couvre les quatre chemins de saisie sans risque de divergence entre eux.Deuxième gap trouvé en vérifiant, corrigé par le même mécanisme :
docs/cahier-des-charges/securite.rstdocumentait l’expiration d’un token comme “automatique à la clôture” – faux jusqu’ici (valider_rattachement()n’a jamais renseignéToken.expire_le). Choix retenu : faire correspondre le comportement réel à la doc existante (révoquer à la clôture) plutôt que réécrire la doc pour admettre l’inverse.GUI (
gui/ecran_competitions.py) : un seul bouton par ligne de compétition, icône 🔒 (clôturer) / 🔓 (rouvrir) reflétant déjà l’état courant – pas deux boutons distincts dont un désactivé. Badge “· Clôturée” ajouté au texte de la ligne. Même patron de dialogue de confirmation que_confirmer_suppression_competition(CTkTopleveltransient+grab_setdifféré +wait_window), un seul dialogue paramétré par préfixe i18n (competitions_cloturer_*/competitions_rouvrir_*) plutôt que deux fonctions quasi identiques.
16 nouveaux tests (
TestClotureCompetition,TestRouvrirCompetition, plus un test storage dédié pourrevoquer_tokens_by_competitioncouvrant la non-interférence entre compétitions). Vérifié réellement (Xvfb) : vrais clicsxdotoolsur le bouton verrou de la ligne puis sur le bouton de confirmation du dialogue (pas d’appel direct aux handlers) – clôture, révocation des tokens en base, badge et icône mis à jour, réouverture, tokens toujours révoqués après réouverture (comportement voulu, pas un oubli) ; captures à chaque étape.