From 2a5131a89d34dae7ad2ec372d32f63276da6d64a Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Mon, 1 Jun 2026 19:08:47 +0200 Subject: [PATCH 01/13] Testing folder and files initiated. utils.py created for unit testing. Testing environment created. --- CACHEDIR.TAG | 4 ++++ pyvenv.cfg | 11 +++++++++++ utils.py | 0 3 files changed, 15 insertions(+) create mode 100644 CACHEDIR.TAG create mode 100644 pyvenv.cfg create mode 100644 utils.py diff --git a/CACHEDIR.TAG b/CACHEDIR.TAG new file mode 100644 index 000000000..837feeff9 --- /dev/null +++ b/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by Python virtualenv. +# For information about cache directory tags, see: +# https://bford.info/cachedir/ \ No newline at end of file diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 000000000..5d632cc2b --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,11 @@ +home = /Library/Frameworks/Python.framework/Versions/3.13/bin +implementation = CPython +version_info = 3.13.3.final.0 +version = 3.13.3 +executable = /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 +command = /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 -m virtualenv /Users/quentintellier/Documents/2 - Python Projects/P11_2 - GÜDLFT/Python_Testing +virtualenv = 21.2.4 +include-system-site-packages = false +base-prefix = /Library/Frameworks/Python.framework/Versions/3.13 +base-exec-prefix = /Library/Frameworks/Python.framework/Versions/3.13 +base-executable = /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 diff --git a/utils.py b/utils.py new file mode 100644 index 000000000..e69de29bb From 232f1336f044ce037fecd9210436a757056e4bfc Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Wed, 17 Jun 2026 15:46:43 +0200 Subject: [PATCH 02/13] =?UTF-8?q?setup=20des=20fichiers=20pour=20test=20de?= =?UTF-8?q?=20coverage.=20Migration=20des=20fonctions=20utils=20dans=20le?= =?UTF-8?q?=20module=20utils.=20Impl=C3=A9mentation=20des=20tests=20unitai?= =?UTF-8?q?res=20pour=20les=20fonctions=20utils.=20Affichage=20des=20messa?= =?UTF-8?q?ges=20flash=20dans=20le=20template=20index.=20Cr=C3=A9ation=20d?= =?UTF-8?q?es=20fonctions=20utils=20pour=20le=20login.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 8196 bytes .coverage | Bin 0 -> 53248 bytes .coveragerc | 2 ++ server.py | 26 +++++++++++--------------- templates/index.html | 9 +++++++++ utils.py | 28 ++++++++++++++++++++++++++++ 6 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 .coverage create mode 100644 .coveragerc diff --git a/.DS_Store b/.DS_Store index 04a508e6f6048834346c5f1769770f2ff01806e6..8518e2f64fa4a87a6036764cd2f82ae577fd7317 100644 GIT binary patch delta 364 zcmZoMXmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD77(QH}hr%jz7$c**Q2SHn1>C zZRTN7WvtI&C}GHD$YDrk$Y&@^DlaZb%E?ax>fD}Gkds+lVqkEMk%^gwm5rU9lbxGA zHaH`{Jh&vWq_o&6u_zkE3(3#VNrJHxlfp7n%i{$^ob&Ta5;OBsi@+K(Q&NFSV!|`? zQu524@=Nnliotq=GwZ=JoSYn-@dD!2)h0$pItnHRwK@vbmPQ6Zrm0RskqFbn1HQIp%0uX=z1m;Fybgfh+B5&Eh4`$9PDx9P!4tA$e)#SR^G@@f_>(;IifAoc&D0E zlXHuzTtm;jIh5nZuZlEhH4ek`tAc}|9`MFuzWm-v)1Y1{_rsfgdh675hLIn?Zc^hn z8}wAV`|3iWytZcEyBT*B=4rOvC>*a?Q=MkKc}Caa{j>WGkCN$5!lS6)7k)P$GMrne z#-ZLkIcDk_!wGnx2|r0lHGQGIjVnua5Usx7n(k|Hj|n9W5Sj~gvhL7p^H2#nhPHo8 z2F|XmC41Gh@S-~sb)ANlFK?~Q>N?{t&}@uXi=&l%p}e|k-m&6tl(bgzt1#7tbW0vF zjp|(k>4d{TPb5#9Bz2B-megY;*+b!M6!PWG)mh;r>6(pXWnomv70RbhnIknKcwXYi ziK!rXOF9G&od}-2`g=~)6HnHiH&eyePvpzzPtB@0SE1S9=ltE}OkCyr*;s!-lRiJP zQ&r|IX_Y^h2OGDXKFub3jvLUBr8!ug0R$)PIF#6F`NJsgzx*=po_y{}>#GV#Uu%0x zRf&_+@V@GYp-ZP#K5d2X>#btF=Y%{9h3lf_`+ezn>fAs%E{AkXHxzLlo;)jkn3fS9 za2dO|*5f+qlM7Gt+cfC(_@b4;!0|fC`HD}&s#Kc$d2sS^)FeU~#X1ejkcQ}?GIgQJ zUY@aZ`L#VV;Zg@qM-6vwl!+2=_v91Bsk6~VCz6pnkb;xMm5DpnZ6_4%K+?|STpCx5 z#CnHorHc8C_UJiOMN?VNKCSO?6E_?D%vZ?^{8nzXW){k4&zhrQGJ@6Q)C}pJz`)V5 zCNDEnJ?qfUw3B^7voyU$n(SN*K4Hi&)e(3`RL$N}bUIb?2B}!#3w+alY|sxD2tWV= z5P$##AOHafKmY;|fB*#MPatb%%mQEkXY4-=`!9OJ0s#m>00Izz00bZa0SG_<0uX?} zODRywWKY}r&qB^Eo7vTs0;TL}Q-2JQDP~u#45&PK*@@KmY;|fB*y_009U<00Izz z00gwaP9|rJw(8eIy0<}{ZXx5*U5X;v@4GUnU-a9<0c{EEZwk6y!$H)eTlriK{2Q`O z+pabmP2PU#`tSEIUioypt`ao2Wyp6rst+T#AJQEV9FvZ4>3JN1>P#O1&>__K{}&AV zSNnnv48I`&0SG_<0uX=z1Rwwb2tWV=5J&}_+-S>u9#`l3`aeTQxMV$#BaE;AasR)x zJ+TG>2tWV=5P$##AOHafKmY;|m_vcAnKhR9`+w8^&!8VH5P$##AOHafKmY;|fB*y_ z009Whp+GjfWU2rEe`whM*bnD0M1%wZ2tWV=5P$##AOHafKmY;|IA#L*OwP1k$$U^U zbC&h`p9jCK/') diff --git a/templates/index.html b/templates/index.html index 926526b7d..c40ee1fcd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,15 @@

Welcome to the GUDLFT Registration Portal!

+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} Please enter your secretary email to continue:
diff --git a/utils.py b/utils.py index e69de29bb..acab6669a 100644 --- a/utils.py +++ b/utils.py @@ -0,0 +1,28 @@ +import json + +def loadClubs(): + with open('clubs.json') as c: + listOfClubs = json.load(c)['clubs'] + return listOfClubs + + +def loadCompetitions(): + with open('competitions.json') as comps: + listOfCompetitions = json.load(comps)['competitions'] + return listOfCompetitions + + +def getClubByEmail(email, clubs): + email = lowercaseEmail(stripWhitespace(email)) + for club in clubs: + if lowercaseEmail(stripWhitespace(club['email'])) == email: + return club + return None + +def lowercaseEmail(email): + """Convertit l'email en minuscules pour une comparaison insensible à la casse.""" + return email.lower() + +def stripWhitespace(email): + """Supprime les espaces avant et après l'email pour une comparaison précise.""" + return email.strip() \ No newline at end of file From 7568d8fa8a0135faeead3feb28c6555a0b3da442 Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Wed, 17 Jun 2026 16:40:41 +0200 Subject: [PATCH 03/13] refactor: switch to Flask application factory and inject test data - move route registration into create_app - inject clubs and competitions into the app factory for tests - use current_app.config inside route handlers - add pytest warning filters for Flask/Werkzeug deprecations --- pytest.ini | 6 ++++ server.py | 94 +++++++++++++++++++++++++++++++----------------------- 2 files changed, 60 insertions(+), 40 deletions(-) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..5a9e34b4e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +filterwarnings = + ignore:ast\.Str is deprecated and will be removed in Python 3\.14; use ast\.Constant instead:DeprecationWarning + ignore:Constant\.__init__ got an unexpected keyword argument 's'\. Support for arbitrary keyword arguments is deprecated and will be removed in Python 3\.15\.:DeprecationWarning + ignore:Attribute s is deprecated and will be removed in Python 3\.14; use value instead:DeprecationWarning + ignore:Constant\.__init__ missing 1 required positional argument.*:DeprecationWarning \ No newline at end of file diff --git a/server.py b/server.py index 00e4ab14b..4b187e3fd 100644 --- a/server.py +++ b/server.py @@ -1,55 +1,69 @@ -import json -from flask import Flask,render_template,request,redirect,flash,url_for +from flask import Flask,render_template,request,redirect,flash,url_for,current_app from utils import loadClubs, loadCompetitions, getClubByEmail -app = Flask(__name__) -app.secret_key = 'something_special' +def create_app(config=None, clubs=None, competitions=None): + app = Flask(__name__) + app.secret_key = 'something_special' + if config: + app.config.update(config) -competitions = loadCompetitions() -clubs = loadClubs() + app.config['COMPETITIONS'] = competitions if competitions is not None else loadCompetitions() + app.config['CLUBS'] = clubs if clubs is not None else loadClubs() -@app.route('/') -def index(): - return render_template('index.html') + @app.route('/') + def index(): + return render_template('index.html') + + @app.route('/showSummary',methods=['POST']) + def showSummary(): + available_clubs = current_app.config['CLUBS'] + available_competitions = current_app.config['COMPETITIONS'] + + if available_clubs is None or available_competitions is None: + flash("Error loading clubs or competitions data.") + return redirect(url_for('index')) + + club = getClubByEmail(request.form['email'], available_clubs) + if club: + return render_template('welcome.html',club=club,competitions=available_competitions) -@app.route('/showSummary',methods=['POST']) -def showSummary(): - if clubs is None or competitions is None: - flash("Error loading clubs or competitions data.") - return redirect(url_for('index')) - - club = getClubByEmail(request.form['email'], clubs) - if club: - return render_template('welcome.html',club=club,competitions=competitions) - else: flash("Unfortunately, the email you entered was not found.") return redirect(url_for('index')) -@app.route('/book//') -def book(competition,club): - foundClub = [c for c in clubs if c['name'] == club][0] - foundCompetition = [c for c in competitions if c['name'] == competition][0] - if foundClub and foundCompetition: - return render_template('booking.html',club=foundClub,competition=foundCompetition) - else: - flash("Something went wrong-please try again") - return render_template('welcome.html', club=club, competitions=competitions) + @app.route('/book//') + def book(competition,club): + available_clubs = current_app.config['CLUBS'] + available_competitions = current_app.config['COMPETITIONS'] + foundClub = [c for c in available_clubs if c['name'] == club][0] + foundCompetition = [c for c in available_competitions if c['name'] == competition][0] + if foundClub and foundCompetition: + return render_template('booking.html',club=foundClub,competition=foundCompetition) + else: + flash("Something went wrong-please try again") + return render_template('welcome.html', club=club, competitions=available_competitions) -@app.route('/purchasePlaces',methods=['POST']) -def purchasePlaces(): - competition = [c for c in competitions if c['name'] == request.form['competition']][0] - club = [c for c in clubs if c['name'] == request.form['club']][0] - placesRequired = int(request.form['places']) - competition['numberOfPlaces'] = int(competition['numberOfPlaces'])-placesRequired - flash('Great-booking complete!') - return render_template('welcome.html', club=club, competitions=competitions) + @app.route('/purchasePlaces',methods=['POST']) + def purchasePlaces(): + available_clubs = current_app.config['CLUBS'] + available_competitions = current_app.config['COMPETITIONS'] + competition = [c for c in available_competitions if c['name'] == request.form['competition']][0] + club = [c for c in available_clubs if c['name'] == request.form['club']][0] + placesRequired = int(request.form['places']) + competition['numberOfPlaces'] = int(competition['numberOfPlaces'])-placesRequired + flash('Great-booking complete!') + return render_template('welcome.html', club=club, competitions=available_competitions) -# TODO: Add route for points display + # TODO: Add route for points display + + + @app.route('/logout') + def logout(): + return redirect(url_for('index')) + + return app -@app.route('/logout') -def logout(): - return redirect(url_for('index')) \ No newline at end of file +app = create_app() \ No newline at end of file From dde1e43de82bcc83bda0d5a0cb6d0d844e5aa85c Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Thu, 18 Jun 2026 11:39:48 +0200 Subject: [PATCH 04/13] Warning filter added for pytest. Integration tests on login feature implemented --- pytest.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index 5a9e34b4e..d7e6f75c9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,5 @@ filterwarnings = ignore:ast\.Str is deprecated and will be removed in Python 3\.14; use ast\.Constant instead:DeprecationWarning ignore:Constant\.__init__ got an unexpected keyword argument 's'\. Support for arbitrary keyword arguments is deprecated and will be removed in Python 3\.15\.:DeprecationWarning ignore:Attribute s is deprecated and will be removed in Python 3\.14; use value instead:DeprecationWarning - ignore:Constant\.__init__ missing 1 required positional argument.*:DeprecationWarning \ No newline at end of file + ignore:Constant\.__init__ missing 1 required positional argument.*:DeprecationWarning + ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version\..*:DeprecationWarning \ No newline at end of file From 041e262f5d84dc4db417ae429f760a847138e4ef Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Fri, 19 Jun 2026 12:54:42 +0200 Subject: [PATCH 05/13] refactor(book route, utils): improve robustness with safe lookups - Replace fragile [0] indexing with safe getClubByName/getCompetitionByName lookups in /book route - Validate config data presence and handle missing club/competition separately - Make JSON loaders return None on errors (OSError, JSONDecodeError, KeyError) - Add helper functions for safe dictionary lookups - Update corresponding unit tests to match new None-return behavior --- .DS_Store | Bin 8196 -> 8196 bytes .coverage | Bin 53248 -> 53248 bytes server.py | 25 +++++++++++++++++-------- utils.py | 34 +++++++++++++++++++++++++++------- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/.DS_Store b/.DS_Store index 8518e2f64fa4a87a6036764cd2f82ae577fd7317..537739ef6f798307758963abfe7994bd7674eb7b 100644 GIT binary patch delta 39 vcmZp1XmOa}&nUeyU^hRb^kyCbb|%i0;^ds9{QMlo&5S~JOq-)bR&fIW?7j{|B4+~!`1OHrpYrfNbv6}@2eEHaNSXdYuV<$WMhe(yA<`(26mZTQz zXXd4(R_JAvE% zr7@Nfr>QLYERBg^X>shPGH|+_$%JcX%2Y52$c4Ffg$2KV#tk4K(R3 d{}-T1*Z4))fXW$}SilrBGniu8{A)g&0RY`)T_pej delta 112 zcmZozz}&Eac>{|B2NS&=1!`uvlv{Z9f#Y#8|G@!J4J4EQ(O#Gg@s7 diff --git a/server.py b/server.py index 4b187e3fd..1842d3281 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,5 @@ from flask import Flask,render_template,request,redirect,flash,url_for,current_app -from utils import loadClubs, loadCompetitions, getClubByEmail +from utils import loadClubs, loadCompetitions, getClubByEmail, getCompetitionByName, getClubByName def create_app(config=None, clubs=None, competitions=None): app = Flask(__name__) @@ -35,14 +35,23 @@ def showSummary(): def book(competition,club): available_clubs = current_app.config['CLUBS'] available_competitions = current_app.config['COMPETITIONS'] - foundClub = [c for c in available_clubs if c['name'] == club][0] - foundCompetition = [c for c in available_competitions if c['name'] == competition][0] - if foundClub and foundCompetition: - return render_template('booking.html',club=foundClub,competition=foundCompetition) - else: - flash("Something went wrong-please try again") - return render_template('welcome.html', club=club, competitions=available_competitions) + if available_clubs is None or available_competitions is None: + flash("Error loading clubs or competitions data.") + return redirect(url_for('index')) + + found_competition = getCompetitionByName(competition, available_competitions) + found_club = getClubByName(club, available_clubs) + + if found_club is None: + flash("Invalid booking URL. Please check the club name.") + return redirect(url_for('index')) + + if found_competition is None: + flash("Invalid booking URL. Please check the competition name.") + return render_template('welcome.html', club=found_club, competitions=available_competitions) + + return render_template('booking.html', club=found_club, competition=found_competition) @app.route('/purchasePlaces',methods=['POST']) def purchasePlaces(): diff --git a/utils.py b/utils.py index acab6669a..0247f9efd 100644 --- a/utils.py +++ b/utils.py @@ -1,15 +1,21 @@ import json def loadClubs(): - with open('clubs.json') as c: - listOfClubs = json.load(c)['clubs'] - return listOfClubs + try: + with open('clubs.json') as c: + listOfClubs = json.load(c)['clubs'] + return listOfClubs + except (OSError, json.JSONDecodeError, KeyError): + return None def loadCompetitions(): - with open('competitions.json') as comps: - listOfCompetitions = json.load(comps)['competitions'] - return listOfCompetitions + try: + with open('competitions.json') as comps: + listOfCompetitions = json.load(comps)['competitions'] + return listOfCompetitions + except (OSError, json.JSONDecodeError, KeyError): + return None def getClubByEmail(email, clubs): @@ -25,4 +31,18 @@ def lowercaseEmail(email): def stripWhitespace(email): """Supprime les espaces avant et après l'email pour une comparaison précise.""" - return email.strip() \ No newline at end of file + return email.strip() + +def getCompetitionByName(name, competitions): + """Récupère une compétition par son nom dans la liste des compétitions.""" + for competition in competitions: + if competition['name'] == name: + return competition + return None + +def getClubByName(name, clubs): + """Récupère un club par son nom dans la liste des clubs.""" + for club in clubs: + if club['name'] == name: + return club + return None \ No newline at end of file From ddcbb9ed8ddb0be9b80878373ca374918a2c248e Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Mon, 22 Jun 2026 14:48:18 +0200 Subject: [PATCH 06/13] feat(purchase): add robust booking validation and flash feedback harden the purchase route when clubs/competitions data is missing or invalid replace fragile list indexing with safe lookup helpers add getClubPoints/getCompetitionPlaces with invalid-input handling add validateBooking to enforce available club points checks display flash messages on the booking page when validation fails update available places only after successful validation --- server.py | 39 +++++++++++++++++++++++++++++++++++---- templates/booking.html | 10 ++++++++++ utils.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 1842d3281..cc5e4ad9a 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,14 @@ from flask import Flask,render_template,request,redirect,flash,url_for,current_app -from utils import loadClubs, loadCompetitions, getClubByEmail, getCompetitionByName, getClubByName +from utils import ( + loadClubs, + loadCompetitions, + getClubByEmail, + getCompetitionByName, + getClubByName, + getClubPoints, + getCompetitionPlaces, + validateBooking, +) def create_app(config=None, clubs=None, competitions=None): app = Flask(__name__) @@ -57,12 +66,34 @@ def book(competition,club): def purchasePlaces(): available_clubs = current_app.config['CLUBS'] available_competitions = current_app.config['COMPETITIONS'] - competition = [c for c in available_competitions if c['name'] == request.form['competition']][0] - club = [c for c in available_clubs if c['name'] == request.form['club']][0] + + if available_clubs is None or available_competitions is None: + flash("Error loading clubs or competitions data.") + return redirect(url_for('index')) + + competition = getCompetitionByName(request.form['competition'], available_competitions) + club = getClubByName(request.form['club'], available_clubs) placesRequired = int(request.form['places']) - competition['numberOfPlaces'] = int(competition['numberOfPlaces'])-placesRequired + + if competition is None or club is None: + flash("Invalid booking request. Please check the club and competition names.") + return redirect(url_for('index')) + + validation_errors = validateBooking( + getClubPoints(club), + getCompetitionPlaces(competition), + placesRequired, + ) + + if validation_errors: + for error in validation_errors: + flash(error) + return render_template('booking.html', club=club, competition=competition) + + competition['numberOfPlaces'] = str(getCompetitionPlaces(competition) - placesRequired) flash('Great-booking complete!') return render_template('welcome.html', club=club, competitions=available_competitions) + # TODO: Add route for points display diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..2d805514c 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -5,6 +5,16 @@ Booking for {{competition['name']}} || GUDLFT +

GUDLFT Booking

+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %}

{{competition['name']}}

Places available: {{competition['numberOfPlaces']}} diff --git a/utils.py b/utils.py index 0247f9efd..1d71d075b 100644 --- a/utils.py +++ b/utils.py @@ -45,4 +45,31 @@ def getClubByName(name, clubs): for club in clubs: if club['name'] == name: return club - return None \ No newline at end of file + return None + +def getClubPoints(club): + """Récupère le nombre de points d'un club.""" + if not isinstance(club, dict): + return None + try: + return int(club.get('points', None)) + except (TypeError, ValueError): + return None + +def getCompetitionPlaces(competition): + """Récupère le nombre de places disponibles pour une compétition.""" + if not isinstance(competition, dict): + return None + try: + return int(competition.get('numberOfPlaces', None)) + except (TypeError, ValueError): + return None + +def validateBooking(club_points, competition_places, placesRequested): + """Valide si un club peut réserver des places pour une compétition.""" + errors = [] + + if placesRequested > club_points: + errors.append("Not enough points available in your club to book the requested number of places.") + + return errors \ No newline at end of file From a161a4de4fb8dfbfebc9a48de21e99d2eeda7874 Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Mon, 22 Jun 2026 16:21:31 +0200 Subject: [PATCH 07/13] refactor(server): improve booking feedback with purchased places count - Update flash message to display the number of places purchased - Remove unnecessary blank lines --- server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server.py b/server.py index cc5e4ad9a..be11a4a83 100644 --- a/server.py +++ b/server.py @@ -91,11 +91,9 @@ def purchasePlaces(): return render_template('booking.html', club=club, competition=competition) competition['numberOfPlaces'] = str(getCompetitionPlaces(competition) - placesRequired) - flash('Great-booking complete!') + flash(f'Booking complete: {placesRequired} places purchased.') return render_template('welcome.html', club=club, competitions=available_competitions) - - # TODO: Add route for points display From 2e1b07ca44b87013b08569d44b1fde16308c624a Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Mon, 22 Jun 2026 17:20:48 +0200 Subject: [PATCH 08/13] feat(booking): add client-side place limit validation with dynamic constraints - Implement dynamic max value for booking input based on club points, available places, and 12-place cap - Add HTML5 validation (min, required) and disable submit when no booking possible - Add server-side validation to reject requests exceeding 12-place limit --- templates/booking.html | 5 +++-- utils.py | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/templates/booking.html b/templates/booking.html index 2d805514c..0ccd0b1b7 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -17,11 +17,12 @@

GUDLFT Booking

{% endwith %}

{{competition['name']}}

Places available: {{competition['numberOfPlaces']}} + {% set max_places = [12, club['points']|int, competition['numberOfPlaces']|int] | min %} - - + + \ No newline at end of file diff --git a/utils.py b/utils.py index 1d71d075b..7afa78a40 100644 --- a/utils.py +++ b/utils.py @@ -72,4 +72,7 @@ def validateBooking(club_points, competition_places, placesRequested): if placesRequested > club_points: errors.append("Not enough points available in your club to book the requested number of places.") + if placesRequested > 12: + errors.append("You cannot book more than 12 places per competition.") + return errors \ No newline at end of file From b805c43312e0d7ecef509494b7bd790c58cbdb1d Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Mon, 22 Jun 2026 19:46:24 +0200 Subject: [PATCH 09/13] feat(booking): block booking for past competitions and hide link in welcome - Add isCompetitionBookable utility function to check if competition can be booked - Implement buildCompetitionsView to add canBook flag to competitions - Hide Book Places link when competition is no longer bookable - Protect /book route to prevent bypassing via direct URL - Fix welcome page header to show club name instead of email - Update mock competition dates to 2026 --- competitions.json | 4 ++-- server.py | 42 +++++++++++++++++++++++++++++++++++++++--- templates/welcome.html | 4 ++-- utils.py | 21 +++++++++++++++++++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/competitions.json b/competitions.json index 039fc61bd..e4f744fe4 100644 --- a/competitions.json +++ b/competitions.json @@ -2,12 +2,12 @@ "competitions": [ { "name": "Spring Festival", - "date": "2020-03-27 10:00:00", + "date": "2026-03-27 10:00:00", "numberOfPlaces": "25" }, { "name": "Fall Classic", - "date": "2020-10-22 13:30:00", + "date": "2026-10-22 13:30:00", "numberOfPlaces": "13" } ] diff --git a/server.py b/server.py index be11a4a83..d5662db18 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,5 @@ from flask import Flask,render_template,request,redirect,flash,url_for,current_app +from datetime import datetime from utils import ( loadClubs, loadCompetitions, @@ -7,6 +8,7 @@ getClubByName, getClubPoints, getCompetitionPlaces, + isCompetitionBookable, validateBooking, ) @@ -19,6 +21,17 @@ def create_app(config=None, clubs=None, competitions=None): app.config['COMPETITIONS'] = competitions if competitions is not None else loadCompetitions() app.config['CLUBS'] = clubs if clubs is not None else loadClubs() + def buildCompetitionsView(competitions): + now = datetime.now() + competitions_view = [] + + for competition in competitions: + competition_view = dict(competition) + competition_view['canBook'] = isCompetitionBookable(competition, now=now) + competitions_view.append(competition_view) + + return competitions_view + @app.route('/') def index(): return render_template('index.html') @@ -34,7 +47,11 @@ def showSummary(): club = getClubByEmail(request.form['email'], available_clubs) if club: - return render_template('welcome.html',club=club,competitions=available_competitions) + return render_template( + 'welcome.html', + club=club, + competitions=buildCompetitionsView(available_competitions), + ) flash("Unfortunately, the email you entered was not found.") return redirect(url_for('index')) @@ -51,6 +68,7 @@ def book(competition,club): found_competition = getCompetitionByName(competition, available_competitions) found_club = getClubByName(club, available_clubs) + #on peut forcer via l'URL une compétition dans le passé ou une compétition sans places disponibles, mais on ne peut pas forcer une compétition ou un club qui n'existent pas if found_club is None: flash("Invalid booking URL. Please check the club name.") @@ -58,7 +76,21 @@ def book(competition,club): if found_competition is None: flash("Invalid booking URL. Please check the competition name.") - return render_template('welcome.html', club=found_club, competitions=available_competitions) + return render_template( + 'welcome.html', + club=found_club, + competitions=buildCompetitionsView(available_competitions), + ) + + if not isCompetitionBookable(found_competition): + flash("This competition is no longer open for booking.") + return render_template( + 'welcome.html', + club=found_club, + competitions=buildCompetitionsView(available_competitions), + ) + + return render_template('booking.html', club=found_club, competition=found_competition) @@ -92,7 +124,11 @@ def purchasePlaces(): competition['numberOfPlaces'] = str(getCompetitionPlaces(competition) - placesRequired) flash(f'Booking complete: {placesRequired} places purchased.') - return render_template('welcome.html', club=club, competitions=available_competitions) + return render_template( + 'welcome.html', + club=club, + competitions=buildCompetitionsView(available_competitions), + ) # TODO: Add route for points display diff --git a/templates/welcome.html b/templates/welcome.html index ff6b261a2..43a023675 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -5,7 +5,7 @@ Summary | GUDLFT Registration -

Welcome, {{club['email']}}

Logout +

Welcome, {{club['name']}}

Logout {% with messages = get_flashed_messages()%} {% if messages %} @@ -23,7 +23,7 @@

Competitions:

{{comp['name']}}
Date: {{comp['date']}}
Number of Places: {{comp['numberOfPlaces']}} - {%if comp['numberOfPlaces']|int >0%} + {%if comp['canBook'] %} Book Places {%endif%} diff --git a/utils.py b/utils.py index 7afa78a40..c92a74cf8 100644 --- a/utils.py +++ b/utils.py @@ -1,4 +1,8 @@ import json +from datetime import datetime + + +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' def loadClubs(): try: @@ -65,6 +69,23 @@ def getCompetitionPlaces(competition): except (TypeError, ValueError): return None + +def isCompetitionBookable(competition, now=None): + """Retourne True si la compétition est dans le futur (ou présent) avec au moins 1 place.""" + if now is None: + now = datetime.now() + + competition_places = getCompetitionPlaces(competition) + if competition_places is None or competition_places <= 0: + return False + + try: + competition_date = datetime.strptime(competition['date'], DATE_FORMAT) + except (KeyError, TypeError, ValueError): + return False + + return competition_date >= now + def validateBooking(club_points, competition_places, placesRequested): """Valide si un club peut réserver des places pour une compétition.""" errors = [] From 2a06d54081ff7d9c2281b7e0a197763fe6c69550 Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Wed, 24 Jun 2026 12:41:09 +0200 Subject: [PATCH 10/13] refactor(booking): rename validateBooking to isBookingValid and harden purchasePlaces route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename validateBooking → isBookingValid in utils and server - add competition bookability check in /purchasePlaces route to prevent booking a past/full competition via direct POST request - update import in server.py accordingly --- .coverage | Bin 53248 -> 53248 bytes server.py | 12 ++++++++++-- utils.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.coverage b/.coverage index 8a8fb72a2726bb06f437a28e1094a1343439da1e..47c811c47be6be6136ee343572d2195c4084da5f 100644 GIT binary patch delta 263 zcmZozz}&Eac>{|BA1m)$2L8GH)_kY=VtL`D3g*_nCi zlg<3k0hPXE;Gf5LhA)oy9ls4w=^b9ye3r&U#>p4`vKUz>$HiTQso!iCe@=mqm47t@ z{|o+`{O9=h^REVKn8}|m#LB|Rsm8R-zP>JWZ`|IhTfJG_yevR58P<3IL7<+QlNrbr zVF7aQ^D=>Cm?1LD8O0b`I5`dY8uGvI61V@H^zM0kT#U_o_Q_lf3=FLN_Zj$q^WW$H k!vB{48PKw`{L*Yd-Hc4EV2YImOtCP7Ddx?;=Cc_907J4?#sB~S delta 195 zcmZozz}&Eac>{|B4+~!`1OHrpYrfNbv6}@2eEBBF`dwl#Pt8fr&rO|d=6{Zhg)feQ ze;!{P-= now -def validateBooking(club_points, competition_places, placesRequested): +def isBookingValid(club_points, competition_places, placesRequested): """Valide si un club peut réserver des places pour une compétition.""" errors = [] From 0ccc512fdd236e2b3444af3d3c69ffa1cb39dbc5 Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Wed, 24 Jun 2026 15:26:39 +0200 Subject: [PATCH 11/13] feat(purchase): centralize points/places updates in utils after booking - add updateClubPoints helper to safely deduct club points - add updateCompetitionPlaces helper to safely deduct competition places - use the new helpers in purchase flow instead of inline update logic - keep booking confirmation flow unchanged while making updates reusable and safer --- server.py | 6 +++++- utils.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 48da20670..235477b7f 100644 --- a/server.py +++ b/server.py @@ -10,6 +10,8 @@ getCompetitionPlaces, isCompetitionBookable, isBookingValid, + updateClubPoints, + updateCompetitionPlaces, ) def create_app(config=None, clubs=None, competitions=None): @@ -130,7 +132,9 @@ def purchasePlaces(): flash(error) return render_template('booking.html', club=club, competition=competition) - competition['numberOfPlaces'] = str(getCompetitionPlaces(competition) - placesRequired) + # Update club points and competition places + updateClubPoints(club, placesRequired) + updateCompetitionPlaces(competition, placesRequired) flash(f'Booking complete: {placesRequired} places purchased.') return render_template( 'welcome.html', diff --git a/utils.py b/utils.py index ac23eb5e5..bf52d2f15 100644 --- a/utils.py +++ b/utils.py @@ -96,4 +96,32 @@ def isBookingValid(club_points, competition_places, placesRequested): if placesRequested > 12: errors.append("You cannot book more than 12 places per competition.") - return errors \ No newline at end of file + return errors + +def updateClubPoints(club, pointsToDeduct): + """Met à jour les points d'un club après une réservation.""" + if not isinstance(club, dict): + return False + try: + current_points = int(club.get('points', 0)) + new_points = current_points - pointsToDeduct + if new_points < 0 or pointsToDeduct < 0: + return False + club['points'] = str(new_points) + return True + except (TypeError, ValueError): + return False + +def updateCompetitionPlaces(competition, placesToDeduct): + """Met à jour le nombre de places disponibles pour une compétition après une réservation.""" + if not isinstance(competition, dict): + return False + try: + current_places = int(competition.get('numberOfPlaces', 0)) + new_places = current_places - placesToDeduct + if new_places < 0 or placesToDeduct < 0: + return False + competition['numberOfPlaces'] = str(new_places) + return True + except (TypeError, ValueError): + return False \ No newline at end of file From a8dced32be8b404733e831367c438aa0c9562315 Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Wed, 24 Jun 2026 16:32:42 +0200 Subject: [PATCH 12/13] feat(booking): enforce cumulative 12-place limit per club and competition - add booking tracker by club/competition pair in app config - pass already booked places to purchase validation - block booking when cumulative total exceeds 12 places - extend validation for zero/negative places and insufficient competition places - persist cumulative counter only after successful purchase - on validation error, render welcome with flash messages --- server.py | 15 ++++++++++++++- utils.py | 13 +++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 235477b7f..1e06c7033 100644 --- a/server.py +++ b/server.py @@ -22,6 +22,10 @@ def create_app(config=None, clubs=None, competitions=None): app.config['COMPETITIONS'] = competitions if competitions is not None else loadCompetitions() app.config['CLUBS'] = clubs if clubs is not None else loadClubs() + app.config.setdefault('BOOKINGS_BY_CLUB_COMPETITION', {}) + + def getBookingKey(club_name, competition_name): + return f"{club_name}::{competition_name}" def buildCompetitionsView(competitions): now = datetime.now() @@ -108,6 +112,8 @@ def purchasePlaces(): competition = getCompetitionByName(request.form['competition'], available_competitions) club = getClubByName(request.form['club'], available_clubs) placesRequired = int(request.form['places']) + booking_key = getBookingKey(request.form['club'], request.form['competition']) + places_already_booked = current_app.config['BOOKINGS_BY_CLUB_COMPETITION'].get(booking_key, 0) if competition is None or club is None: flash("Invalid booking request. Please check the club and competition names.") @@ -125,16 +131,23 @@ def purchasePlaces(): getClubPoints(club), getCompetitionPlaces(competition), placesRequired, + placesAlreadyBooked=places_already_booked, ) if validation_errors: for error in validation_errors: flash(error) - return render_template('booking.html', club=club, competition=competition) + #return render_template('booking.html', club=club, competition=competition) + return render_template( + 'welcome.html', + club=club, + competitions=buildCompetitionsView(available_competitions), + ) # Update club points and competition places updateClubPoints(club, placesRequired) updateCompetitionPlaces(competition, placesRequired) + current_app.config['BOOKINGS_BY_CLUB_COMPETITION'][booking_key] = places_already_booked + placesRequired flash(f'Booking complete: {placesRequired} places purchased.') return render_template( 'welcome.html', diff --git a/utils.py b/utils.py index bf52d2f15..7d560922e 100644 --- a/utils.py +++ b/utils.py @@ -86,14 +86,23 @@ def isCompetitionBookable(competition, now=None): return competition_date >= now -def isBookingValid(club_points, competition_places, placesRequested): +def isBookingValid(club_points, competition_places, placesRequested, placesAlreadyBooked=0): """Valide si un club peut réserver des places pour une compétition.""" errors = [] + if placesRequested <= 0: + if placesRequested == 0: + errors.append("You need to book at least one place.") + else: + errors.append("You cannot book a negative number of places.") + + if placesRequested > competition_places: + errors.append("Not enough places available in this competition.") + if placesRequested > club_points: errors.append("Not enough points available in your club to book the requested number of places.") - if placesRequested > 12: + if placesRequested > 12 or (placesAlreadyBooked + placesRequested) > 12: errors.append("You cannot book more than 12 places per competition.") return errors From 6ce7ea9d6fa2412672c88a778eadddcf2ad90fd6 Mon Sep 17 00:00:00 2001 From: Quentin Tellier Date: Wed, 24 Jun 2026 17:28:21 +0200 Subject: [PATCH 13/13] feat(points-board): add public read-only points board -add new public route /pointsBoard (no login required) -render a dedicated read-only page listing each club with available points -handle missing clubs data with flash error + redirect to home -add navigation link to points board from home page -add navigation link to points board from welcome page --- server.py | 10 ++++++++++ templates/index.html | 1 + templates/points_board.html | 28 ++++++++++++++++++++++++++++ templates/welcome.html | 1 + 4 files changed, 40 insertions(+) create mode 100644 templates/points_board.html diff --git a/server.py b/server.py index 1e06c7033..74b02f2ed 100644 --- a/server.py +++ b/server.py @@ -42,6 +42,16 @@ def buildCompetitionsView(competitions): def index(): return render_template('index.html') + @app.route('/pointsBoard') + def pointsBoard(): + available_clubs = current_app.config['CLUBS'] + + if available_clubs is None: + flash("Error loading clubs data.") + return redirect(url_for('index')) + + return render_template('points_board.html', clubs=available_clubs) + @app.route('/showSummary',methods=['POST']) def showSummary(): available_clubs = current_app.config['CLUBS'] diff --git a/templates/index.html b/templates/index.html index c40ee1fcd..53c2e306d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,5 +21,6 @@

Welcome to the GUDLFT Registration Portal!

+

View Public Points Board

\ No newline at end of file diff --git a/templates/points_board.html b/templates/points_board.html new file mode 100644 index 000000000..78516e7fa --- /dev/null +++ b/templates/points_board.html @@ -0,0 +1,28 @@ + + + + + Public Points Board | GUDLFT Registration + + +

Public Points Board

+

This page is read-only and does not require login.

+ + + + + + + + + {% for club in clubs %} + + + + + {% endfor %} + +
ClubAvailable Points
{{ club['name'] }}{{ club['points'] }}
+

Back to Home

+ + diff --git a/templates/welcome.html b/templates/welcome.html index 43a023675..4225664fe 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -6,6 +6,7 @@

Welcome, {{club['name']}}

Logout +

View Public Points Board

{% with messages = get_flashed_messages()%} {% if messages %}