diff --git a/.DS_Store b/.DS_Store index 04a508e6f..ed2a65c8b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 2cba99d87..cefb4c172 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ bin include lib .Python -tests/ + .envrc __pycache__ \ 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/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/booking_manager.py b/app/booking_manager.py new file mode 100644 index 000000000..3ef6047d3 --- /dev/null +++ b/app/booking_manager.py @@ -0,0 +1,84 @@ +# app/booking_manager.py + +import json +import os +from typing import List, Optional +from app.models import Club, Competition + + +class BookingManager: + """Gère la logique de réservation : chargement des données, vérification des règles, etc.""" + + def __init__(self, clubs_file: str, competitions_file: str): + self.clubs: List[Club] = self.load_clubs(clubs_file) + self.competitions: List[Competition] = self.load_competitions( + competitions_file) + + @staticmethod + def load_clubs(filepath: str) -> List[Club]: + """Charge et retourne la liste de clubs depuis un fichier JSON.""" + if not os.path.exists(filepath): + raise FileNotFoundError(f"Fichier introuvable : {filepath}") + with open(filepath, "r") as f: + data = json.load(f) + clubs = [] + for c in data["clubs"]: + clubs.append( + Club(name=c["name"], email=c["email"], points=int(c["points"])) + ) + return clubs + + @staticmethod + def load_competitions(filepath: str) -> List[Competition]: + """Charge et retourne la liste de compétitions depuis un fichier JSON.""" + if not os.path.exists(filepath): + raise FileNotFoundError(f"Fichier introuvable : {filepath}") + with open(filepath, "r") as f: + data = json.load(f) + competitions = [] + for c in data["competitions"]: + competitions.append( + Competition( + name=c["name"], + date=c["date"], + number_of_places=int(c["numberOfPlaces"]) + ) + ) + return competitions + + def find_club_by_email(self, email: str) -> Optional[Club]: + return next((club for club in self.clubs if club.email == email), None) + + def find_club_by_name(self, name: str) -> Optional[Club]: + return next((club for club in self.clubs if club.name == name), None) + + def find_competition_by_name(self, name: str) -> Optional[Competition]: + return next((c for c in self.competitions if c.name == name), None) + + def purchase_places(self, club_name: str, competition_name: str, places_requested: int) -> bool: + """Tente d'acheter `places_requested` places pour le `club_name` dans `competition_name`. + Renvoie True si l’opération réussit, False sinon (règles non respectées). + """ + club = self.find_club_by_name(club_name) + competition = self.find_competition_by_name(competition_name) + + # Vérifications basiques + if not club or not competition: + return False + + # 1) Pas plus de 12 places en une seule fois + if places_requested > 12: + return False + + # 2) Pas plus de places que le club n'a de points + if places_requested > club.points: + return False + + # 3) Pas plus de places que celles disponibles dans la compétition + if places_requested > competition.number_of_places: + return False + + # Si tout est OK, on décrémente les places + competition.number_of_places -= places_requested + club.points -= places_requested + return True diff --git a/app/models.py b/app/models.py new file mode 100644 index 000000000..daff602f2 --- /dev/null +++ b/app/models.py @@ -0,0 +1,13 @@ +# app/models.py +class Competition: + def __init__(self, name, date, number_of_places): + self.name = name + self.date = date + self.number_of_places = number_of_places + + +class Club: + def __init__(self, name, email, points): + self.name = name + self.email = email + self.points = points diff --git a/app/server.py b/app/server.py new file mode 100644 index 000000000..bd306ca80 --- /dev/null +++ b/app/server.py @@ -0,0 +1,72 @@ +# app/server.py + +from flask import Flask, render_template, request, redirect, flash, url_for +from app.booking_manager import BookingManager + +# Indique que le dossier statique se trouve dans ../static (à la racine du projet) +# Les templates seront recherchés par défaut dans "templates" situé dans ce même dossier (ici "app/templates") +app = Flask(__name__, static_folder="../static") +app.secret_key = "secret_key_xyz" + +# Instanciation du manager avec les chemins de fichiers en paramètre +manager = BookingManager( + clubs_file="data/clubs.json", + competitions_file="data/competitions.json" +) + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/showSummary", methods=["POST"]) +def show_summary(): + email = request.form.get("email", "") + club = manager.find_club_by_email(email) + if not club: + flash("Email inconnu ou invalide.") + return redirect(url_for("index")) + return render_template("welcome.html", club=club, competitions=manager.competitions) + + +@app.route("/book//") +def book(competition, club): + found_competition = manager.find_competition_by_name(competition) + found_club = manager.find_club_by_name(club) + if not found_competition or not found_club: + flash("Something went wrong - please try again") + return redirect(url_for("index")) + return render_template("booking.html", club=found_club, competition=found_competition) + + +@app.route("/purchasePlaces", methods=["POST"]) +def purchase_places(): + competition_name = request.form.get("competition") + club_name = request.form.get("club") + places_str = request.form.get("places") + try: + places_requested = int(places_str) + except ValueError: + flash("Le nombre de places est invalide.") + return redirect(url_for("index")) + + success = manager.purchase_places( + club_name, competition_name, places_requested) + if success: + flash("Great-booking complete!") + else: + flash("Impossible de réserver ces places (Règle non respectée).") + + club = manager.find_club_by_name(club_name) + return render_template("welcome.html", club=club, competitions=manager.competitions) + + +@app.route("/clubsPoints") +def clubs_points(): + return render_template("clubs_points.html", clubs=manager.clubs) + + +@app.route("/logout") +def logout(): + return redirect(url_for("index")) diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 000000000..3828a02fa --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,37 @@ + + + + + + + {% block title %}GUDLFT{% endblock %} + + + + + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + + + {% block content %}{% endblock %} +
+ + diff --git a/app/templates/booking.html b/app/templates/booking.html new file mode 100644 index 000000000..be884db6e --- /dev/null +++ b/app/templates/booking.html @@ -0,0 +1,16 @@ + +{% extends "base.html" %} + +{% block title %}Réservation | {{ competition['name'] }}{% endblock %} + +{% block content %} +

{{ competition['name'] }}

+

Places disponibles: {{ competition['number_of_places'] }}

+
+ + + + + +
+{% endblock %} diff --git a/app/templates/clubs_points.html b/app/templates/clubs_points.html new file mode 100644 index 000000000..f7a06436c --- /dev/null +++ b/app/templates/clubs_points.html @@ -0,0 +1,24 @@ + +{% extends "base.html" %} + +{% block title %}Points des Clubs{% endblock %} + +{% block content %} +

Points des Clubs

+ + + + + + + + + {% for club in clubs %} + + + + + {% endfor %} + +
ClubPoints
{{ club['name'] }}{{ club['points'] }}
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 000000000..4ef54b808 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,13 @@ + +{% extends "base.html" %} + +{% block title %}Accueil | GUDLFT{% endblock %} + +{% block content %} +

Bienvenue sur GUDLFT

+
+ + + +
+{% endblock %} diff --git a/app/templates/welcome.html b/app/templates/welcome.html new file mode 100644 index 000000000..b821d7bb1 --- /dev/null +++ b/app/templates/welcome.html @@ -0,0 +1,22 @@ + +{% extends "base.html" %} + +{% block title %}Résumé | GUDLFT Registration{% endblock %} + +{% block content %} +

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

+

Points disponibles: {{ club['points'] }}

+ +

Compétitions :

+ +{% endblock %} diff --git a/clubs.json b/data/clubs.json similarity index 100% rename from clubs.json rename to data/clubs.json diff --git a/competitions.json b/data/competitions.json similarity index 100% rename from competitions.json rename to data/competitions.json diff --git a/launch.json b/launch.json new file mode 100644 index 000000000..a0cc0ba8c --- /dev/null +++ b/launch.json @@ -0,0 +1,17 @@ +{ + "name": "Python Debugger: Flask", + "type": "debugpy", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "app/server.py", + "FLASK_DEBUG": "1" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload" + ], + "jinja": true, + "justMyCode": true +} diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 000000000..9c71c33dd --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,8 @@ +home = /usr/local/bin +implementation = CPython +version_info = 3.12.2.final.0 +virtualenv = 20.28.0 +include-system-site-packages = false +base-prefix = /Library/Frameworks/Python.framework/Versions/3.12 +base-exec-prefix = /Library/Frameworks/Python.framework/Versions/3.12 +base-executable = /Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 diff --git a/server.py b/server.py deleted file mode 100644 index 4084baeac..000000000 --- a/server.py +++ /dev/null @@ -1,59 +0,0 @@ -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 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 000000000..d13d15053 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,120 @@ +/* static/css/style.css */ + +/* Style global */ +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + background-color: #f8f8f8; + margin: 0; + padding: 0; + color: #333; + } + + /* Barre de navigation */ + nav { + background-color: #333; + color: #fff; + padding: 10px 20px; + } + + nav ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + } + + nav ul li { + margin-right: 15px; + } + + nav ul li a { + color: #fff; + text-decoration: none; + font-weight: bold; + } + + nav ul li a:hover { + text-decoration: underline; + } + + /* Conteneur principal */ + .container { + width: 90%; + max-width: 1000px; + margin: 20px auto; + background-color: #fff; + padding: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border-radius: 5px; + } + + /* Titres */ + h1, h2, h3 { + color: #444; + } + + /* Formulaires */ + form label { + display: block; + margin-bottom: 5px; + font-weight: bold; + } + + form input[type="email"], + form input[type="number"], + form input[type="text"] { + width: 100%; + max-width: 300px; + padding: 8px; + margin-bottom: 10px; + border: 1px solid #ddd; + border-radius: 4px; + } + + form button { + background-color: #333; + color: #fff; + border: none; + padding: 10px 15px; + border-radius: 4px; + cursor: pointer; + } + + form button:hover { + background-color: #555; + } + + /* Messages flash */ + .flashes { + list-style: none; + padding: 0; + margin: 10px 0; + } + + .flashes li { + background-color: #e74c3c; + color: #fff; + padding: 10px; + border-radius: 4px; + margin-bottom: 5px; + } + + /* Tableaux */ + table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + } + + table th, + table td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; + } + + table th { + background-color: #333; + color: #fff; + } + \ No newline at end of file diff --git a/templates/booking.html b/templates/booking.html deleted file mode 100644 index 06ae1156c..000000000 --- a/templates/booking.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Booking for {{competition['name']}} || GUDLFT - - -

{{competition['name']}}

- Places available: {{competition['numberOfPlaces']}} -
- - - - -
- - \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 926526b7d..000000000 --- a/templates/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - GUDLFT Registration - - -

Welcome to the GUDLFT Registration Portal!

- Please enter your secretary email to continue: -
- - - -
- - \ No newline at end of file diff --git a/templates/welcome.html b/templates/welcome.html deleted file mode 100644 index ff6b261a2..000000000 --- a/templates/welcome.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - Summary | GUDLFT Registration - - -

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

Logout - - {% with messages = get_flashed_messages()%} - {% if messages %} -
    - {% for message in messages %} -
  • {{message}}
  • - {% endfor %} -
- {% endif%} - Points available: {{club['points']}} -

Competitions:

-
    - {% for comp in competitions%} -
  • - {{comp['name']}}
    - Date: {{comp['date']}}
    - Number of Places: {{comp['numberOfPlaces']}} - {%if comp['numberOfPlaces']|int >0%} - Book Places - {%endif%} -
  • -
    - {% endfor %} -
- {%endwith%} - - - \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/test_functional.py b/tests/functional/test_functional.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py new file mode 100644 index 000000000..45570f9ec --- /dev/null +++ b/tests/integration/test_integration.py @@ -0,0 +1,27 @@ +import unittest +from app.server import app + + +class TestServerRoutes(unittest.TestCase): + + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + + def test_purchase_places_route(self): + data = { + "club": "Iron Temple", + "competition": "Spring Festival", + "places": "3" + } + response = self.client.post("/purchasePlaces", data=data) + # Vérifier qu'on obtient un code 200 et un message de succès + self.assertEqual(response.status_code, 200) + self.assertIn(b"booking complete", response.data.lower(), + "Doit contenir un message de réservation réussie.") + + # On peut ajouter des checks sur le HTML renvoyé, le nombre de places, etc. + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_booking_manager.py b/tests/unit/test_booking_manager.py new file mode 100644 index 000000000..63b83b3e7 --- /dev/null +++ b/tests/unit/test_booking_manager.py @@ -0,0 +1,42 @@ +import unittest +from app.booking_manager import BookingManager + + +class TestBookingManager(unittest.TestCase): + + def setUp(self): + # Instancier un BookingManager avec des JSON de test + self.manager = BookingManager( + clubs_file="data/clubs.json", + competitions_file="data/competitions.json" + ) + + def test_purchase_places_happy_path(self): + """ + Teste le cas normal : + - Club a suffisamment de points + - Compétition a suffisamment de places + - Moins de 12 places demandées + """ + success = self.manager.purchase_places( + "Iron Temple", "Spring Festival", 3) + self.assertTrue(success, "L'achat de 3 places devrait réussir.") + + def test_purchase_places_too_many_places_requested(self): + """ + Teste la limite de 12 places : si on essaie d'en prendre 13, ça doit échouer. + """ + success = self.manager.purchase_places( + "Iron Temple", "Spring Festival", 13) + self.assertFalse( + success, "L'achat de 13 places doit échouer (max 12).") + + # Ajoute d'autres tests pour couvrir : + # - club sans assez de points + # - competition full + # - club ou competition inexistant + # etc. + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py new file mode 100644 index 000000000..a32d405b6 --- /dev/null +++ b/tests/unit/test_server.py @@ -0,0 +1,38 @@ +import unittest +from app.server import app + + +class TestWelcomePage(unittest.TestCase): + + def setUp(self): + """ + Prépare l'application Flask en mode TEST. + """ + app.config['TESTING'] = True + self.client = app.test_client() + + def test_show_summary_no_undefined_error_for_unknown_email(self): + """ + Vérifie que l'application redirige (302) pour un email inconnu + et ne produit pas d'erreur Jinja une fois la redirection suivie. + """ + response = self.client.post( + '/showSummary', data={'email': 'email_inconnu@example.com'}) + + # On s'attend à un code 302 (redirection) + self.assertEqual(response.status_code, 302, + "La route doit rediriger pour un email inconnu.") + + # Optionnel : suivre la redirection pour valider le contenu final + response_followed = self.client.post( + '/showSummary', + data={'email': 'email_inconnu@example.com'}, + follow_redirects=True + ) + # Après la redirection, on devrait avoir un code 200 (page affichée) + self.assertEqual(response_followed.status_code, 200, + "Après redirection, on doit avoir un code 200.") + + # Vérifie qu'on ne voit pas 'UndefinedError' dans la page + self.assertNotIn(b'UndefinedError', response_followed.data, + "Aucune erreur Jinja ne doit être présente dans la page finale.") diff --git a/tests/unit/test_static_files.py b/tests/unit/test_static_files.py new file mode 100644 index 000000000..af9f43d3e --- /dev/null +++ b/tests/unit/test_static_files.py @@ -0,0 +1,25 @@ +import unittest +from app.server import app + + +class TestStaticFiles(unittest.TestCase): + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + + def test_css_loaded(self): + # On envoie une requête GET vers l'URL du CSS + response = self.client.get('/static/css/style.css') + # Vérifie que le serveur renvoie bien un code 200 (OK) + self.assertEqual(response.status_code, 200, + "Le CSS devrait être accessible et retourner un code 200.") + # Vérifie que le contenu du CSS n'est pas vide + self.assertTrue(len(response.data) > 0, + "Le fichier CSS ne doit pas être vide.") + # Optionnel : vérifier qu'une règle CSS connue est présente (ici "body") + self.assertIn(b"body", response.data, + "Le CSS doit contenir la règle 'body'.") + + +if __name__ == '__main__': + unittest.main()