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
8 changes: 8 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[run]
source = server
omit =
tests/*

[report]
show_missing = True
skip_covered = False
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
bin
include
lib
lib64
pyvenv.cfg
.Python
tests/
.envrc
__pycache__
__pycache__
.pytest_cache
.coverage
htmlcov/
22 changes: 21 additions & 1 deletion competitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@
"name": "Fall Classic",
"date": "2020-10-22 13:30:00",
"numberOfPlaces": "13"
},
{
"name": "Early Summer Classic",
"date": "2026-06-01 10:00:00",
"numberOfPlaces": "10"
},
{
"name": "Summer Lift Off",
"date": "2026-08-15 09:00:00",
"numberOfPlaces": "20"
},
{
"name": "Winter Games",
"date": "2026-12-05 14:00:00",
"numberOfPlaces": "15"
},
{
"name": "Regional Championship",
"date": "2027-03-20 10:30:00",
"numberOfPlaces": "30"
}
]
}
}
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
234 changes: 218 additions & 16 deletions server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import json
from flask import Flask,render_template,request,redirect,flash,url_for
from datetime import datetime
from functools import wraps

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


def loadClubs():
Expand All @@ -14,46 +17,245 @@ def loadCompetitions():
return listOfCompetitions


COMPETITION_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'


def is_competition_upcoming(competition):
"""Retourne True si la compétition n'a pas encore eu lieu."""
competition_date = datetime.strptime(competition['date'], COMPETITION_DATE_FORMAT)
return competition_date > datetime.now()


def get_upcoming_competitions():
"""Retourne uniquement les compétitions à venir (Phase 1)."""
return [competition for competition in competitions if is_competition_upcoming(competition)]


def get_club_by_email(email):
"""Retourne le club correspondant à un email, ou None si inconnu."""
matching_clubs = [club for club in clubs if club['email'] == email]
return matching_clubs[0] if matching_clubs else None


def get_logged_in_club():
"""Lit la session Flask et retourne le club connecté, ou None."""
email = session.get('club_email')
if not email:
return None
return get_club_by_email(email)


def login_required(view_func):
"""
Décorateur de protection des routes : vérifie qu'un secrétaire est connecté.
Si la session est absente, redirige vers la page de connexion.
Sinon, injecte logged_in_club dans la vue pour éviter de relire la session.
"""
@wraps(view_func)
def wrapped_view(*args, **kwargs):
logged_in_club = get_logged_in_club()
if not logged_in_club:
flash('Please log in to access this page.')
return redirect(url_for('index'))
return view_func(*args, logged_in_club=logged_in_club, **kwargs)
return wrapped_view


MAX_PLACES_PER_CLUB = 12

# Places déjà réservées par couple (club, compétition) — suivi cumulé en mémoire
club_competition_bookings = {}


def get_places_already_booked(club, competition):
"""Retourne le nombre de places déjà réservées par un club pour une compétition."""
booking_key = (club['name'], competition['name'])
return club_competition_bookings.get(booking_key, 0)


def record_purchase(club, competition, places):
"""Enregistre une réservation dans le compteur cumulé."""
booking_key = (club['name'], competition['name'])
club_competition_bookings[booking_key] = get_places_already_booked(club, competition) + places


def validate_purchase(club, competition, places_required):
"""
Vérifie les règles métier avant une réservation.
Retourne un message d'erreur, ou None si la réservation est valide.
"""
if places_required <= 0:
return 'Number of places must be at least 1.'

if not is_competition_upcoming(competition):
return 'This competition has already taken place.'

if places_required > MAX_PLACES_PER_CLUB:
return f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition.'

already_booked = get_places_already_booked(club, competition)
if already_booked + places_required > MAX_PLACES_PER_CLUB:
remaining_allowed = MAX_PLACES_PER_CLUB - already_booked
if remaining_allowed <= 0:
return (
f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition '
f'for your club.'
)
return (
f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition. '
f'You already have {already_booked}, you can only book {remaining_allowed} more.'
)

available_places = int(competition['numberOfPlaces'])
if available_places <= 0:
return 'Competition is full.'
if places_required > available_places:
return f'Not enough places available. Only {available_places} remaining.'

club_points = int(club['points'])
if places_required > club_points:
return 'Not enough points available.'

return None


def require_own_club(club, logged_in_club):
"""
Vérifie qu'un secrétaire ne réserve que pour son propre club.
Retourne une redirection en cas d'échec, None sinon.
"""
if logged_in_club['email'] != club['email']:
flash('You can only book places for your own club.')
return redirect(url_for('showSummary'))
return None


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'])

@app.route('/showSummary', methods=['GET', '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)
if request.method == 'POST':
# Connexion : vérifie l'email puis enregistre le club en session
matching_clubs = [club for club in clubs if club['email'] == request.form['email']]
if not matching_clubs:
flash('Email not found. Please use a registered secretary email.')
return redirect(url_for('index'))
session['club_email'] = matching_clubs[0]['email']
# POST-redirect-GET : évite de re-soumettre le formulaire au retour arrière
return redirect(url_for('showSummary'))
return _render_summary()


@login_required
def _render_summary(logged_in_club):
"""Affiche le tableau de bord du club connecté (route GET /showSummary)."""
return render_template(
'welcome.html',
club=logged_in_club,
competitions=get_upcoming_competitions(),
)


# Réservation : accès réservé aux secrétaires connectés (@login_required)
@app.route('/book/<competition>/<club>')
def book(competition,club):
@login_required
def book(competition, club, logged_in_club):
foundClub = [c for c in clubs if c['name'] == club][0]
foundCompetition = [c for c in competitions if c['name'] == competition][0]

redirect_response = require_own_club(foundClub, logged_in_club)
if redirect_response:
return redirect_response

if not is_competition_upcoming(foundCompetition):
flash('This competition has already taken place.')
return redirect(url_for('showSummary'))

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)
already_booked = get_places_already_booked(foundClub, foundCompetition)
max_places = min(
MAX_PLACES_PER_CLUB - already_booked,
int(foundCompetition['numberOfPlaces']),
int(foundClub['points']),
)
if max_places <= 0:
flash(
f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition '
f'for your club.'
)
return redirect(url_for('showSummary'))
return render_template(
'booking.html',
club=foundClub,
competition=foundCompetition,
max_places=max_places,
already_booked=already_booked,
)

flash("Something went wrong-please try again")
return redirect(url_for('showSummary'))


@app.route('/purchasePlaces',methods=['POST'])
@app.route('/purchasePlaces', methods=['GET', 'POST'])
def purchasePlaces():
if request.method == 'GET':
# Accès direct à l'URL après une réservation (retour arrière / refresh)
if get_logged_in_club():
return redirect(url_for('showSummary'))
flash('Please log in to access this page.')
return redirect(url_for('index'))
return _process_purchase()


@login_required
def _process_purchase(logged_in_club):
"""Traite la réservation (route POST /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)

redirect_response = require_own_club(club, logged_in_club)
if redirect_response:
return redirect_response

try:
placesRequired = int(request.form['places'])
except (ValueError, TypeError, KeyError):
flash('Invalid number of places.')
return redirect(url_for('showSummary'))

error_message = validate_purchase(club, competition, placesRequired)
if error_message:
flash(error_message)
return redirect(url_for('showSummary'))

# Déduction des places et des points (1 point = 1 place)
competition['numberOfPlaces'] = str(int(competition['numberOfPlaces']) - placesRequired)
club['points'] = str(int(club['points']) - placesRequired)
record_purchase(club, competition, placesRequired)
flash(f'Great-booking complete! {placesRequired} places purchased.')
# POST-redirect-GET : l'URL ne reste plus bloquée sur /purchasePlaces
return redirect(url_for('showSummary'))


# TODO: Add route for points display
# Phase 2 : tableau public des points, consultable sans être connecté
@app.route('/clubsPoints')
def clubsPoints():
"""Affiche le solde de points de tous les clubs (lecture seule, accès public)."""
return render_template('clubs_points.html', clubs=clubs)


@app.route('/logout')
def logout():
return redirect(url_for('index'))
# Invalide la session côté serveur pour bloquer l'accès aux routes protégées
session.clear()
return redirect(url_for('index'))
8 changes: 7 additions & 1 deletion templates/booking.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
<body>
<h2>{{competition['name']}}</h2>
Places available: {{competition['numberOfPlaces']}}
<p>Your club points: {{ club['points'] }}</p>
{% if already_booked > 0 %}
<p>Already booked for this competition: {{ already_booked }}</p>
{% endif %}
<p>You can book up to <strong>{{ max_places }}</strong> place(s) for this competition.</p>
<form action="/purchasePlaces" method="post">
<input type="hidden" name="club" value="{{club['name']}}">
<input type="hidden" name="competition" value="{{competition['name']}}">
<label for="places">How many places?</label><input type="number" name="places" id=""/>
<label for="places">How many places?</label>
<input type="number" name="places" id="places" min="1" max="{{ max_places }}" required/>
<button type="submit">Book</button>
</form>
</body>
Expand Down
Loading