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/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/requirements.txt b/requirements.txt index 139affa05..48948c695 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ 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/server.py b/server.py index 4084baeac..637c3aaf2 100644 --- a/server.py +++ b/server.py @@ -1,59 +1,91 @@ -import json -from flask import Flask,render_template,request,redirect,flash,url_for +import os +from dotenv import load_dotenv +from flask import Flask, flash, redirect, render_template, request, 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 +from globals import LIMITED_BOOKING_PLACE +from json_services import JSONServices +load_dotenv() # Load environment variables from .env file app = Flask(__name__) -app.secret_key = 'something_special' +app.secret_key = os.getenv("SECRET_KEY") -competitions = loadCompetitions() -clubs = loadClubs() -@app.route('/') +@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) + return render_template("index.html") -@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] +@app.route("/showSummary", methods=["POST"]) +def showSummary(): + email = request.form["email"] + if email == "": + flash("Please enter an email", "error") + return render_template("index.html") + + try: + clubs = JSONServices.load("clubs.json")["clubs"] + competitions = JSONServices.load("competitions.json")["competitions"] + club = [club for club in clubs if club["email"] == email][0] + except FileNotFoundError as ex: + print(ex) + flash("Error loading data. Please contact support.", "error") + return render_template("index.html") + except IndexError: + flash("Email not found", "error") + return render_template("index.html") + + return render_template("welcome.html", club=club, competitions=competitions) + + +@app.route("/book//") +def book(competition, club): + clubs = JSONServices.load("clubs.json")["clubs"] + competitions = JSONServices.load("competitions.json")["competitions"] + 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) + 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) + return render_template("welcome.html", club=club, competitions=competitions) -@app.route('/purchasePlaces',methods=['POST']) +@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) + clubs = JSONServices.load("clubs.json")["clubs"] + competitions = JSONServices.load("competitions.json")["competitions"] + 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"]) + + club_points = int(club["points"]) + + if placesRequired > club_points: + flash("Not enough points", "error") + return render_template("booking.html", club=club, competition=competition) + + if placesRequired > LIMITED_BOOKING_PLACE: + flash(f"Maximum booking limit is {LIMITED_BOOKING_PLACE} places", "error") + return render_template("booking.html", club=club, competition=competition) + + number_of_places = int(competition["numberOfPlaces"]) - placesRequired + + competition["numberOfPlaces"] = str(number_of_places) + JSONServices.save("competitions.json", {"competitions": competitions}) + + flash("Great-booking complete!") + return render_template("welcome.html", club=club, competitions=competitions) # TODO: Add route for points display -@app.route('/logout') +@app.route("/logout") def logout(): - return redirect(url_for('index')) \ No newline at end of file + return redirect(url_for("index")) + + +if __name__ == "__main__": + app.run(debug=os.getenv("FLASK_DEBUG") == "1") diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..569ac22c4 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -6,12 +6,22 @@

{{competition['name']}}

- Places available: {{competition['numberOfPlaces']}} -
- - - - -
+ {% with messages = get_flashed_messages()%} + {% if messages %} +
    + {% for message in messages %} +
  • {{message}}
  • + {% endfor %} +
+ {% endif%} + Places available: {{competition['numberOfPlaces']}} +
+ + + + + +
+ {%endwith%} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 926526b7d..37a2460f8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,6 +5,15 @@ GUDLFT Registration + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %}

Welcome to the GUDLFT Registration Portal!

Please enter your secretary email to continue:
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/json_services/__init__.py b/tests/json_services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/json_services/test_json_services.py b/tests/json_services/test_json_services.py new file mode 100644 index 000000000..09198beab --- /dev/null +++ b/tests/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/test_booking_place.py b/tests/test_booking_place.py new file mode 100644 index 000000000..cf99ca99e --- /dev/null +++ b/tests/test_booking_place.py @@ -0,0 +1,63 @@ +from globals import LIMITED_BOOKING_PLACE + + +def get_mock_clubs(points: str): + return {"clubs": [{"email": "clubtest@test.com", "name": "Club Test", "points": points}]} + + +def get_mock_competitions(places: str): + return {"competitions": [{"name": "Competition Test", "date": "2020-03-27 10:00:00", "numberOfPlaces": places}]} + + +def test_club_cannot_book_more_than_points(client, mocker): + mock_clubs = get_mock_clubs("5") + mock_competitions = get_mock_competitions("10") + + mocker.patch("server.JSONServices.load", side_effect=[mock_clubs, mock_competitions]) + + response = client.post( + "/purchasePlaces", data={"competition": "Competition Test", "club": "Club Test", "places": "6"} + ) + + assert response.status_code == 200 + assert b"Not enough points" in response.data + + +def test_club_cannot_book_more_than_limit(client, mocker): + places_requested = str(LIMITED_BOOKING_PLACE + 1) + club_points = str(LIMITED_BOOKING_PLACE + 5) + number_of_competition_places = str(LIMITED_BOOKING_PLACE + 5) + mock_clubs = get_mock_clubs(club_points) + mock_competitions = get_mock_competitions(number_of_competition_places) + + mocker.patch("server.JSONServices.load", side_effect=[mock_clubs, mock_competitions]) + + response = client.post( + "/purchasePlaces", data={"competition": "Competition Test", "club": "Club Test", "places": places_requested} + ) + + assert response.status_code == 200 + assert f"Maximum booking limit is {LIMITED_BOOKING_PLACE} places".encode() in response.data + + +def test_competition_places_are_deducted_when_club_books(client, mocker): + mock_clubs = get_mock_clubs(points="15") + mock_competitions = get_mock_competitions(places="20") + places_requested = "5" + + mocker.patch("server.JSONServices.load", side_effect=[mock_clubs, mock_competitions]) + mocker.patch("json_services.open", mocker.mock_open()) + + mock_json_dump = mocker.patch("json.dump") + + response = client.post( + "/purchasePlaces", data={"competition": "Competition Test", "club": "Club Test", "places": places_requested} + ) + + assert response.status_code == 200 + + called_data = mock_json_dump.call_args[0][0] + competitions = called_data["competitions"] + + updated = next(c for c in competitions if c["name"] == "Competition Test") + assert updated["numberOfPlaces"] == "15" diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 000000000..74764ca8c --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,10 @@ +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