diff --git a/.coverage b/.coverage new file mode 100644 index 000000000..78d7007cb Binary files /dev/null and b/.coverage differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..96e09e2ab --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + */__init__.py + */globals.py diff --git a/.gitignore b/.gitignore index 2cba99d87..f017d8e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ bin include lib .Python -tests/ .envrc -__pycache__ \ No newline at end of file +__pycache__ +.env +.flake8 \ No newline at end of file diff --git a/clubs.json b/clubs.json index 1d7ad1ffe..2fd53d22f 100644 --- a/clubs.json +++ b/clubs.json @@ -1,15 +1,19 @@ {"clubs":[ { + "id": "25e97c3f-844b-4200-a3cb-7f0a09c0f623", "name":"Simply Lift", "email":"john@simplylift.co", "points":"13" }, { + "id": "d5c2bc91-2171-40ef-a9bc-49af8e6518c9", "name":"Iron Temple", "email": "admin@irontemple.com", "points":"4" }, - { "name":"She Lifts", + { + "id": "006a09e0-9325-471c-970b-6fb81c3146d4", + "name":"She Lifts", "email": "kate@shelifts.co.uk", "points":"12" } diff --git a/competitions.json b/competitions.json index 039fc61bd..f74a08c2f 100644 --- a/competitions.json +++ b/competitions.json @@ -1,14 +1,22 @@ { "competitions": [ { + "id": "89bee937-7503-431a-bb1c-d2a05998170a", "name": "Spring Festival", "date": "2020-03-27 10:00:00", - "numberOfPlaces": "25" + "available_places": "25" }, { + "id": "0684ecc2-80ca-4057-a144-3357298689d9", "name": "Fall Classic", "date": "2020-10-22 13:30:00", - "numberOfPlaces": "13" + "available_places": "13" + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Summer Challenge", + "date": "2025-08-27 10:00:00", + "available_places": "20" } ] } \ No newline at end of file diff --git a/entities/__init__.py b/entities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/entities/club.py b/entities/club.py new file mode 100644 index 000000000..74d29915a --- /dev/null +++ b/entities/club.py @@ -0,0 +1,68 @@ +import uuid + + +class Club: + """Class representing a club with a name, email, and points system for reservation.""" + + def __init__(self, name: str, email: str, points: int): + self._id = str(uuid.uuid4()) + self._name = name + self._email = email + self._points = points + + def has_enough_points(self, points: int) -> bool: + """Check if the club has enough points.""" + return self._points >= points + + def consume_points(self, points: int) -> None: + """Consume points from the club.""" + if not self.has_enough_points(points): + raise ValueError("Not enough points to consume.") + self._points -= points + + def reserve(self, competition, places, date): + """Reserve places for the club in a competition.""" + return competition.reserve_places(self, places, date) + + @property + def id(self) -> str: + """Get the ID of the club.""" + return self._id + + @property + def name(self) -> str: + """Get the name of the club.""" + return self._name + + @property + def email(self) -> str: + """Get the email of the club.""" + return self._email + + @property + def points(self) -> int: + """Get the points of the club.""" + return self._points + + @classmethod + def deserialize(cls, data: dict): + """Deserialize data into the Club object.""" + club = cls( + name=data.get("name"), + email=data.get("email"), + points=int(data.get("points")), + ) + club._id = data.get("id") + return club + + def serialize(self) -> dict: + """Serialize the Club object into a dictionary.""" + return { + "id": self._id, + "name": self._name, + "email": self._email, + "points": str(self._points), + } + + def __str__(self) -> str: + return f"Club(id={self._id}, name={self._name}, email={self._email}, points={self._points})" diff --git a/entities/competition.py b/entities/competition.py new file mode 100644 index 000000000..ab915f0df --- /dev/null +++ b/entities/competition.py @@ -0,0 +1,94 @@ +import uuid +from datetime import datetime + +from entities.reservation import Reservation + + +class Competition: + """Class representing a competition with a name, date, available places and maximum places per reservation.""" + + MAX_PLACES_PER_RESERVATION = 12 + + def __init__( + self, + name: str, + date: datetime, + available_places: int, + max_places_per_reservation: int = MAX_PLACES_PER_RESERVATION, + ): + self._id = str(uuid.uuid4()) + self._name = name + self._date = date + self._available_places = available_places + self._max_places_per_reservation = max_places_per_reservation + + def can_reserve(self, places: int) -> bool: + """Check if there are enough available places to reserve.""" + return self._available_places >= places + + def is_within_reservation_limit(self, places: int, total_already_reserved: int) -> bool: + """Check if the total reserved places stay within the allowed limit.""" + return total_already_reserved + places <= self._max_places_per_reservation + + def reserve_places(self, club, places, date): + """Reserve places for a club in the competition.""" + if not self.can_reserve(places): + raise ValueError("Not enough available places to reserve.") + if not club.has_enough_points(places): + raise ValueError("Not enough points to reserve places.") + + club.consume_points(places) + self._available_places -= places + + return Reservation(club.id, self._id, places, date) + + @property + def id(self) -> str: + """Get the ID of the competition.""" + return self._id + + @property + def name(self) -> str: + """Get the name of the competition.""" + return self._name + + @property + def date(self) -> datetime: + """Get the date of the competition.""" + return self._date + + @property + def available_places(self) -> int: + """Get the number of available places.""" + return self._available_places + + @property + def max_places_per_reservation(self) -> int: + """Get the maximum places per reservation.""" + return self._max_places_per_reservation + + @classmethod + def deserialize(cls, data: dict): + """Deserialize data into the Competition object.""" + competition = cls( + name=data.get("name"), + date=datetime.strptime(data.get("date"), "%Y-%m-%d %H:%M:%S"), + available_places=int(data.get("available_places")), + ) + competition._id = data.get("id") + return competition + + def serialize(self) -> dict: + """Serialize the Competition object into a dictionary.""" + return { + "id": self._id, + "name": self._name, + "date": self._date.strftime("%Y-%m-%d %H:%M:%S"), + "available_places": str(self._available_places), + } + + def __str__(self) -> str: + return ( + f"Competition(id={self._id}, name={self._name}, " + f"date={self._date}, available_places={self._available_places})" + ) diff --git a/entities/reservation.py b/entities/reservation.py new file mode 100644 index 000000000..8f10f04ce --- /dev/null +++ b/entities/reservation.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime + + +class Reservation: + """Class representing a reservation for a club in a competition.""" + + def __init__(self, club_id: str, competition_id: str, reserved_places: int, date: datetime): + self._id = str(uuid.uuid4()) + self._club_id = club_id + self._competition_id = competition_id + self._reserved_places = reserved_places + self._date = date + + @property + def club_id(self) -> str: + """Get the ID of the club.""" + return self._club_id + + @property + def competition_id(self) -> str: + """Get the ID of the competition.""" + return self._competition_id + + @property + def reserved_places(self) -> int: + """Get the number of reserved places.""" + return self._reserved_places + + @property + def date(self) -> datetime: + """Get the date of the reservation.""" + return self._date + + @classmethod + def deserialize(cls, data: dict): + """Deserialize data into the Reservation object.""" + reservation = cls( + club_id=data.get("club_id"), + competition_id=data.get("competition_id"), + reserved_places=int(data.get("reserved_places")), + date=datetime.strptime(data.get("date"), "%Y-%m-%d %H:%M:%S"), + ) + reservation._id = data.get("id") + return reservation + + def serialize(self) -> dict: + """Serialize the Reservation object into a dictionary.""" + return { + "id": self._id, + "club_id": self._club_id, + "competition_id": self._competition_id, + "reserved_places": str(self._reserved_places), + "date": self._date.strftime("%Y-%m-%d %H:%M:%S"), + } + + def __str__(self) -> str: + return ( + f"Reservation(id={self._id}, club_id={self._club_id}, " + f"competition_id={self._competition_id}, reserved_places={self._reserved_places}, date={self._date})" + ) diff --git a/globals.py b/globals.py new file mode 100644 index 000000000..38424ffb6 --- /dev/null +++ b/globals.py @@ -0,0 +1 @@ +LIMITED_BOOKING_PLACE = 12 diff --git a/json_services.py b/json_services.py new file mode 100644 index 000000000..7f0e98460 --- /dev/null +++ b/json_services.py @@ -0,0 +1,16 @@ +import json + + +class JSONServices: + @staticmethod + def load(filename: str): + try: + with open(filename) as file: + return json.load(file) + except FileNotFoundError: + raise FileNotFoundError(f"File {filename} not found.") + + @staticmethod + def save(file_name, data): + with open(file_name, "w") as file: + json.dump(data, file, indent=4) diff --git a/repositories/__init__.py b/repositories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/repositories/club_json_repository.py b/repositories/club_json_repository.py new file mode 100644 index 000000000..71bd162de --- /dev/null +++ b/repositories/club_json_repository.py @@ -0,0 +1,46 @@ +import os + +from entities.club import Club +from json_services import JSONServices +from repository_protocols.club_repository import ClubRepository + + +class ClubJsonRepository(ClubRepository): + """Repository for managing clubs stored in a JSON file.""" + + def __init__(self, file_path: str): + self.file_path = file_path + self._clubs: list[Club] = self._load() + + def _load(self) -> list[Club]: + """Load clubs from the JSON file. If the file does not exist, return an empty list.""" + if not os.path.exists(self.file_path): + return [] + + data = JSONServices.load(self.file_path).get("clubs", []) + return [Club.deserialize(club) for club in data] + + def reload(self) -> None: + """Reload the clubs from the JSON file.""" + self._clubs = self._load() + + def all(self) -> list[Club]: + """Get all clubs from the repository.""" + return self._clubs + + def find_by_name(self, name: str) -> Club | None: + """Find a club by its name.""" + return next((club for club in self._clubs if club.name == name), None) + + def find_by_email(self, email: str) -> Club | None: + """Find a club by its email.""" + return next((club for club in self._clubs if club.email == email), None) + + def update(self, club: Club) -> None: + """Update an existing club in the repository.""" + for i, existing_club in enumerate(self._clubs): + if existing_club.name == club.name: + self._clubs[i] = club + break + serialized_data = [club.serialize() for club in self._clubs] + JSONServices.save(self.file_path, {"clubs": serialized_data}) diff --git a/repositories/competition_json_repository.py b/repositories/competition_json_repository.py new file mode 100644 index 000000000..fae6bdf90 --- /dev/null +++ b/repositories/competition_json_repository.py @@ -0,0 +1,46 @@ +import os +from datetime import datetime + +from entities.competition import Competition +from json_services import JSONServices +from repository_protocols.competition_repository import CompetitionRepository + + +class CompetitionJsonRepository(CompetitionRepository): + """Repository for managing competitions stored in a JSON file.""" + + def __init__(self, file_path: str): + self.file_path = file_path + self._competitions: list[Competition] = self._load() + + def _load(self) -> list[Competition]: + """Load competitions from the JSON file. If the file does not exist, return an empty list.""" + if not os.path.exists(self.file_path): + return [] + + data = JSONServices.load(self.file_path).get("competitions", []) + return [Competition.deserialize(competition) for competition in data] + + def reload(self) -> None: + """Reload the competitions from the JSON file.""" + self._competitions = self._load() + + def all(self) -> list[Competition]: + """Get all competitions sorted by upcoming first, then outdated.""" + now = datetime.now() + upcoming = [competition for competition in self._competitions if competition.date >= now] + outdated = [competition for competition in self._competitions if competition.date < now] + return sorted(upcoming, key=lambda c: c.date) + sorted(outdated, key=lambda c: c.date) + + def find_by_name(self, name: str) -> Competition | None: + """Find a competition by its name.""" + return next((competition for competition in self._competitions if competition.name == name), None) + + def update(self, competition: Competition) -> None: + """Update an existing competition in the repository.""" + for i, existing_competition in enumerate(self._competitions): + if existing_competition.name == competition.name: + self._competitions[i] = competition + break + serialized_data = [competition.serialize() for competition in self._competitions] + JSONServices.save(self.file_path, {"competitions": serialized_data}) diff --git a/repositories/reservation_json_repository.py b/repositories/reservation_json_repository.py new file mode 100644 index 000000000..e75f39804 --- /dev/null +++ b/repositories/reservation_json_repository.py @@ -0,0 +1,52 @@ +import os + +from entities.reservation import Reservation +from json_services import JSONServices +from repository_protocols.reservation_repository import ReservationRepository + + +class ReservationJsonRepository(ReservationRepository): + """Repository for managing reservations stored in a JSON file.""" + + def __init__(self, file_path: str): + self.file_path = file_path + self._reservations: list[Reservation] = self._load() + + def _load(self) -> list[Reservation]: + """Load reservations from the JSON file. If the file does not exist, return an empty list.""" + if not os.path.exists(self.file_path): + return [] + + data = JSONServices.load(self.file_path).get("reservations", []) + return [Reservation.deserialize(reservation) for reservation in data] + + def reload(self) -> None: + """Reload the reservations from the JSON file.""" + self._reservations = self._load() + + def _add(self, reservation: Reservation) -> None: + """Add a reservation to the repository.""" + self._reservations.append(reservation) + + def all(self) -> list[Reservation]: + """Get all reservations from the repository.""" + return self._reservations + + def get_club_reservations_for_competition(self, club_id: str, competition_id: str) -> list[Reservation]: + """Get all reservations for a specific club and competition.""" + return [ + reservation + for reservation in self._reservations + if reservation.club_id == club_id and reservation.competition_id == competition_id + ] + + def get_total_places_club_reservation_for_competition(self, club_id: str, competition_id: str) -> int: + """Get the total number of places reserved by a club for a specific competition.""" + reservations = self.get_club_reservations_for_competition(club_id, competition_id) + return sum(reservation.reserved_places for reservation in reservations) + + def save(self, reservation: Reservation) -> None: + """Save a new reservation to the repository.""" + self._add(reservation) + serialized_data = [reservation.serialize() for reservation in self._reservations] + JSONServices.save(self.file_path, {"reservations": serialized_data}) diff --git a/repository_protocols/__init__.py b/repository_protocols/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/repository_protocols/club_repository.py b/repository_protocols/club_repository.py new file mode 100644 index 000000000..cb526f641 --- /dev/null +++ b/repository_protocols/club_repository.py @@ -0,0 +1,15 @@ +from typing import Protocol + +from entities.club import Club + + +class ClubRepository(Protocol): + """Protocol for a repository that manages Club entities.""" + + def all(self) -> list[Club]: ... + + def find_by_name(self, name: str) -> Club | None: ... + + def find_by_email(self, email: str) -> Club | None: ... + + def update(self, club: Club) -> None: ... diff --git a/repository_protocols/competition_repository.py b/repository_protocols/competition_repository.py new file mode 100644 index 000000000..960fedcce --- /dev/null +++ b/repository_protocols/competition_repository.py @@ -0,0 +1,13 @@ +from typing import Protocol + +from entities.competition import Competition + + +class CompetitionRepository(Protocol): + """Protocol for a repository that manages Competition entities.""" + + def all(self) -> list[Competition]: ... + + def find_by_name(self, name: str) -> Competition | None: ... + + def update(self, competition: Competition) -> None: ... diff --git a/repository_protocols/reservation_repository.py b/repository_protocols/reservation_repository.py new file mode 100644 index 000000000..6ec5b6eb4 --- /dev/null +++ b/repository_protocols/reservation_repository.py @@ -0,0 +1,17 @@ +from typing import Protocol + +from entities.reservation import Reservation + + +class ReservationRepository(Protocol): + """Protocol for a repository that manages Reservation entities.""" + + def all(self) -> list[Reservation]: ... + + def get_club_reservations_for_competition(self, club_id: str, competition_id: str) -> list[Reservation]: ... + + def get_total_places_club_reservation_for_competition(self, club_id: str, competition_id: str) -> int: ... + + def save(self, reservation: Reservation) -> None: ... + + def reload(self) -> None: ... diff --git a/requirements.txt b/requirements.txt index 139affa05..4a729280f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ click==7.1.2 +coverage==7.9.1 Flask==1.1.2 itsdangerous==1.1.0 Jinja2==2.11.2 MarkupSafe==1.1.1 Werkzeug==1.0.1 +pytest==8.3.5 +python-dotenv==1.1.0 \ No newline at end of file diff --git a/reservations.json b/reservations.json new file mode 100644 index 000000000..8b4ac9ca3 --- /dev/null +++ b/reservations.json @@ -0,0 +1,11 @@ +{ + "reservations": [ + { + "id": "2a47a8db-b4b7-40de-ae02-8763041a3c7c", + "club_id": "25e97c3f-844b-4200-a3cb-7f0a09c0f623", + "competition_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "reserved_places": "8", + "date": "2025-06-26 10:58:36" + } + ] +} \ No newline at end of file diff --git a/server.py b/server.py index 4084baeac..fefa343f7 100644 --- a/server.py +++ b/server.py @@ -1,59 +1,130 @@ -import json -from flask import Flask,render_template,request,redirect,flash,url_for +import os +from datetime import datetime +from dotenv import load_dotenv +from flask import Flask, flash, redirect, render_template, request, session, url_for -def loadClubs(): - with open('clubs.json') as c: - listOfClubs = json.load(c)['clubs'] - return listOfClubs +from repositories.club_json_repository import ClubJsonRepository +from repositories.competition_json_repository import CompetitionJsonRepository +from repositories.reservation_json_repository import ReservationJsonRepository +from usecases.reservation_place import ReservePlaceUseCase +load_dotenv() # Load environment variables from .env file -def loadCompetitions(): - with open('competitions.json') as comps: - listOfCompetitions = json.load(comps)['competitions'] - return listOfCompetitions +app = Flask(__name__) +app.secret_key = os.getenv("SECRET_KEY") +reservation_repository = ReservationJsonRepository("reservations.json") +club_repository = ClubJsonRepository("clubs.json") +competition_repository = CompetitionJsonRepository("competitions.json") -app = Flask(__name__) -app.secret_key = 'something_special' +reserve_place_use_case = ReservePlaceUseCase( + club_repository=club_repository, + competition_repository=competition_repository, + reservation_repository=reservation_repository, +) -competitions = loadCompetitions() -clubs = loadClubs() -@app.route('/') +@app.route("/") def index(): - return render_template('index.html') + return render_template("index.html") + + +@app.route("/showSummary", methods=["GET", "POST"]) +def show_summary(): + club_repository.reload() + competition_repository.reload() + date_now = datetime.now() + + if request.method == "POST": + email = request.form["email"] + if email == "": + flash("Please enter an email", "error") + return render_template("index.html") + + club = club_repository.find_by_email(email) + if not club: + flash("Email not found", "error") + return render_template("index.html") + + session["club_name"] = club.name + + return render_template("welcome.html", club=club, competitions=competition_repository.all(), date_now=date_now) + + if request.method == "GET": + club_name = session.get("club_name") + if not club_name: + flash("You must be logged in to view this page.", "error") + return redirect(url_for("index")) + + club = club_repository.find_by_name(club_name) + if not club: + flash("club not found.", "error") + return redirect(url_for("index")) + return render_template("welcome.html", club=club, competitions=competition_repository.all(), date_now=date_now) + + +@app.route("/book//") +def book(competition, club): + club_repository.reload() + competition_repository.reload() + found_club = club_repository.find_by_name(club) + found_competition = competition_repository.find_by_name(competition) + date_now = datetime.now() + if found_club and found_competition: + return render_template("booking.html", club=found_club, competition=found_competition) + else: + flash("Something went wrong-please try again") + return render_template("welcome.html", club=club, competitions=competition_repository.all(), date_now=date_now) -@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("/purchasePlaces", methods=["POST"]) +def purchase_places(): + club_repository.reload() + competition_repository.reload() + reservation_repository.reload() -@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) + club_name = request.form["club"] + competition_name = request.form["competition"] + places = request.form["places"].strip() + date_now = datetime.now() + if not places.isdigit(): + flash("Please enter a valid number of places", "error") + club = club_repository.find_by_name(club_name) + competition = competition_repository.find_by_name(competition_name) + return render_template("booking.html", club=club, competition=competition) -@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) + places_required = int(places) + try: + reserve_place_use_case.execute( + club_name=club_name, competition_name=competition_name, places=places_required, date=date_now + ) + flash(f"{places_required} place(s) successfully reserved for {competition_name}!", "success") -# TODO: Add route for points display + except ValueError as e: + flash(str(e), "error") + club = club_repository.find_by_name(club_name) + competition = competition_repository.find_by_name(competition_name) + return render_template("booking.html", club=club, competition=competition) + club = club_repository.find_by_name(club_name) + return render_template("welcome.html", club=club, competitions=competition_repository.all(), date_now=date_now) -@app.route('/logout') + +@app.route("/points-dashboard") +def points_dashboard(): + club_repository.reload() + return render_template("points_dashboard.html", clubs=club_repository.all()) + + +@app.route("/logout") def logout(): - return redirect(url_for('index')) \ No newline at end of file + session.pop("club_name", None) + flash("You have been logged out.", "info") + return redirect(url_for("index")) + + +if __name__ == "__main__": + app.run(debug=os.getenv("FLASK_DEBUG") == "1") diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 000000000..b21432480 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,43 @@ + + + + + + GÜDLFT | {% block title %}Home{% endblock %} + + + + +
+
+

GÜDLFT

+ +
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..fe2347568 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -1,17 +1,19 @@ - - - - - Booking for {{competition['name']}} || GUDLFT - - -

{{competition['name']}}

- Places available: {{competition['numberOfPlaces']}} -
- - - - -
- - \ No newline at end of file +{% extends "base.html" %} +{% block title %}Booking for {{ competition.name }}{% endblock %} +{% block content %} +
+
+

{{ competition.name }}

+

Places available: {{ competition.available_places }}

+
+ + +
+ + +
+ +
+
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html index 926526b7d..c988f9023 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,16 +1,20 @@ - - - - - GUDLFT Registration - - -

Welcome to the GUDLFT Registration Portal!

- Please enter your secretary email to continue: -
- - - -
- - \ No newline at end of file +{% extends "base.html" %} +{% block title %}Login{% endblock %} +{% block content %} +
+
+
+
+

Welcome to the GÜDLFT Login Portal!

+
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/templates/points_dashboard.html b/templates/points_dashboard.html new file mode 100644 index 000000000..577bd49c6 --- /dev/null +++ b/templates/points_dashboard.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Points Dashboard{% endblock %} +{% block content %} +

Club Points Dashboard

+ {% if clubs %} +
+ + + + + + + + + {% for club in clubs %} + + + + + {% endfor %} + +
Club NamePoints
{{ club.name }}{{ club.points }}
+
+ {% else %} +

No clubs registered.

+ {% endif %} +{% endblock %} diff --git a/templates/welcome.html b/templates/welcome.html index ff6b261a2..6b16afbcf 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -1,36 +1,37 @@ - - - - - Summary | GUDLFT Registration - - -

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

Logout - - {% with messages = get_flashed_messages()%} - {% if messages %} -
    - {% for message in messages %} -
  • {{message}}
  • +{% extends "base.html" %} +{% block content %} +
    +

    Welcome, {{ club.email }}

    + Logout +
    +
    + Points available: {{ club.points }} +
    +

    Competitions:

    +
    + {% for comp in competitions %} +
    +
    +
    +
    + {{ comp.name }} + {% if comp.date >= date_now %} + Upcoming + {% else %} + Outdated + {% endif %} +
    +

    Date: {{ comp.date }}

    +

    Number of Places: {{ comp.available_places }}

    + {% if comp.available_places|int > 0 %} + Book Places + {% else %} + No places available + {% endif %} +
    +
    +
    {% 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 + +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..714fa5abc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from server import app as flask_app + + +@pytest.fixture +def app(): + flask_app.config.update({"TESTING": True}) + yield flask_app + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/tests/in_memory_repositories/__init__.py b/tests/in_memory_repositories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/in_memory_repositories/in_memory_club_repository.py b/tests/in_memory_repositories/in_memory_club_repository.py new file mode 100644 index 000000000..c168f5acb --- /dev/null +++ b/tests/in_memory_repositories/in_memory_club_repository.py @@ -0,0 +1,28 @@ +from entities.club import Club +from repository_protocols.club_repository import ClubRepository + + +class InMemoryClubRepository(ClubRepository): + def __init__(self, clubs: list[Club]): + self._clubs = clubs + + def all(self) -> list[Club]: + return self._clubs + + def find_by_name(self, name: str) -> Club | None: + return next((club for club in self._clubs if club.name == name), None) + + def find_by_email(self, email: str) -> Club | None: + return next((club for club in self._clubs if club.email == email), None) + + def update(self, club: Club) -> None: + for i, existing_club in enumerate(self._clubs): + if existing_club.name == club.name: + self._clubs[i] = club + break + else: + self._clubs.append(club) + + def reload(self) -> None: + """Reload the repository (no-op for in-memory repository).""" + pass diff --git a/tests/in_memory_repositories/in_memory_competition_repository.py b/tests/in_memory_repositories/in_memory_competition_repository.py new file mode 100644 index 000000000..1da7f9dbf --- /dev/null +++ b/tests/in_memory_repositories/in_memory_competition_repository.py @@ -0,0 +1,25 @@ +from entities.competition import Competition +from repository_protocols.competition_repository import CompetitionRepository + + +class InMemoryCompetitionRepository(CompetitionRepository): + def __init__(self, competitions: list[Competition]): + self._competitions = competitions + + def all(self) -> list[Competition]: + return self._competitions + + def find_by_name(self, name: str) -> Competition | None: + return next((comp for comp in self._competitions if comp.name == name), None) + + def update(self, competition: Competition) -> None: + for i, existing_competition in enumerate(self._competitions): + if existing_competition.name == competition.name: + self._competitions[i] = competition + break + else: + self._competitions.append(competition) + + def reload(self) -> None: + """Reload the repository (no-op for in-memory repository).""" + pass diff --git a/tests/in_memory_repositories/in_memory_reservation_repository.py b/tests/in_memory_repositories/in_memory_reservation_repository.py new file mode 100644 index 000000000..63591b060 --- /dev/null +++ b/tests/in_memory_repositories/in_memory_reservation_repository.py @@ -0,0 +1,28 @@ +from entities.reservation import Reservation +from repository_protocols.reservation_repository import ReservationRepository + + +class InMemoryReservationRepository(ReservationRepository): + def __init__(self): + self._reservations: list[Reservation] = [] + + def all(self) -> list[Reservation]: + return self._reservations + + def get_club_reservations_for_competition(self, club_id: str, competition_id: str) -> list[Reservation]: + return [ + reservation + for reservation in self._reservations + if reservation.club_id == club_id and reservation.competition_id == competition_id + ] + + def get_total_places_club_reservation_for_competition(self, club_id: str, competition_id: str) -> int: + reservations = self.get_club_reservations_for_competition(club_id, competition_id) + return sum(reservation.reserved_places for reservation in reservations) + + def save(self, reservation: Reservation) -> None: + self._reservations.append(reservation) + + def reload(self) -> None: + # In-memory repository does not need to reload data + pass diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integrations/test_book.py b/tests/integrations/test_book.py new file mode 100644 index 000000000..5bf4bc0db --- /dev/null +++ b/tests/integrations/test_book.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from entities.club import Club +from entities.competition import Competition +from tests.in_memory_repositories.in_memory_club_repository import InMemoryClubRepository +from tests.in_memory_repositories.in_memory_competition_repository import InMemoryCompetitionRepository + + +def test_book_with_valid_club_and_competition(client, monkeypatch): + club = Club(name="Test Club", email="test@club.com", points=15) + competition = Competition(name="Compétition", date=datetime(2025, 10, 1, 10, 0, 0), available_places=10) + club_repository = InMemoryClubRepository([club]) + competition_repository = InMemoryCompetitionRepository([competition]) + + monkeypatch.setattr("server.club_repository", club_repository) + monkeypatch.setattr("server.competition_repository", competition_repository) + + with client.session_transaction() as session: + session["club_name"] = club.name + + response = client.get(f"/book/{competition.name}/{club.name}") + assert response.status_code == 200 + assert b"Book" in response.data and b"How many places" in response.data + + +def test_book_with_invalid_club(client, monkeypatch): + competition = Competition(name="Compétition", date=datetime(2025, 10, 1, 10, 0, 0), available_places=10) + club_repository = InMemoryClubRepository([]) + competition_repository = InMemoryCompetitionRepository([competition]) + + monkeypatch.setattr("server.club_repository", club_repository) + monkeypatch.setattr("server.competition_repository", competition_repository) + + response = client.get(f"/book/{competition.name}/unknown_club", follow_redirects=True) + assert response.status_code == 200 + assert b"Something went wrong-please try again" in response.data diff --git a/tests/integrations/test_index.py b/tests/integrations/test_index.py new file mode 100644 index 000000000..d98c410af --- /dev/null +++ b/tests/integrations/test_index.py @@ -0,0 +1,4 @@ +def test_index(client): + response = client.get("/") + assert response.status_code == 200 + assert b"Login" in response.data or b"Points Dashboard" in response.data diff --git a/tests/integrations/test_logout.py b/tests/integrations/test_logout.py new file mode 100644 index 000000000..16b620b1b --- /dev/null +++ b/tests/integrations/test_logout.py @@ -0,0 +1,11 @@ +def test_logout_clears_session_and_redirect(client): + with client.session_transaction() as session: + session["club_name"] = "Test Club" + + response = client.get("/logout", follow_redirects=True) + + with client.session_transaction() as session: + assert "club_name" not in session + + assert response.status_code == 200 + assert b"You have been logged out." in response.data diff --git a/tests/integrations/test_points_dashboard.py b/tests/integrations/test_points_dashboard.py new file mode 100644 index 000000000..9e71678cc --- /dev/null +++ b/tests/integrations/test_points_dashboard.py @@ -0,0 +1,27 @@ +from entities.club import Club +from tests.in_memory_repositories.in_memory_club_repository import InMemoryClubRepository + + +def test_points_dashboard_with_clubs(client, monkeypatch): + test_club = Club(name="Test Club", email="test@club.com", points=13) + nice_club = Club(name="Nice Club", email="nice@club.com", points=8) + club_repository = InMemoryClubRepository([test_club, nice_club]) + monkeypatch.setattr("server.club_repository", club_repository) + + response = client.get("/points-dashboard") + assert response.status_code == 200 + assert b"Points Dashboard" in response.data + assert b"Test Club" in response.data + assert b"13" in response.data + assert b"Nice Club" in response.data + assert b"8" in response.data + + +def test_points_dashboard_without_clubs(client, monkeypatch): + club_repository = InMemoryClubRepository([]) + monkeypatch.setattr("server.club_repository", club_repository) + + response = client.get("/points-dashboard") + assert response.status_code == 200 + assert b"Points Dashboard" in response.data + assert b"No clubs registered." in response.data diff --git a/tests/integrations/test_purchase_places.py b/tests/integrations/test_purchase_places.py new file mode 100644 index 000000000..8d2a49643 --- /dev/null +++ b/tests/integrations/test_purchase_places.py @@ -0,0 +1,187 @@ +from datetime import datetime +from typing import Tuple + +import pytest + +from entities.club import Club +from entities.competition import Competition +from tests.in_memory_repositories.in_memory_club_repository import InMemoryClubRepository +from tests.in_memory_repositories.in_memory_competition_repository import InMemoryCompetitionRepository +from tests.in_memory_repositories.in_memory_reservation_repository import InMemoryReservationRepository +from usecases.reservation_place import ReservePlaceUseCase + + +@pytest.fixture +def default_club(): + return Club(name="Test Club", email="test@club.com", points=13) + + +@pytest.fixture +def future_competition(): + return Competition(name="Future Competition", date=datetime(2025, 10, 1, 10, 0, 0), available_places=10) + + +@pytest.fixture +def past_competition(): + return Competition(name="Test Competition", date=datetime(2024, 10, 1, 10, 0, 0), available_places=15) + + +def setup_use_case( + club: Club, competition: Competition, monkeypatch: pytest.MonkeyPatch +) -> Tuple[InMemoryClubRepository, InMemoryCompetitionRepository, InMemoryReservationRepository]: + club_repository = InMemoryClubRepository([club]) + competition_repository = InMemoryCompetitionRepository([competition]) + reservation_repository = InMemoryReservationRepository() + reservation_use_case = ReservePlaceUseCase(club_repository, competition_repository, reservation_repository) + + monkeypatch.setattr("server.club_repository", club_repository) + monkeypatch.setattr("server.competition_repository", competition_repository) + monkeypatch.setattr("server.reservation_repository", reservation_repository) + monkeypatch.setattr("server.reserve_place_use_case", reservation_use_case) + + return club_repository, competition_repository, reservation_repository + + +def test_purchase_places_success(client, monkeypatch, default_club, future_competition): + """Test successful reservation of places for a competition.""" + setup_use_case(default_club, future_competition, monkeypatch) + reserved_places = "5" + + with client.session_transaction() as session: + session["club_name"] = default_club.name + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": future_competition.name, "places": reserved_places}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert f"{reserved_places} place(s) successfully reserved for {future_competition.name}!" in response.data.decode() + + +def test_purchase_places_fails_with_invalid_input(client, monkeypatch, default_club, future_competition): + setup_use_case(default_club, future_competition, monkeypatch) + invalid_input = "abcd" # Non-numeric input + + with client.session_transaction() as session: + session["club_name"] = default_club.name + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": future_competition.name, "places": invalid_input}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Please enter a valid number of places" in response.data + + +def test_purchase_places_fails_with_club_not_found(client, monkeypatch, default_club, future_competition): + setup_use_case(default_club, future_competition, monkeypatch) + + response = client.post( + "/purchasePlaces", + data={"club": "unknown_club", "competition": future_competition.name, "places": "5"}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Club not found." in response.data + + +def test_purchase_places_fails_with_competition_not_found(client, monkeypatch, default_club, future_competition): + setup_use_case(default_club, future_competition, monkeypatch) + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": "unknown_competition", "places": "5"}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Competition not found." in response.data + + +def test_purchase_places_fails_with_zero_places(client, monkeypatch, default_club, future_competition): + setup_use_case(default_club, future_competition, monkeypatch) + reserved_places = "0" + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": future_competition.name, "places": reserved_places}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Number of places must be greater than zero." in response.data + + +def test_purchase_places_fails_with_club_reserved_greater_than_available_places( + client, monkeypatch, default_club, future_competition +): + setup_use_case(default_club, future_competition, monkeypatch) + reserved_places = "11" + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": future_competition.name, "places": reserved_places}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Not enough available places in the competition." in response.data + + +def test_purchase_places_fails_with_club_not_enough_points(client, monkeypatch, default_club, future_competition): + setup_use_case(default_club, future_competition, monkeypatch) + reserved_places = "14" + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": future_competition.name, "places": reserved_places}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Club does not have enough points to reserve places." in response.data + + +def test_purchase_places_fails_with_past_competition(client, monkeypatch, default_club, past_competition): + setup_use_case(default_club, past_competition, monkeypatch) + reserved_places = "5" + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": past_competition.name, "places": reserved_places}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Cannot reserve places for a past competition." in response.data + + +def test_purchase_places_fails_with_exceding_max_per_club(client, monkeypatch, default_club, future_competition): + _, _, reservation_repository = setup_use_case(default_club, future_competition, monkeypatch) + reserved_places = "3" + + reservation_repository.save( + default_club.reserve(future_competition, 10, datetime.now()) + ) # Simulate existing reservation of 10 places + + total_already_reserved = reservation_repository.get_total_places_club_reservation_for_competition( + default_club.id, future_competition.id + ) + + remaining_quota = future_competition.max_places_per_reservation - total_already_reserved + + response = client.post( + "/purchasePlaces", + data={"club": default_club.name, "competition": future_competition.name, "places": reserved_places}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert ( + f"You have already reserved {total_already_reserved} place(s). You can only reserve {remaining_quota} more." + ) in response.data.decode() diff --git a/tests/integrations/test_show_summary.py b/tests/integrations/test_show_summary.py new file mode 100644 index 000000000..bbab8a572 --- /dev/null +++ b/tests/integrations/test_show_summary.py @@ -0,0 +1,63 @@ +import datetime + +from entities.club import Club +from entities.competition import Competition +from tests.in_memory_repositories.in_memory_club_repository import InMemoryClubRepository +from tests.in_memory_repositories.in_memory_competition_repository import InMemoryCompetitionRepository + + +def test_login_without_email(client): + response = client.post("/showSummary", data={"email": ""}) + assert response.status_code == 200 + assert b"Please enter an email" in response.data + + +def test_login_with_non_existent_email(client): + response = client.post("/showSummary", data={"email": "nonexistent@test.com"}) + assert response.status_code == 200 + assert b"Email not found" in response.data + + +def test_login_with_valid_email(client, monkeypatch): + club = Club(name="Test Club", email="test@club.com", points=15) + club_repository = InMemoryClubRepository([club]) + monkeypatch.setattr("server.club_repository", club_repository) + + response = client.post("/showSummary", data={"email": club.email}) + assert response.status_code == 200 + assert b"test@club.com" in response.data + + +def test_show_summary_get_without_login(client): + response = client.get("/showSummary") + assert response.status_code == 302 + assert response.headers["Location"].endswith("/") + + +def test_show_summary_get_with_login(client, monkeypatch): + club = Club(name="Test Club", email="test@club.com", points=15) + competition = Competition(name="Compétition", date=datetime.datetime(2025, 10, 1, 10, 0, 0), available_places=10) + club_repository = InMemoryClubRepository([club]) + competition_repository = InMemoryCompetitionRepository([competition]) + + monkeypatch.setattr("server.club_repository", club_repository) + monkeypatch.setattr("server.competition_repository", competition_repository) + + with client.session_transaction() as session: + session["club_name"] = club.name + + response = client.get("/showSummary") + assert response.status_code == 200 + assert b"test@club.com" in response.data + + +def test_show_summary_get_with_unknown_club(client, monkeypatch): + club_repository = InMemoryClubRepository([]) + monkeypatch.setattr("server.club_repository", club_repository) + + with client.session_transaction() as session: + session["club_name"] = "Unknown Club" + + response = client.get("/showSummary", follow_redirects=True) + assert response.status_code == 200 + assert b"club not found" in response.data diff --git a/tests/units/__init__.py b/tests/units/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/units/entities/__init__.py b/tests/units/entities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/units/entities/test_club.py b/tests/units/entities/test_club.py new file mode 100644 index 000000000..8b984ce1c --- /dev/null +++ b/tests/units/entities/test_club.py @@ -0,0 +1,20 @@ +import pytest + +from entities.club import Club + + +@pytest.fixture +def club(): + return Club(name="Test Club", email="test@example.com", points=10) + + +class TestClub: + def test_has_enough_points(self, club): + assert club.has_enough_points(5) is True + + def test_has_not_enough_points(self, club): + assert club.has_enough_points(11) is False + + def test_consume_points(self, club): + club.consume_points(5) + assert club._points == 5 diff --git a/tests/units/entities/test_competition.py b/tests/units/entities/test_competition.py new file mode 100644 index 000000000..4b05da062 --- /dev/null +++ b/tests/units/entities/test_competition.py @@ -0,0 +1,22 @@ +import pytest + +from entities.competition import Competition + + +@pytest.fixture +def competition(): + return Competition(name="Test Competition", date="2023-10-01", available_places=5) + + +class TestCompetition: + def test_can_reserve(self, competition): + assert competition.can_reserve(3) is True + + def test_cannot_reserve(self, competition): + assert competition.can_reserve(6) is False + + def test_is_within_reservation_limit(self, competition): + assert competition.is_within_reservation_limit(3, 5) is True + + def test_exceeds_reservation_limit(self, competition): + assert competition.is_within_reservation_limit(5, 8) is False diff --git a/tests/units/json_services/__init__.py b/tests/units/json_services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/units/json_services/test_json_services.py b/tests/units/json_services/test_json_services.py new file mode 100644 index 000000000..09198beab --- /dev/null +++ b/tests/units/json_services/test_json_services.py @@ -0,0 +1,32 @@ +import json + +import pytest + +from json_services import JSONServices + + +def test_load_valid_json_file(mocker): + mock_data = {"clubs": [{"email": "clubtest@test.com", "name": "Club Test"}]} + + mocker.patch("builtins.open", mocker.mock_open(read_data=json.dumps(mock_data))) + result = JSONServices.load("clubs.json") + assert result == mock_data + + +def test_load_non_existent_json_file(mocker): + mocker.patch("builtins.open", side_effect=FileNotFoundError) + with pytest.raises(FileNotFoundError) as exc_info: + JSONServices.load("non_existent_file.json") + assert str(exc_info.value) == "File non_existent_file.json not found." + + +def test_save_json_file(mocker): + mock_data = {"clubs": [{"email": "clubtest@test.com", "name": "Club Test", "points": "10"}]} + + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mock_json_dump = mocker.patch("json.dump") + + JSONServices.save("clubs.json", mock_data) + + mock_open.assert_called_once_with("clubs.json", "w") + mock_json_dump.assert_called_once_with(mock_data, mock_open(), indent=4) diff --git a/tests/units/usecases/__init__.py b/tests/units/usecases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/units/usecases/test_reserve_place_use_case.py b/tests/units/usecases/test_reserve_place_use_case.py new file mode 100644 index 000000000..5d7b61d0d --- /dev/null +++ b/tests/units/usecases/test_reserve_place_use_case.py @@ -0,0 +1,178 @@ +import datetime + +import pytest + +from entities.club import Club +from entities.competition import Competition +from entities.reservation import Reservation +from tests.in_memory_repositories.in_memory_club_repository import InMemoryClubRepository +from tests.in_memory_repositories.in_memory_competition_repository import InMemoryCompetitionRepository +from tests.in_memory_repositories.in_memory_reservation_repository import InMemoryReservationRepository +from usecases.reservation_place import ReservePlaceUseCase + + +@pytest.fixture +def club(): + return Club(name="Test Club", email="test@club.com", points=20) + + +@pytest.fixture +def future_competition(): + future_date = datetime.datetime.now() + datetime.timedelta(days=15) + return Competition(name="Test Competition", date=future_date, available_places=20) + + +@pytest.fixture +def date_now(): + return datetime.datetime.now() + + +def test_successful_reserve_place(club, future_competition, date_now): + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([future_competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + fetched_club = club_repo.find_by_name("Test Club") + fetched_competition = competition_repo.find_by_name("Test Competition") + + use_case.execute(club_name=fetched_club.name, competition_name=fetched_competition.name, places=5, date=date_now) + + assert fetched_club.points == 15 + assert fetched_competition.available_places == 15 + assert len(reservation_repo.all()) == 1 + reservation = reservation_repo.all()[0] + assert reservation.club_id == fetched_club.id + assert reservation.competition_id == fetched_competition.id + assert reservation.reserved_places == 5 + assert reservation.date == date_now + assert reservation.date == date_now + + +def test_reserve_place_with_past_competition_raises_error(club, date_now): + past_date = datetime.datetime.now() - datetime.timedelta(days=15) + past_competition = Competition(name="Past Competition", date=past_date, available_places=20) + + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([past_competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Cannot reserve places for a past competition."): + use_case.execute(club_name=club.name, competition_name=past_competition.name, places=5, date=date_now) + + +def test_reserve_place_with_club_not_found_raises_error(future_competition, date_now): + club_repo = InMemoryClubRepository([]) + competition_repo = InMemoryCompetitionRepository([future_competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Club not found."): + use_case.execute(club_name="Unknown Club", competition_name=future_competition.name, places=5, date=date_now) + + +def test_reserve_place_with_competition_not_found_raises_error(club, date_now): + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Competition not found."): + use_case.execute(club_name=club.name, competition_name="Unknown Competition", places=5, date=date_now) + + +def test_reserve_place_with_zero_places_raises_error(club, future_competition, date_now): + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([future_competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Number of places must be greater than zero."): + use_case.execute(club_name=club.name, competition_name=future_competition.name, places=0, date=date_now) + + +def test_reserve_place_with_negative_places_raises_error(club, future_competition, date_now): + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([future_competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Number of places must be greater than zero."): + use_case.execute(club_name=club.name, competition_name=future_competition.name, places=-5, date=date_now) + + +def test_reserve_place_with_insufficient_points_raises_error(club, future_competition, date_now): + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([future_competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Club does not have enough points to reserve places."): + use_case.execute(club_name=club.name, competition_name=future_competition.name, places=25, date=date_now) + + +def test_reserve_place_with_exceeding_max_places_per_reservation_raises_error(club, future_competition, date_now): + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([future_competition]) + reservation_repo = InMemoryReservationRepository() + + reserved_places = 8 + remaining_quota = future_competition.MAX_PLACES_PER_RESERVATION - reserved_places + + existing_reservations = Reservation( + club_id=club.id, competition_id=future_competition.id, reserved_places=reserved_places, date=date_now + ) + + reservation_repo.save(existing_reservations) + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises( + ValueError, + match=f"You have already reserved {reserved_places} place\\(s\\). You can only reserve {remaining_quota} more.", + ): + use_case.execute( + club_name=club.name, + competition_name=future_competition.name, + places=5, + date=date_now, + ) + + +def test_reserve_place_with_not_enough_available_places_raises_error(club, date_now): + future_date = (datetime.datetime.now() + datetime.timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S") + competition = Competition(name="Test Competition", date=future_date, available_places=10) + club_repo = InMemoryClubRepository([club]) + competition_repo = InMemoryCompetitionRepository([competition]) + reservation_repo = InMemoryReservationRepository() + + use_case = ReservePlaceUseCase( + club_repository=club_repo, competition_repository=competition_repo, reservation_repository=reservation_repo + ) + + with pytest.raises(ValueError, match="Not enough available places in the competition."): + use_case.execute(club_name=club.name, competition_name=competition.name, places=11, date=date_now) diff --git a/usecases/__init__.py b/usecases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/usecases/reservation_place.py b/usecases/reservation_place.py new file mode 100644 index 000000000..859a240c0 --- /dev/null +++ b/usecases/reservation_place.py @@ -0,0 +1,59 @@ +from datetime import datetime + +from repository_protocols.club_repository import ClubRepository +from repository_protocols.competition_repository import CompetitionRepository +from repository_protocols.reservation_repository import ReservationRepository + + +class ReservePlaceUseCase: + """Use case for reserving places in a competition for a club.""" + + def __init__( + self, + club_repository: ClubRepository, + competition_repository: CompetitionRepository, + reservation_repository: ReservationRepository, + ): + self._club_repository = club_repository + self._competition_repository = competition_repository + self._reservation_repository = reservation_repository + + def execute(self, club_name: str, competition_name: str, places: int, date: datetime) -> None: + """Reserves places in a competition for a club.""" + club = self._club_repository.find_by_name(club_name) + + if not club: + raise ValueError("Club not found.") + + competition = self._competition_repository.find_by_name(competition_name) + + if not competition: + raise ValueError("Competition not found.") + + if places <= 0: + raise ValueError("Number of places must be greater than zero.") + + if not club.has_enough_points(places): + raise ValueError("Club does not have enough points to reserve places.") + + total_already_reserved = self._reservation_repository.get_total_places_club_reservation_for_competition( + club.id, competition.id + ) + if not competition.is_within_reservation_limit(places, total_already_reserved): + remaining_quota = competition.max_places_per_reservation - total_already_reserved + raise ValueError( + f"You have already reserved {total_already_reserved} place(s). " + f"You can only reserve {remaining_quota} more." + ) + + if not competition.can_reserve(places): + raise ValueError("Not enough available places in the competition.") + + if competition.date < date: + raise ValueError("Cannot reserve places for a past competition.") + + reservation = club.reserve(competition, places, date) + + self._reservation_repository.save(reservation) + self._club_repository.update(club) + self._competition_repository.update(competition)