diff --git a/.DS_Store b/.DS_Store index 04a508e6f..537739ef6 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.coverage b/.coverage new file mode 100644 index 000000000..47c811c47 Binary files /dev/null and b/.coverage differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..3dbfbb408 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* \ No newline at end of file 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/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/pytest.ini b/pytest.ini new file mode 100644 index 000000000..d7e6f75c9 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[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 + ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version\..*:DeprecationWarning \ 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/server.py b/server.py index 4084baeac..48da20670 100644 --- a/server.py +++ b/server.py @@ -1,59 +1,151 @@ -import json -from flask import Flask,render_template,request,redirect,flash,url_for - - -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 - - -app = Flask(__name__) -app.secret_key = 'something_special' - -competitions = loadCompetitions() -clubs = loadClubs() - -@app.route('/') -def index(): - return render_template('index.html') - -@app.route('/showSummary',methods=['POST']) -def showSummary(): - club = [club for club in clubs if club['email'] == request.form['email']][0] - return render_template('welcome.html',club=club,competitions=competitions) - - -@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('/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) - - -# TODO: Add route for points display - - -@app.route('/logout') -def logout(): - return redirect(url_for('index')) \ No newline at end of file +from flask import Flask,render_template,request,redirect,flash,url_for,current_app +from datetime import datetime +from utils import ( + loadClubs, + loadCompetitions, + getClubByEmail, + getCompetitionByName, + getClubByName, + getClubPoints, + getCompetitionPlaces, + isCompetitionBookable, + isBookingValid, +) + +def create_app(config=None, clubs=None, competitions=None): + app = Flask(__name__) + app.secret_key = 'something_special' + if config: + app.config.update(config) + + 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') + + @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=buildCompetitionsView(available_competitions), + ) + + flash("Unfortunately, the email you entered was not found.") + return redirect(url_for('index')) + + + @app.route('/book//') + def book(competition,club): + 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')) + + 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.") + 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=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) + + @app.route('/purchasePlaces',methods=['POST']) + def purchasePlaces(): + 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')) + + competition = getCompetitionByName(request.form['competition'], available_competitions) + club = getClubByName(request.form['club'], available_clubs) + placesRequired = int(request.form['places']) + + if competition is None or club is None: + flash("Invalid booking request. Please check the club and competition names.") + return redirect(url_for('index')) + + if not isCompetitionBookable(competition): + flash("This competition is no longer open for booking.") + return render_template( + 'welcome.html', + club=club, + competitions=buildCompetitionsView(available_competitions), + ) + + validation_errors = isBookingValid( + 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(f'Booking complete: {placesRequired} places purchased.') + return render_template( + 'welcome.html', + club=club, + competitions=buildCompetitionsView(available_competitions), + ) + + # TODO: Add route for points display + + + @app.route('/logout') + def logout(): + return redirect(url_for('index')) + + return app + + +app = create_app() \ No newline at end of file diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..0ccd0b1b7 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -5,13 +5,24 @@ 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']}} + {% set max_places = [12, club['points']|int, competition['numberOfPlaces']|int] | min %}
- - + +
\ No newline at end of file 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/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 new file mode 100644 index 000000000..ac23eb5e5 --- /dev/null +++ b/utils.py @@ -0,0 +1,99 @@ +import json +from datetime import datetime + + +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + +def loadClubs(): + try: + with open('clubs.json') as c: + listOfClubs = json.load(c)['clubs'] + return listOfClubs + except (OSError, json.JSONDecodeError, KeyError): + return None + + +def loadCompetitions(): + 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): + 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() + +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 + +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 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 isBookingValid(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.") + + if placesRequested > 12: + errors.append("You cannot book more than 12 places per competition.") + + return errors \ No newline at end of file