Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
bin
include
lib
.Python
tests/
.envrc
__pycache__
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
.env
.env.*
*.env
.venv/
venv/
env/
.pytest_cache/
.coverage
htmlcov/

# IDE / OS
.vscode/
.idea/
.DS_Store

# Builds
dist/
build/
37 changes: 37 additions & 0 deletions docs/perf-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@

# Rapport de performance – Gudlft (Locust)

## Contexte & objectif
Mesurer latence/débit sur les endpoints principaux en environnement local. L’application garde son **état en mémoire** (pas de DB) : sous charge, des rejets métier (points/places/limite 12) sont **attendus**.

## Environnement
- Localhost (`http://127.0.0.1:5000`), serveur Flask de dev
- Windows + Python 3.13.5
- Locust 2.40.x (gevent)
- Scénarios Locust dans `tests/performance/locustfile.py`

## Critère de succès
On marque une requête **“succès”** si :
- **Succès explicite** : `Great-booking complete!`
- **OU** message métier **attendu** (ex. `Pas assez de points`, `Pas assez de places disponibles`, `Maximum 12...`, `Competition already finished`, `Invalid quantity`).

But : refléter la réalité fonctionnelle **sans** gonfler artificiellement le failure rate quand l’état s’épuise.

## Scénarios
- **BrowseUser** (lecture seule) : `GET /`, `POST /showSummary`, `GET /points`, `GET /book/<comp>/<club>`
- **BookingUser** (achat) : `POST /purchasePlaces`

## Procédure de test (reproductible)
Terminal 1 – serveur :
```powershell
.\.venv\Scripts\Activate.ps1
$env:FLASK_APP="server.py"
$env:FLASK_ENV="development"
flask run
```
## Run headless – 20 users, 2 min (Option B)
- Throughput agrégé : ~12–14 req/s (dernier snapshot ≈ 12.7 req/s)
- Latences : p50 ≈ 3 ms, p95 ≈ 4–5 ms, p99 ≤ 17 ms
- Fails : 0% (succès = achat OK **ou** message métier attendu)
- Commande : locust -f tests/performance/locustfile.py --host http://127.0.0.1:5000 --headless -u 20 -r 2 -t 2m --csv perf_run
- CSV : perf_run_stats.csv, perf_run_failures.csv
28 changes: 28 additions & 0 deletions docs/test-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Rapport de tests – Gudlft (Phase 1 & 2)

## Contexte
Application Flask sans base de données ; données en JSON, état en mémoire. Objectif : gestion de compétitions entre clubs (hébergement, inscriptions, frais et administration). Les règles clés testées : points vs places (1 point = 1 place), plafond **12** par club/compétition (cumul), blocage des compétitions passées, validation des entrées, messages flash lisibles.

## Environnement
- OS : Windows (PowerShell)
- Python : 3.13.5
- Outils : `pytest 8.4.x`, `coverage`, (perf séparée via Locust 2.40.x)
- Dossier projet : `Python_Testing/`

## Installation & exécution (reproductible)
```powershell
# Créer/activer l'environnement
py -m venv .venv
.\.venv\Scripts\Activate.ps1

# Dépendances
pip install -r requirements.txt
pip install -r requirements-dev.txt # si présent
```
## Résultats
```
Tests : 26 passed
Couverture : 97% (coverage report -m)
Rapport HTML : htmlcov/index.html

```
1 change: 1 addition & 0 deletions perf_run_exceptions.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Count,Message,Traceback,Nodes
Expand Down
1 change: 1 addition & 0 deletions perf_run_failures.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Method,Name,Error,Occurrences
Expand Down
7 changes: 7 additions & 0 deletions perf_run_stats.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%
GET,GET /,117,0,3,2.863653846190542,1.7238000000361353,16.994999983580783,521.0,4.463776056203892,0.0,3,3,3,3,3,4,6,8,17,17,17
GET,GET /book/Fall Classic/Simply Lift,31,0,3,3.1111096773642086,2.3249000078067183,16.517899988684803,840.0,1.18270989523351,0.0,3,3,3,3,3,3,17,17,17,17,17
GET,GET /points,52,0,2,2.855065386728921,1.832099980674684,17.162100004497916,578.0,1.9839004694239522,0.0,3,3,3,3,3,4,4,17,17,17,17
POST,POST /purchasePlaces,110,0,3,3.411788183042187,2.105600025970489,17.47570000588894,1406.0,4.196712531473745,0.0,3,3,3,3,4,4,17,17,17,17,17
POST,POST /showSummary,20,0,3,3.1272849984816276,2.6485999987926334,4.801999981282279,1198.0,0.7630386420861355,0.0,3,3,3,3,4,5,5,5,5,5,5
,Aggregated,330,0,3,3.0842354552023057,1.7238000000361353,17.47570000588894,895.9787878787879,12.590137594421234,0.0,3,3,3,3,4,4,8,17,17,17,17
Expand Down
27 changes: 27 additions & 0 deletions perf_run_stats_history.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size
1759515554,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0
1759515555,2,,Aggregated,0.000000,0.000000,3,3,4,4,8,8,8,8,8,8,8,6,0,3,4.014166668639518,2.5883000052999705,8.103200001642108,894.1666666666666
1759515556,4,,Aggregated,0.000000,0.000000,3,3,4,5,6,8,8,8,8,8,8,13,0,3,3.739976924127684,2.4760000233072788,8.103200001642108,865.4615384615385
1759515557,6,,Aggregated,6.000000,0.000000,3,3,3,3,5,6,8,8,8,8,8,21,0,3,3.3827523833939006,2.2515000018756837,8.103200001642108,885.7142857142857
1759515558,8,,Aggregated,6.000000,0.000000,3,3,3,3,4,6,8,8,8,8,8,31,0,3,3.1839161313274094,2.2515000018756837,8.103200001642108,880.9677419354839
1759515559,12,,Aggregated,6.333333,0.000000,3,3,3,3,4,5,8,8,8,8,8,43,0,3,3.08086744409466,2.2071000130381435,8.103200001642108,896.9767441860465
1759515560,14,,Aggregated,6.750000,0.000000,3,3,3,3,4,6,8,17,17,17,17,58,0,3,3.233653449569829,2.2071000130381435,16.517899988684803,898.0689655172414
1759515561,16,,Aggregated,7.400000,0.000000,3,3,3,3,3,5,8,17,17,17,17,71,0,3,3.148538029742834,2.2071000130381435,16.517899988684803,890.7887323943662
1759515562,18,,Aggregated,8.166667,0.000000,3,3,3,3,4,4,8,17,17,17,17,86,0,3,3.12140814115315,2.2071000130381435,16.517899988684803,892.6046511627907
1759515564,20,,Aggregated,9.142857,0.000000,3,3,3,3,4,4,8,17,17,17,17,104,0,3,3.2453365403433474,2.2071000130381435,17.292900010943413,913.5480769230769
1759515565,20,,Aggregated,9.625000,0.000000,3,3,3,3,4,4,8,17,17,17,17,122,0,3,3.1705254113461945,2.171300002373755,17.292900010943413,899.7622950819672
1759515566,20,,Aggregated,9.555556,0.000000,3,3,3,3,4,4,17,17,17,17,17,136,0,3,3.2420720599475317,2.171300002373755,17.292900010943413,896.0735294117648
1759515567,20,,Aggregated,10.400000,0.000000,3,3,3,3,4,4,17,17,17,17,17,149,0,3,3.2712530212683117,1.764099986758083,17.47570000588894,895.5704697986578
1759515568,20,,Aggregated,11.600000,0.000000,3,3,3,3,4,4,17,17,17,17,17,160,0,3,3.238246251203236,1.764099986758083,17.47570000588894,902.8875
1759515569,20,,Aggregated,13.000000,0.000000,3,3,3,3,4,4,17,17,17,17,17,173,0,3,3.2029849722055515,1.764099986758083,17.47570000588894,887.2543352601156
1759515570,20,,Aggregated,13.000000,0.000000,3,3,3,3,4,4,17,17,17,17,17,187,0,3,3.166143316619925,1.764099986758083,17.47570000588894,902.812834224599
1759515571,20,,Aggregated,13.500000,0.000000,3,3,3,3,3,4,17,17,17,17,17,197,0,3,3.1404086305324914,1.764099986758083,17.47570000588894,900.4365482233502
1759515572,20,,Aggregated,13.500000,0.000000,3,3,3,3,3,4,8,17,17,17,17,211,0,3,3.120472512767381,1.764099986758083,17.47570000588894,899.7962085308056
1759515573,20,,Aggregated,13.200000,0.000000,3,3,3,3,3,4,8,17,17,17,17,225,0,3,3.098057334104346,1.764099986758083,17.47570000588894,902.9155555555556
1759515574,20,,Aggregated,13.400000,0.000000,3,3,3,3,3,4,17,17,17,17,17,236,0,3,3.134600424515557,1.7238000000361353,17.47570000588894,901.5635593220339
1759515575,20,,Aggregated,13.800000,0.000000,3,3,3,3,4,4,8,17,17,17,17,252,0,3,3.117447223173388,1.7238000000361353,17.47570000588894,901.9087301587301
1759515576,20,,Aggregated,13.000000,0.000000,3,3,3,3,3,4,8,17,17,17,17,265,0,3,3.096227925534378,1.7238000000361353,17.47570000588894,905.3245283018867
1759515577,20,,Aggregated,12.900000,0.000000,3,3,3,3,3,4,8,17,17,17,17,273,0,3,3.092184982747829,1.7238000000361353,17.47570000588894,901.9230769230769
1759515578,20,,Aggregated,12.800000,0.000000,3,3,3,3,4,4,8,17,17,17,17,288,0,3,3.0804031259524183,1.7238000000361353,17.47570000588894,899.8611111111111
1759515579,20,,Aggregated,12.300000,0.000000,3,3,3,3,4,4,6,17,17,17,17,301,0,3,3.067390034164757,1.7238000000361353,17.47570000588894,899.827242524917
1759515580,20,,Aggregated,12.600000,0.000000,3,3,3,3,4,4,8,17,17,17,17,315,0,3,3.090096508718229,1.7238000000361353,17.47570000588894,899.2444444444444
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ itsdangerous==1.1.0
Jinja2==2.11.2
MarkupSafe==1.1.1
Werkzeug==1.0.1
pytest
coverage
locust
185 changes: 141 additions & 44 deletions server.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,156 @@
import json
from flask import Flask,render_template,request,redirect,flash,url_for

from datetime import datetime
from flask import Flask, render_template, request, redirect, flash, url_for, session

# ---------- Data loading ----------
def loadClubs():
with open('clubs.json') as c:
listOfClubs = json.load(c)['clubs']
return listOfClubs

with open("clubs.json", "r", encoding="utf-8") as c:
return json.load(c)["clubs"]

def loadCompetitions():
with open('competitions.json') as comps:
listOfCompetitions = json.load(comps)['competitions']
return listOfCompetitions

with open("competitions.json", "r", encoding="utf-8") as comps:
return json.load(comps)["competitions"]

app = Flask(__name__)
app.secret_key = 'something_special'
app.secret_key = "something_secret_for_flash" # NOTE: en prod, utiliser une variable d'env

competitions = loadCompetitions()
# Données en mémoire (réinitialisées dans les tests via conftest)
clubs = loadClubs()
competitions = loadCompetitions()

@app.route('/')
# suivi cumulatif des réservations par (club, competition)
# clé: (club_name, comp_name) -> int
club_bookings = {}

# ---------- Helpers ----------
def find_club_by_name(name):
return next((c for c in clubs if c.get("name") == name), None)

def find_club_by_email(email_value):
if not email_value:
return None
return next((c for c in clubs if c.get("email", "").lower() == email_value.lower()), None)

def find_comp_by_name(name):
return next((c for c in competitions if c.get("name") == name), None)

def competition_in_past(competition) -> bool:
"""True si la compétition est passée. Tolérant aux dates mal formées."""
try:
comp_dt = datetime.strptime(competition["date"], "%Y-%m-%d %H:%M:%S")
return comp_dt < datetime.now()
except Exception:
return False

def validate_purchase(club, competition, places_required, current_booked=0):
"""
Règles:
- compétition non passée
- places_required entier > 0
- places_required <= places restantes
- points du club >= places_required
- plafond 12 (cumul par club/compétition)
"""
# 1) compétition non passée
if competition_in_past(competition):
return False, "Competition already finished"

# 2) quantité valide
try:
n = int(places_required)
except (TypeError, ValueError):
return False, "Invalid quantity"
if n <= 0:
return False, "Invalid quantity (>=1)"

# 3) places restantes
comp_left = int(competition["numberOfPlaces"])
if n > comp_left:
return False, "Pas assez de places disponibles"

# 4) points du club
club_pts = int(club["points"])
if n > club_pts:
return False, "Pas assez de points"

# 5) plafond 12 cumulé
if current_booked + n > 12:
return False, "Maximum 12 places par club sur une compétition"

return True, None

# ---------- Routes ----------
@app.route("/")
def index():
return render_template('index.html')
# index doit afficher les messages flash ("Email inconnu", etc.)
return render_template("index.html")

@app.route('/showSummary',methods=['POST'])
@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/<competition>/<club>')
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'])
email_input = (request.form.get("email") or "").strip()
club = next((c for c in clubs if c.get("email") == email_input), None)
if not club:
flash("Adresse mail inconnue")
flash("Email inconnu")
return redirect(url_for("index"))
session["club_email"] = club["email"].lower()
return render_template("welcome.html", club=club, competitions=competitions)


@app.route("/book/<competition>/<club>")
def book(competition, club):
foundClub = find_club_by_name(club)
foundCompetition = find_comp_by_name(competition)
if not foundClub or not foundCompetition:
flash("Club ou compétition introuvable")
return redirect(url_for("index"))
return render_template("booking.html", club=foundClub, competition=foundCompetition)

@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')
comp_name = request.form.get("competition")
places_str = request.form.get("places", "0")

# récupérer le club depuis la session (le formulaire peut ne pas l'envoyer)
club_email = session.get("club_email")
club = find_club_by_email(club_email)
competition = find_comp_by_name(comp_name) if comp_name else None

if not competition or not club:
# Le test cherche le mot "invalides" dans le message d'erreur
flash("Données invalides (club/compétition invalides)")
return redirect(url_for("index"))

booked = club_bookings.get((club["name"], competition["name"]), 0)
ok, msg = validate_purchase(club, competition, places_str, current_booked=booked)
if not ok:
flash(msg)
# On reste sur la page du club (welcome), status 200 attendu par les tests
return render_template("welcome.html", club=club, competitions=competitions)

n = int(places_str)

# appliquer la réservation (cohérence points/places)
new_places = int(competition["numberOfPlaces"]) - n
new_points = int(club["points"]) - n
if new_places < 0 or new_points < 0:
# sauvegarde défensive (ne devrait pas arriver si la validation est OK)
flash("Erreur de calcul des points/places")
return render_template("welcome.html", club=club, competitions=competitions)

competition["numberOfPlaces"] = str(new_places)
club["points"] = str(new_points)
club_bookings[(club["name"], competition["name"])] = booked + n

flash("Great-booking complete!")
return render_template("welcome.html", club=club, competitions=competitions)

@app.route("/points")
def points():
# tableau public (pas de login requis)
return render_template("points.html", clubs=clubs)

@app.route("/logout")
def logout():
return redirect(url_for('index'))
session.clear()
return redirect(url_for("index"))
15 changes: 15 additions & 0 deletions templates/booking.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@
<title>Booking for {{competition['name']}} || GUDLFT</title>
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="flashes">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}

<!-- Optionnel: un peu de style pour la lisibilité -->
<style>
.flashes { list-style: none; padding: 0; }
.flashes li { background: #fff3cd; border: 1px solid #ffeeba; padding: .5rem .75rem; margin: .5rem 0; }
</style>
<h2>{{competition['name']}}</h2>
Places available: {{competition['numberOfPlaces']}}
<form action="/purchasePlaces" method="post">
Expand Down
Loading