From 0da8dc16fb26e9df5d0efd7c7a463e354d8e00cf Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Sun, 6 Jul 2025 12:58:07 +0200 Subject: Transitioned back to a traditional flask app --- app/model.py | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 app/model.py (limited to 'app/model.py') diff --git a/app/model.py b/app/model.py new file mode 100644 index 0000000..467310b --- /dev/null +++ b/app/model.py @@ -0,0 +1,174 @@ +from math import floor +from typing import Tuple +from os.path import isfile +from pandas import read_csv +from dataclasses import dataclass +from importlib.resources import path +from random import getrandbits, randint +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine, not_ +from names import get_first_name, get_last_name +from match_up.data import ( + MIN_LEVEL, + MAX_LEVEL, + PLAYER_PER_COURT, + BASE, + GUEST_LEVELS, + GUESTS_PER_LEVEL, + Player, + DisplayPlayer, +) + + +@dataclass +class CourtList: + total: int = 12 + + def __post_init__(self) -> None: + self.courts = {i + 1: True for i in range(self.total)} + + def get_court_status(self, court_number: int) -> bool: + return self.courts[court_number] + + def toggle_court_status(self, court_number: int) -> bool: + new_status = not self.courts[court_number] + self.courts[court_number] = new_status + return new_status + + def get_courts(self) -> dict: + return self.courts + + def separate_courts(self) -> Tuple[dict, dict]: + active_courts = {k: v for k, v in self.courts.items() if v} + inactive_courts = {k: v for k, v in self.courts.items() if not v} + return active_courts, inactive_courts + + +def _init_guests(session) -> None: + for level_id, level in enumerate(GUEST_LEVELS): + for player_id in range(GUESTS_PER_LEVEL): + unique_id = (level_id * GUESTS_PER_LEVEL) + player_id + 1 + player = Player( + id=-unique_id, + first="Guest", + last=str(unique_id), + level=level, + status=False, + count=0, + ) + session.add(player) + session.commit() + + +def _init_database_from_csv(session, csv_location: str) -> None: + data = read_csv(csv_location) + session.query(Player).delete() + for _, entry in data.iterrows(): + player = Player( + id=entry["id"], + first=entry["first"], + last=entry["last"], + level=entry["skill"], + team=entry["team"], + status=False, + count=0, + ) + session.add(player) + session.commit() + _init_guests(session) + + +def _init_random_database(session, player_count: int = 80) -> None: + total_teams = 5 + players_per_team = 5 + for i in range(player_count): + if i < total_teams * players_per_team: + team = floor(i / players_per_team) + 1 + else: + team = 0 + player = Player( + id=i, + first=get_first_name(), + last=get_last_name(), + level=randint(MIN_LEVEL, MAX_LEVEL), + team=team, + status=bool(getrandbits(1)), + count=0, + ) + session.add(player) + session.commit() + _init_guests(session) + + +class PlayerList: + + def __init__(self, csv_location=None) -> None: + with path("match_up", "players.db") as p: + self.db_location = p + db_file_found = True + if not isfile(self.db_location): + db_file_found = False + self._init_database() + if csv_location is not None: + _init_database_from_csv(self.session, csv_location) + else: + if not db_file_found: + _init_random_database(self.session) + + def _init_database(self): + engine = create_engine("sqlite:///" + str(self.db_location)) + BASE.metadata.create_all(bind=engine) + self.session = sessionmaker(bind=engine)() + + def get_player_status(self, id: int) -> bool: + return self.session.query(Player).filter(Player.id == id).first().status + + def toggle_player_status(self, id: int) -> DisplayPlayer: + player = self.session.query(Player).filter(Player.id == id).first() + player.status = not player.status + self.session.commit() + return DisplayPlayer(player) + + def get_all_players(self) -> Tuple[list[DisplayPlayer], list[DisplayPlayer]]: + all_players = [ + DisplayPlayer(p) + for p in self.session.query(Player) + .order_by(Player.first, Player.last) + .all() + ] + regulars = [player for player in all_players if player.first != "Guest"] + guests = [player for player in all_players if player.first == "Guest"] + guests.sort(key=lambda x: int(x.last)) + return regulars, guests + + def get_possible_game_count(self) -> int: + total_players = len(self.get_players()) + possible_games = floor(total_players / PLAYER_PER_COURT) + return possible_games + + def get_players(self) -> list[Player]: + active_players = ( + self.session.query(Player) + .order_by(Player.first, Player.last) + .filter(not_(Player.status == 0)) + .all() + ) + return active_players + + def increment_game_count(self, player_ids: list[int]): + players = self.session.query(Player).filter(Player.id.in_(player_ids)) + for player in players: + player.count += 1 + self.session.commit() + + def reset_game_count(self): + players = self.session.query(Player) + for player in players: + player.count = 0 + self.session.commit() + + def reset_all_players(self): + players = self.session.query(Player) + for player in players: + player.status = 0 + self.session.commit() -- cgit v1.3.1 From c773faeea93fe5db2eb493c79c0c82f46336493c Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Sun, 6 Jul 2025 13:31:37 +0200 Subject: Running application --- app/__init__.py | 25 +----------- app/controller.py | 6 +-- app/model.py | 6 +-- app/routes.py | 63 ++++++++++++++---------------- app/templates/controls.j2 | 47 ++++++++++++++++++++++ app/templates/games.j2 | 21 ++++++++++ app/templates/index.j2 | 13 +++++++ app/templates/macros.j2 | 99 +++++++++++++++++++++++++++++++++++++++++++++++ app/templates/players.j2 | 31 +++++++++++++++ app/utilities.py | 4 +- matchup.py | 1 + templates/controls.j2 | 47 ---------------------- templates/games.j2 | 21 ---------- templates/index.j2 | 13 ------- templates/macros.j2 | 99 ----------------------------------------------- templates/players.j2 | 31 --------------- 16 files changed, 249 insertions(+), 278 deletions(-) create mode 100644 app/templates/controls.j2 create mode 100644 app/templates/games.j2 create mode 100644 app/templates/index.j2 create mode 100644 app/templates/macros.j2 create mode 100644 app/templates/players.j2 create mode 100644 matchup.py delete mode 100644 templates/controls.j2 delete mode 100644 templates/games.j2 delete mode 100644 templates/index.j2 delete mode 100644 templates/macros.j2 delete mode 100644 templates/players.j2 (limited to 'app/model.py') diff --git a/app/__init__.py b/app/__init__.py index bccd0e7..96c8ef5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,26 +1,5 @@ from flask import Flask -from waitress import serve -from argparse import ArgumentParser -from importlib.resources import path -from match_up.controller import RoundGenerator +app = Flask(__name__) -with path("match_up", "static") as p: - static_folder = p -with path("match_up", "templates") as p: - template_folder = p -_app = Flask( - "Match Up! Backend", static_folder=static_folder, template_folder=template_folder -) -_rg = RoundGenerator() - - -from match_up import routes - - -def main(): - parser = ArgumentParser() - parser.add_argument("-d", "--database", help="CSV database", default=None) - args = parser.parse_args() - _rg.update_player_list(csv_location=args.database) - serve(_app, port=80) +from app import routes diff --git a/app/controller.py b/app/controller.py index b00bf6b..498715d 100644 --- a/app/controller.py +++ b/app/controller.py @@ -1,8 +1,8 @@ from typing import Tuple from operator import attrgetter -from match_up.data import Game, PLAYER_PER_COURT -from match_up.model import Player, PlayerList, CourtList -from match_up.utilities import ( +from app.data import Game, PLAYER_PER_COURT +from app.model import Player, PlayerList, CourtList +from app.utilities import ( _create_placeholders, _select_players, _create_shuffle_games, diff --git a/app/model.py b/app/model.py index 467310b..b2b6470 100644 --- a/app/model.py +++ b/app/model.py @@ -3,12 +3,11 @@ from typing import Tuple from os.path import isfile from pandas import read_csv from dataclasses import dataclass -from importlib.resources import path from random import getrandbits, randint from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine, not_ from names import get_first_name, get_last_name -from match_up.data import ( +from app.data import ( MIN_LEVEL, MAX_LEVEL, PLAYER_PER_COURT, @@ -103,8 +102,7 @@ def _init_random_database(session, player_count: int = 80) -> None: class PlayerList: def __init__(self, csv_location=None) -> None: - with path("match_up", "players.db") as p: - self.db_location = p + self.db_location = "players.db" db_file_found = True if not isfile(self.db_location): db_file_found = False diff --git a/app/routes.py b/app/routes.py index e5a3b99..c18d93b 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,80 +1,73 @@ -from match_up import _app, _rg -from jinja2 import Environment, PackageLoader +from app import app +from app.controller import RoundGenerator from flask import render_template, request, url_for, Response -re = Environment(loader=PackageLoader("match_up")) +rg = RoundGenerator() - -@_app.route("/", methods=["GET"]) +@app.route("/", methods=["GET"]) def home(): - return re.get_template("index.j2").render(url_for=url_for) + return render_template("index.j2", url_for=url_for) -@_app.route("/control", methods=["GET"]) +@app.route("/control", methods=["GET"]) def get_controls(): - return render_template("controls.j2", courts=_rg.courts.get_courts()) + return render_template("controls.j2", courts=rg.courts.get_courts()) -@_app.route("/propose", methods=["POST"]) +@app.route("/propose", methods=["POST"]) def get_random_games(): levels = int(request.form["levels"].split()[0]) team_practice = request.form["team"] == "Teams" return render_template( - "games.j2", games=_rg.propose(levels, team_practice), title="Proposal" + "games.j2", games=rg.propose(levels, team_practice), title="Proposal" ) -@_app.route("/confirm", methods=["POST"]) +@app.route("/confirm", methods=["POST"]) def confirm_games(): - return render_template("games.j2", games=_rg.confirm(), title=f"Round: {_rg.round}") + return render_template("games.j2", games=rg.confirm(), title=f"Round: {rg.round}") -@_app.route("/clear", methods=["POST"]) +@app.route("/clear", methods=["POST"]) def clear_games(): - return render_template("games.j2", games=_rg.clear(), title=f"Round: {_rg.round}") + return render_template("games.j2", games=rg.clear(), title=f"Round: {rg.round}") -@_app.route("/reset", methods=["POST"]) +@app.route("/reset", methods=["POST"]) def reset_games(): - return render_template("games.j2", games=_rg.reset(), title=f"Round: {_rg.round}") + return render_template("games.j2", games=rg.reset(), title=f"Round: {rg.round}") -@_app.route("/player-toggle/", methods=["POST"]) +@app.route("/player-toggle/", methods=["POST"]) def toggle_player(player_request): id = int(player_request) - player = _rg.players.toggle_player_status(id) - t = re.from_string( - '{% from "macros.j2" import player_button %}{{player_button(player)}}' - ) + player = rg.players.toggle_player_status(id) + t = '{% from "macros.j2" import player_button %}{{player_button(player)}}' return render_template(t, player=player) -@_app.route("/players", methods=["GET"]) +@app.route("/players", methods=["GET"]) def get_list_of_players(): - regulars, guests = _rg.players.get_all_players() - return re.get_template("players.j2").render( - regulars=regulars, guests=guests, url_for=url_for - ) + regulars, guests = rg.players.get_all_players() + return render_template("players.j2", regulars=regulars, guests=guests, url_for=url_for) -@_app.route("/reset_players", methods=["POST"]) +@app.route("/reset_players", methods=["POST"]) def reset_all_players(): - _rg.players.reset_all_players() + rg.players.reset_all_players() response = Response("Players reset!") response.headers["HX-Refresh"] = "true" return response -@_app.route("/court-toggle/", methods=["POST"]) +@app.route("/court-toggle/", methods=["POST"]) def toggle_court(court_number): id = int(court_number) - status = _rg.courts.toggle_court_status(id) - t = re.from_string( - '{% from "macros.j2" import court_button %}{{ court_button(id, status) }}' - ) + status = rg.courts.toggle_court_status(id) + t = '{% from "macros.j2" import court_button %}{{ court_button(id, status) }}' return render_template(t, id=id, status=status) -@_app.route("/courts", methods=["GET"]) +@app.route("/courts", methods=["GET"]) def get_list_of_courts(): - return render_template("courts.j2", courts=_rg.courts.get_courts()) + return render_template("courts.j2", courts=rg.courts.get_courts()) diff --git a/app/templates/controls.j2 b/app/templates/controls.j2 new file mode 100644 index 0000000..0183c97 --- /dev/null +++ b/app/templates/controls.j2 @@ -0,0 +1,47 @@ +{% from "macros.j2" import court_button %} +
+
+ {% for key, value in courts.items() %} + {{ court_button(key, value) }} + {% endfor %} +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/app/templates/games.j2 b/app/templates/games.j2 new file mode 100644 index 0000000..46150f7 --- /dev/null +++ b/app/templates/games.j2 @@ -0,0 +1,21 @@ + + + + + + + + + + +{% for game in games %} + + + +{% endfor %} + +
{{ title }}
Court Team 1 Team 2
{{ game.court }}{{ game.team1.player1.first }}{{ " " + game.team1.player1.last }} + {{ game.team1.player2.first }}{{ " " + game.team1.player2.last }} + {{ game.team2.player1.first }}{{ " " + game.team2.player1.last }} + {{ game.team2.player2.first }}{{ " " + game.team2.player2.last }} +
\ No newline at end of file diff --git a/app/templates/index.j2 b/app/templates/index.j2 new file mode 100644 index 0000000..ce0d531 --- /dev/null +++ b/app/templates/index.j2 @@ -0,0 +1,13 @@ +{% from "macros.j2" import header, footer, navbar %} + + + {{ header(url_for) }} + +
+ {{ navbar(url_for) }} +
+
+ {{ footer() }} +
+ + diff --git a/app/templates/macros.j2 b/app/templates/macros.j2 new file mode 100644 index 0000000..2974744 --- /dev/null +++ b/app/templates/macros.j2 @@ -0,0 +1,99 @@ +{% macro court_button(id, status) -%} + +{%- endmacro %} + +{% macro player_button(player) -%} + +{%- endmacro %} + +{% macro guest_control(level, value) -%} +
+
+ +
+
+ +
+
+ +
+
+{%- endmacro %} + +{% macro header(url_for) -%} + + + + Match Up! + + + + + + + + + +{%- endmacro %} + +{% macro footer() -%} +
Made by Karan using Flask, HTMX, Notyf and Bulma
+{%- endmacro %} + +{% macro navbar(url_for) -%} + +{%- endmacro %} \ No newline at end of file diff --git a/app/templates/players.j2 b/app/templates/players.j2 new file mode 100644 index 0000000..0beeee9 --- /dev/null +++ b/app/templates/players.j2 @@ -0,0 +1,31 @@ +{% from "macros.j2" import header, navbar, footer, player_button, guest_control %} + + + {{ header(url_for) }} + +
+ {{ navbar(url_for) }} +

Members

+
+
+ {% for regular in regulars %} +
{{ player_button(regular) }}
+ {% endfor %} +
+

Guests

+
+
+ {% for guest in guests %} +
{{ player_button(guest) }}
+ {% endfor %} +
+
+
+
+ +
+ {{ footer() }} +
+ + \ No newline at end of file diff --git a/app/utilities.py b/app/utilities.py index 7140a5f..e324161 100644 --- a/app/utilities.py +++ b/app/utilities.py @@ -1,7 +1,7 @@ from typing import Tuple from random import shuffle, sample -from match_up.data import Player, DisplayPlayer, Team, Game -from match_up.data import MAX_LEVEL, PLAYER_PER_COURT +from app.data import Player, DisplayPlayer, Team, Game +from app.data import MAX_LEVEL, PLAYER_PER_COURT def _shuffle_two_lists(a: list, b: list) -> Tuple[list, list]: diff --git a/matchup.py b/matchup.py new file mode 100644 index 0000000..e524e69 --- /dev/null +++ b/matchup.py @@ -0,0 +1 @@ +from app import app \ No newline at end of file diff --git a/templates/controls.j2 b/templates/controls.j2 deleted file mode 100644 index 0183c97..0000000 --- a/templates/controls.j2 +++ /dev/null @@ -1,47 +0,0 @@ -{% from "macros.j2" import court_button %} -
-
- {% for key, value in courts.items() %} - {{ court_button(key, value) }} - {% endfor %} -
-
-
-
-
- -
-
-
-
- -
-
-
- -
-
-
- -
-
- -
-
\ No newline at end of file diff --git a/templates/games.j2 b/templates/games.j2 deleted file mode 100644 index 46150f7..0000000 --- a/templates/games.j2 +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - -{% for game in games %} - - - -{% endfor %} - -
{{ title }}
Court Team 1 Team 2
{{ game.court }}{{ game.team1.player1.first }}{{ " " + game.team1.player1.last }} - {{ game.team1.player2.first }}{{ " " + game.team1.player2.last }} - {{ game.team2.player1.first }}{{ " " + game.team2.player1.last }} - {{ game.team2.player2.first }}{{ " " + game.team2.player2.last }} -
\ No newline at end of file diff --git a/templates/index.j2 b/templates/index.j2 deleted file mode 100644 index ce0d531..0000000 --- a/templates/index.j2 +++ /dev/null @@ -1,13 +0,0 @@ -{% from "macros.j2" import header, footer, navbar %} - - - {{ header(url_for) }} - -
- {{ navbar(url_for) }} -
-
- {{ footer() }} -
- - diff --git a/templates/macros.j2 b/templates/macros.j2 deleted file mode 100644 index 2974744..0000000 --- a/templates/macros.j2 +++ /dev/null @@ -1,99 +0,0 @@ -{% macro court_button(id, status) -%} - -{%- endmacro %} - -{% macro player_button(player) -%} - -{%- endmacro %} - -{% macro guest_control(level, value) -%} -
-
- -
-
- -
-
- -
-
-{%- endmacro %} - -{% macro header(url_for) -%} - - - - Match Up! - - - - - - - - - -{%- endmacro %} - -{% macro footer() -%} -
Made by Karan using Flask, HTMX, Notyf and Bulma
-{%- endmacro %} - -{% macro navbar(url_for) -%} - -{%- endmacro %} \ No newline at end of file diff --git a/templates/players.j2 b/templates/players.j2 deleted file mode 100644 index 0beeee9..0000000 --- a/templates/players.j2 +++ /dev/null @@ -1,31 +0,0 @@ -{% from "macros.j2" import header, navbar, footer, player_button, guest_control %} - - - {{ header(url_for) }} - -
- {{ navbar(url_for) }} -

Members

-
-
- {% for regular in regulars %} -
{{ player_button(regular) }}
- {% endfor %} -
-

Guests

-
-
- {% for guest in guests %} -
{{ player_button(guest) }}
- {% endfor %} -
-
-
-
- -
- {{ footer() }} -
- - \ No newline at end of file -- cgit v1.3.1 From 218366342a03c9be2075b6d7aec417dd41caf825 Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Sun, 6 Jul 2025 22:44:14 +0200 Subject: Moved a lot of the code into database queries --- app/__init__.py | 4 + app/config.py | 11 +- app/controller.py | 178 +++++++++---------- app/data.py | 76 --------- app/model.py | 321 +++++++++++++++++++---------------- app/routes.py | 28 +-- app/utilities.py | 106 ++++++------ migrations/README | 1 + migrations/alembic.ini | 50 ++++++ migrations/env.py | 113 ++++++++++++ migrations/script.py.mako | 24 +++ migrations/versions/d158cf8f4950_.py | 39 +++++ 12 files changed, 571 insertions(+), 380 deletions(-) delete mode 100644 app/data.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/d158cf8f4950_.py (limited to 'app/model.py') diff --git a/app/__init__.py b/app/__init__.py index 7b0c03d..2e0a8be 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,11 @@ from flask import Flask from app.config import Config +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy app = Flask(__name__) app.config.from_object(Config) +db = SQLAlchemy(app) +migrate = Migrate(app, db) from app import routes diff --git a/app/config.py b/app/config.py index aeb2840..a6a60be 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,13 @@ from os import environ +from os.path import abspath, dirname, join + class Config: - SECRET_KEY = environ.get("SECRET_KEY") or "you-will-never-guess" \ No newline at end of file + SECRET_KEY = environ.get("SECRET_KEY") or None + DATABASE_CSV = environ.get("DATABASE_CSV") or None + COURT_COUNT = environ.get("COURT_COUNT") or None + SQLALCHEMY_DATABASE_URI = "sqlite:///" + join(abspath(dirname(__file__)), "app.db") + PLAYERS_PER_COURT = 4 + GUEST_LEVELS = [1, 3, 5] + MIN_PLAYER_LEVEL = 1 + MAX_PLAYER_LEVEL = 10 \ No newline at end of file diff --git a/app/controller.py b/app/controller.py index 498715d..1937af8 100644 --- a/app/controller.py +++ b/app/controller.py @@ -1,99 +1,103 @@ -from typing import Tuple +from math import floor +from app import app, db +from datetime import date from operator import attrgetter -from app.data import Game, PLAYER_PER_COURT -from app.model import Player, PlayerList, CourtList +from sqlalchemy import select, func +from app.model import ( + Player, + Players, + Courts, + Game, + DisplayPlayer, + DisplayTeam, + DisplayGame, +) from app.utilities import ( - _create_placeholders, - _select_players, - _create_shuffle_games, - _create_team_games, + get_placeholder_games, + get_team_games, + get_shuffle_games, separate_teams_and_players, - split_courts, ) -class RoundGenerator: - proposed_games: list[Game] = [] - proposed_players: list[Player] = [] - - def __init__(self) -> None: - self.courts = CourtList() - self.players = PlayerList() - self.reset() - - def _separate_courts(self, courts: dict) -> Tuple[dict, dict]: - possible_games = min(len(courts), self.players.get_possible_game_count()) - courts_as_list = list(courts.items()) - used_courts = dict(courts_as_list[:possible_games]) - unused_courts = dict(courts_as_list[possible_games:]) - return used_courts, unused_courts +class Session: + levels: int = 10 + teams: bool = False + proposal: list[DisplayGame] = [] + courts: Courts = Courts() + players: Players = Players() - def _create_inactive_games(self) -> list[Game]: - active_courts, reserved_courts = self.courts.separate_courts() - reserved_games = _create_placeholders(reserved_courts, "RESERVED") - _, unused_courts = self._separate_courts(active_courts) - blank_games = _create_placeholders(unused_courts, "---") - games = reserved_games + blank_games - return games + def __init__(self): + games = db.session.query(Game).filter(Game.date.date() == date.today()).all() + if len(games) > 0: + return + id = self.players.default_id() + player_data = {f"player_{i+1}": id for i in range(4)} + for court in self.courts.all(): + args = {"round_id": 0, "court_id": court.id} | player_data + db.session.add(Game(*args)) + db.session.commit() - def _create_active_games( - self, total_levels: int, group_by_team: bool - ) -> Tuple[list[Game], list[Player]]: - active_courts = self.courts.separate_courts()[0] - used_courts = self._separate_courts(active_courts)[0] - required_player_count = len(used_courts) * PLAYER_PER_COURT - active_players = self.players.get_players() - if group_by_team: - active_teams, team_less_players = separate_teams_and_players(active_players) - regular_court_count = len(used_courts) - len(active_teams) - regular_courts, team_courts = split_courts(used_courts, regular_court_count) - new_player_count = required_player_count - ( - PLAYER_PER_COURT * len(active_teams) - ) - regular_players = _select_players(team_less_players, new_player_count) - regular_games = _create_shuffle_games( - regular_courts, regular_players, total_levels - ) - team_games, selected_team_players = _create_team_games( - team_courts, active_teams - ) - selected_players = regular_players + selected_team_players - games = regular_games + team_games - else: - selected_players = _select_players(active_players, required_player_count) - games = _create_shuffle_games(used_courts, selected_players, total_levels) - return games, selected_players + def round(self) -> int: + query = select(func.max(Game.round_id)).where(Game.date.date() == date.today()) + round_id = db.session.scalars(query).first() + if round_id is None: + raise ValueError(f"Database isn't initialized") + return round_id - def update_player_list(self, csv_location): - self.players = PlayerList(csv_location) - - def clear(self) -> list[Game]: - self.proposed_games = [] - self.proposed_players = [] - return self.games - - def propose(self, levels: int, group_by_team: bool) -> list[Game]: - inactive_games = self._create_inactive_games() - active_games, selected_players = self._create_active_games( - levels, group_by_team + def all(self) -> list[DisplayGame]: + games = ( + db.session.query(Game) + .filter(Game.date.date() == date.today()) + .filter(Game.round_id == self.round()) + .order_by(Game.court_id) + .all() ) - games = active_games + inactive_games - games.sort(key=attrgetter("court")) - self.proposed_games = games - self.proposed_players = selected_players - return self.proposed_games + dg = [] + for game in games: + dp = [] + for i in range(4): + p = db.session.get(Player, getattr(game, f"player_{i+1}")) + if p is None: + raise ValueError(f"Player id mismatch.") + t1 = DisplayTeam(DisplayPlayer(dp[0]), DisplayPlayer(dp[1])) + t2 = DisplayTeam(DisplayPlayer(dp[2]), DisplayPlayer(dp[3])) + db.session.get(Player, game.player_2) + dg.append(DisplayGame(game.court_id, t1, t2)) + return dg + + def propose(self) -> list[DisplayGame]: + reserved_courts = [court for court in self.courts.all() if not court.status] + reserved_games = get_placeholder_games(reserved_courts, "RESERVED") + active_courts = self.courts.active() + active_players = self.players.active() + game_count = floor(len(active_players) / app.config["PLAYER_PER_COURT"]) + game_count = min(len(active_courts), game_count) + used_courts = active_courts[:game_count] + unused_courts = active_courts[game_count:] + blank_games = get_placeholder_games(unused_courts, "---") + active_teams = {} + if self.teams: + active_teams, active_players = separate_teams_and_players(active_players) + team_courts = used_courts[: len(active_teams)] + shuffle_courts = used_courts[len(active_teams) :] + team_games = get_team_games(team_courts, active_teams) + shuffle_games = get_shuffle_games(shuffle_courts, active_players, self.levels) + self.proposal = shuffle_games + team_games + blank_games + reserved_games + self.proposal.sort(key=attrgetter("court_id")) + return self.proposal - def confirm(self) -> list[Game]: - if len(self.proposed_games) == 0: - return self.games - self.games = self.proposed_games - identifiers = [int(player.id) for player in self.proposed_players] - self.players.increment_game_count(identifiers) - self.round += 1 - return self.clear() + def confirm(self) -> list[DisplayGame]: + if len(self.proposal) == 0: + return self.all() + for game in self.proposal: + db.session.add(game) + db.session.commit() + self.proposal = [] + return self.all() - def reset(self): - self.round = 0 - self.games = _create_placeholders(self.courts.get_courts(), "---") - self.players.reset_game_count() - return self.games + def reset(self) -> list[DisplayGame]: + db.session.query(Game).filter(Game.date.date() == date.today()).delete() + db.session.commit() + self.__init__() + return self.all() diff --git a/app/data.py b/app/data.py deleted file mode 100644 index c00dde2..0000000 --- a/app/data.py +++ /dev/null @@ -1,76 +0,0 @@ -from dataclasses import dataclass -from sqlalchemy import Column, String, Integer -from sqlalchemy.ext.declarative import declarative_base - - -MIN_LEVEL = 1 -MAX_LEVEL = 10 -PLAYER_PER_COURT = 4 -GUEST_LEVELS = [1, 3, 5] -GUESTS_PER_LEVEL = 4 - -BASE = declarative_base() - - -class Player(BASE): - __tablename__ = "players" - id = Column("id", Integer, primary_key=True) - first = Column("first", String) - last = Column("last", String) - level = Column("level", Integer) - team = Column("team", Integer) - status = Column("status", Integer) - count = Column("count", Integer) - - def __init__( - self, - id: int, - first: str = "", - last: str = "", - level: int = 0, - team: int = 0, - status: bool = True, - count: int = 0, - ): - self.id = id - self.first = first - self.last = last - self.status = status - self.team = team - self.level = level - self.count = count - - def __repr__(self): - team = f" of Team {self.team}" if self.team else "" - status = "active" if self.status else "inactive" - return f"{self.first} {self.last} (Level {self.level}){team} is {status} and has played {self.count} games" - - -class DisplayPlayer: - id: int - first: str - last: str - status: bool - - def __init__(self, player: Player): - self.id = int(player.id) - self.first = str(player.first) - self.last = str(player.last) - self.status = bool(player.status) - - def __repr__(self): - status = "active" if self.status else "inactive" - return f"{self.first} {self.last} is {status}" - - -@dataclass -class Team: - player1: DisplayPlayer - player2: DisplayPlayer - - -@dataclass -class Game: - court: int - team1: Team - team2: Team diff --git a/app/model.py b/app/model.py index b2b6470..2833bb1 100644 --- a/app/model.py +++ b/app/model.py @@ -1,83 +1,87 @@ from math import floor -from typing import Tuple -from os.path import isfile +from app import app, db from pandas import read_csv from dataclasses import dataclass -from random import getrandbits, randint -from sqlalchemy.orm import sessionmaker -from sqlalchemy import create_engine, not_ +from sqlalchemy.orm import Mapped +from datetime import date, datetime +from random import randint, getrandbits +from typing import Tuple, Optional, Sequence +from sqlalchemy.orm import mapped_column as mc from names import get_first_name, get_last_name -from app.data import ( - MIN_LEVEL, - MAX_LEVEL, - PLAYER_PER_COURT, - BASE, - GUEST_LEVELS, - GUESTS_PER_LEVEL, - Player, - DisplayPlayer, -) +from sqlalchemy import String, Boolean, Integer, ForeignKey, select -@dataclass -class CourtList: - total: int = 12 +class User(db.Model): + id: Mapped[int] = mc(primary_key=True) + username: Mapped[str] = mc(String(64), index=True, unique=True) + password_hash: Mapped[Optional[str]] = mc(String(256)) + + def __repr__(self): + return f"User {self.username}" + + +class Court(db.Model): + id: Mapped[int] = mc(primary_key=True) + status: Mapped[bool] = mc(Boolean, default=True) - def __post_init__(self) -> None: - self.courts = {i + 1: True for i in range(self.total)} + def __repr__(self): + return f"Court {self.id} is {"active" if self.status else "inactive"}" - def get_court_status(self, court_number: int) -> bool: - return self.courts[court_number] - def toggle_court_status(self, court_number: int) -> bool: - new_status = not self.courts[court_number] - self.courts[court_number] = new_status +class Courts: + + def __init__(self): + if app.config["COURT_COUNT"] is not None: + db.session.query(Court).delete() + for i in range(app.config["COURT_COUNT"]): + db.session.add(Court()) + db.session.commit() + + def get(self, id: int) -> bool: + court = db.session.get(Court, id) + if court is None: + raise ValueError(f"Court {id} doesn't exist.") + return court.status + + def toggle(self, id: int) -> bool: + court = db.session.get(Court, id) + if court is None: + raise ValueError(f"Court {id} doesn't exist.") + new_status = not court.status + court.status = new_status + db.session.commit() return new_status - def get_courts(self) -> dict: - return self.courts - - def separate_courts(self) -> Tuple[dict, dict]: - active_courts = {k: v for k, v in self.courts.items() if v} - inactive_courts = {k: v for k, v in self.courts.items() if not v} - return active_courts, inactive_courts - - -def _init_guests(session) -> None: - for level_id, level in enumerate(GUEST_LEVELS): - for player_id in range(GUESTS_PER_LEVEL): - unique_id = (level_id * GUESTS_PER_LEVEL) + player_id + 1 - player = Player( - id=-unique_id, - first="Guest", - last=str(unique_id), - level=level, - status=False, - count=0, - ) - session.add(player) - session.commit() - - -def _init_database_from_csv(session, csv_location: str) -> None: - data = read_csv(csv_location) - session.query(Player).delete() - for _, entry in data.iterrows(): - player = Player( - id=entry["id"], - first=entry["first"], - last=entry["last"], - level=entry["skill"], - team=entry["team"], - status=False, - count=0, - ) - session.add(player) - session.commit() - _init_guests(session) + def active(self): + return db.session.query(Court).where(Court.status).all() + def all(self) -> list[Court]: + courts = [court for court in db.session.query(Court).order_by(Court.id)] + return courts -def _init_random_database(session, player_count: int = 80) -> None: + def reset(self): + for court in db.session.scalars(select(Court)): + court.status = True + db.session.commit() + + +class Player(db.Model): + id: Mapped[int] = mc(primary_key=True) + first_name: Mapped[str] = mc(String(64), index=True, default="---") + last_name: Mapped[str] = mc(String(64), index=True, default="---") + team: Mapped[int] = mc(Integer, index=True, default=0) + status: Mapped[bool] = mc(Boolean, default=False) + level: Mapped[int] = mc(Integer, default=app.config["MIN_PLAYER_LEVEL"]) + + def __repr__(self): + return f"{self.first_name} {self.last_name} is {"active" if self.status else "inactive"}" + + +def initialize_random_database( + player_count: int = 80, guest_per_level: int = 4 +) -> None: + db.session.add(Player(*{"first_name": "---", "last_name": ""})) + db.session.add(Player(*{"first_name": "RESERVED", "last_name": ""})) total_teams = 5 players_per_team = 5 for i in range(player_count): @@ -85,88 +89,117 @@ def _init_random_database(session, player_count: int = 80) -> None: team = floor(i / players_per_team) + 1 else: team = 0 - player = Player( - id=i, - first=get_first_name(), - last=get_last_name(), - level=randint(MIN_LEVEL, MAX_LEVEL), - team=team, - status=bool(getrandbits(1)), - count=0, + args = { + "first_name": get_first_name(), + "last_name": get_last_name(), + "team": team, + "status": bool(getrandbits(1)), + "level": randint( + app.config["MIN_PLAYER_LEVEL"], app.config["MAX_PLAYER_LEVEL"] + ), + } + db.session.add(Player(*args)) + meta_data = {"first_name": "Guest", "team": 0, "status": False} + for level_id, level in enumerate(app.config["GUEST_LEVELS"]): + for player_id in range(guest_per_level): + unique_id = (level_id * guest_per_level) + player_id + 1 + args = meta_data | {"last_name": str(unique_id), "level": level} + db.session.add(Player(*args)) + db.session.commit() + + +class Players: + + def __init__(self) -> None: + if app.config["DATABASE_CSV"] is not None: + data = read_csv(app.config["DATABASE_CSV"]) + db.session.query(Player).delete() + for entry in data.to_dict("records"): + args = entry | {"status": False} + db.session.add(Player(*args)) + db.session.commit() + else: + if len(self.all()) == 0: + initialize_random_database() + + def default_id(self): + query = select(Player).where(Player.first_name == "---") + player = db.session.scalars(query).first() + if player is None: + raise ValueError(f"Database isn't initialized.") + return player.id + + def reserved_id(self): + query = select(Player).where(Player.first_name == "RESERVED") + player = db.session.scalars(query).first() + if player is None: + raise ValueError(f"Database isn't initialized.") + return player.id + + def get(self, id: int) -> bool: + player = db.session.get(Player, id) + if player is None: + raise ValueError(f"User {id} doesn't exist.") + return player.status + + def toggle(self, id: int) -> Player: + player = db.session.get(Player, id) + if player is None: + raise ValueError(f"User {id} doesn't exist.") + player.status = not player.status + db.session.commit() + return player + + def all(self) -> Tuple[list[Player], list[Player]]: + regulars = [ + p for p in db.session.query(Player).where(Player.first_name != "Guest") + ] + guests = [ + p for p in db.session.query(Player).where(Player.first_name == "Guest") + ] + guests.sort(key=lambda x: int(x.last_name)) + return regulars, guests + + def active(self) -> Sequence[Player]: + query = ( + select(Player) + .where(Player.status) + .order_by(Player.first_name, Player.last_name) ) - session.add(player) - session.commit() - _init_guests(session) + return db.session.scalars(query).all() + def reset(self): + for player in db.session.scalars(select(Player)): + player.status = False + db.session.commit() -class PlayerList: - def __init__(self, csv_location=None) -> None: - self.db_location = "players.db" - db_file_found = True - if not isfile(self.db_location): - db_file_found = False - self._init_database() - if csv_location is not None: - _init_database_from_csv(self.session, csv_location) - else: - if not db_file_found: - _init_random_database(self.session) +class Game(db.Model): + id: Mapped[int] = mc(primary_key=True) + round_id: Mapped[int] = mc(Integer, index=True) + court_id: Mapped[int] = mc(ForeignKey(Court.id), index=True) + player_1: Mapped[int] = mc(ForeignKey(Player.id), index=True) + player_2: Mapped[int] = mc(ForeignKey(Player.id), index=True) + player_3: Mapped[int] = mc(ForeignKey(Player.id), index=True) + player_4: Mapped[int] = mc(ForeignKey(Player.id), index=True) + date: Mapped[datetime] = mc(index=True, default=lambda: date.today()) - def _init_database(self): - engine = create_engine("sqlite:///" + str(self.db_location)) - BASE.metadata.create_all(bind=engine) - self.session = sessionmaker(bind=engine)() - def get_player_status(self, id: int) -> bool: - return self.session.query(Player).filter(Player.id == id).first().status +class DisplayPlayer: - def toggle_player_status(self, id: int) -> DisplayPlayer: - player = self.session.query(Player).filter(Player.id == id).first() - player.status = not player.status - self.session.commit() - return DisplayPlayer(player) - - def get_all_players(self) -> Tuple[list[DisplayPlayer], list[DisplayPlayer]]: - all_players = [ - DisplayPlayer(p) - for p in self.session.query(Player) - .order_by(Player.first, Player.last) - .all() - ] - regulars = [player for player in all_players if player.first != "Guest"] - guests = [player for player in all_players if player.first == "Guest"] - guests.sort(key=lambda x: int(x.last)) - return regulars, guests + def __init__(self, player: Player): + self.id = player.id + self.name = f"{player.first_name} {player.last_name}" - def get_possible_game_count(self) -> int: - total_players = len(self.get_players()) - possible_games = floor(total_players / PLAYER_PER_COURT) - return possible_games - - def get_players(self) -> list[Player]: - active_players = ( - self.session.query(Player) - .order_by(Player.first, Player.last) - .filter(not_(Player.status == 0)) - .all() - ) - return active_players - - def increment_game_count(self, player_ids: list[int]): - players = self.session.query(Player).filter(Player.id.in_(player_ids)) - for player in players: - player.count += 1 - self.session.commit() - - def reset_game_count(self): - players = self.session.query(Player) - for player in players: - player.count = 0 - self.session.commit() - - def reset_all_players(self): - players = self.session.query(Player) - for player in players: - player.status = 0 - self.session.commit() + +@dataclass +class DisplayTeam: + player_1: DisplayPlayer + player_2: DisplayPlayer + + +@dataclass +class DisplayGame: + court_id: int + team_1: DisplayTeam + team_2: DisplayTeam diff --git a/app/routes.py b/app/routes.py index b943e16..2cc6009 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,9 +1,9 @@ from app import app from app.forms import LoginForm -from app.controller import RoundGenerator +from app.controller import Session from flask import render_template, request, url_for, Response, flash, redirect -rg = RoundGenerator() +g = Session() @app.route("/", methods=["GET"]) def home(): @@ -19,50 +19,50 @@ def login(): @app.route("/control", methods=["GET"]) def get_controls(): - return render_template("controls.j2", courts=rg.courts.get_courts()) + return render_template("controls.j2", courts=g.courts.all()) @app.route("/propose", methods=["POST"]) def get_random_games(): - levels = int(request.form["levels"].split()[0]) - team_practice = request.form["team"] == "Teams" + g.levels = int(request.form["levels"].split()[0]) + g.teams = request.form["team"] == "Teams" return render_template( - "games.j2", games=rg.propose(levels, team_practice), title="Proposal" + "games.j2", games=g.propose(), title="Proposal" ) @app.route("/confirm", methods=["POST"]) def confirm_games(): - return render_template("games.j2", games=rg.confirm(), title=f"Round: {rg.round}") + return render_template("games.j2", games=g.confirm(), title=f"Round: {g.round()}") @app.route("/clear", methods=["POST"]) def clear_games(): - return render_template("games.j2", games=rg.clear(), title=f"Round: {rg.round}") + return render_template("games.j2", games=g.all(), title=f"Round: {g.round()}") @app.route("/reset", methods=["POST"]) def reset_games(): - return render_template("games.j2", games=rg.reset(), title=f"Round: {rg.round}") + return render_template("games.j2", games=g.reset(), title=f"Round: {g.round()}") @app.route("/player-toggle/", methods=["POST"]) def toggle_player(player_request): id = int(player_request) - player = rg.players.toggle_player_status(id) + player = g.players.toggle(id) t = '{% from "macros.j2" import player_button %}{{player_button(player)}}' return render_template(t, player=player) @app.route("/players", methods=["GET"]) def get_list_of_players(): - regulars, guests = rg.players.get_all_players() + regulars, guests = g.players.all() return render_template("players.j2", regulars=regulars, guests=guests, url_for=url_for) @app.route("/reset_players", methods=["POST"]) def reset_all_players(): - rg.players.reset_all_players() + g.players.reset() response = Response("Players reset!") response.headers["HX-Refresh"] = "true" return response @@ -71,11 +71,11 @@ def reset_all_players(): @app.route("/court-toggle/", methods=["POST"]) def toggle_court(court_number): id = int(court_number) - status = rg.courts.toggle_court_status(id) + status = g.courts.toggle(id) t = '{% from "macros.j2" import court_button %}{{ court_button(id, status) }}' return render_template(t, id=id, status=status) @app.route("/courts", methods=["GET"]) def get_list_of_courts(): - return render_template("courts.j2", courts=rg.courts.get_courts()) + return render_template("courts.j2", courts=g.courts.all()) diff --git a/app/utilities.py b/app/utilities.py index e324161..00dc14a 100644 --- a/app/utilities.py +++ b/app/utilities.py @@ -1,7 +1,7 @@ -from typing import Tuple +from app import app, db +from typing import Tuple, Sequence from random import shuffle, sample -from app.data import Player, DisplayPlayer, Team, Game -from app.data import MAX_LEVEL, PLAYER_PER_COURT +from app.model import Player, DisplayPlayer, Court, DisplayGame, DisplayTeam def _shuffle_two_lists(a: list, b: list) -> Tuple[list, list]: @@ -25,76 +25,34 @@ def sample_list(indices: list, cost: list[int], count=None) -> list: return indices -def _create_placeholders(courts: dict, name: str) -> list[Game]: - dummy_player = Player(id=-1, first=name) - dummy_team = Team( - player1=DisplayPlayer(dummy_player), player2=DisplayPlayer(dummy_player) - ) - games = [Game(court, dummy_team, dummy_team) for court in courts] - return games - - def _normalize_skill(level: int, new_max: int) -> int: - new_level = round((new_max - 1) * (level / MAX_LEVEL)) + 1 + new_level = round((new_max - 1) * (level / app.config["MAX_PLAYER_LEVEL"])) + 1 return new_level -def _select_players(players: list[Player], count: int) -> list[Player]: +def select_players(players: Sequence[Player], count: int) -> list[Player]: identifiers = [player.id for player in players] - play_count = [int(player.count) for player in players] + play_count = [int(player.id) for player in players] ids = sample_list(identifiers, play_count, count) selected_players = [player for player in players if player.id in ids] return selected_players -def _create_groups_of_four_players( - players: list[Player], new_max: int -) -> list[list[Player]]: +def group_by_four(players: list[Player], new_max: int) -> list[list[Player]]: identifiers = [player.id for player in players] player_levels = [_normalize_skill(int(player.level), new_max) for player in players] identifiers = sample_list(identifiers, player_levels) court_players = [] - for i in range(0, len(players), PLAYER_PER_COURT): - court_ids = identifiers[i : i + PLAYER_PER_COURT] + for i in range(0, len(players), app.config["PLAYERS_PER_COURT"]): + court_ids = identifiers[i : i + app.config["PLAYERS_PER_COURT"]] selected_players = [player for player in players if player.id in court_ids] court_players.append(selected_players) return court_players -def _create_shuffle_games( - courts: dict, players: list[Player], levels: int -) -> list[Game]: - court_players = _create_groups_of_four_players(players, levels) - games = [] - for court, players in zip(courts, court_players): - team1 = Team( - player1=DisplayPlayer(players[0]), player2=DisplayPlayer(players[3]) - ) - team2 = Team( - player1=DisplayPlayer(players[1]), player2=DisplayPlayer(players[2]) - ) - games.append(Game(court, team1, team2)) - return games - - -def _create_team_games(courts: dict, teams: dict) -> tuple[list[Game], list[Player]]: - games = [] - selected_players = [] - for court, (_, players) in zip(courts, teams.items()): - players = sample(players, PLAYER_PER_COURT) - selected_players += players - players.sort(key=lambda x: x.level, reverse=True) - team1 = Team( - player1=DisplayPlayer(players[0]), player2=DisplayPlayer(players[3]) - ) - team2 = Team( - player1=DisplayPlayer(players[1]), player2=DisplayPlayer(players[2]) - ) - games.append(Game(court, team1, team2)) - return games, selected_players - - -def separate_teams_and_players(players: list[Player]) -> Tuple[dict, list[Player]]: +def separate_teams_and_players( + players: Sequence[Player], +) -> tuple[dict[int, list[Player]], list[Player]]: unique_team_id = {player.team for player in players} grouped_players_by_team = {team_id: [] for team_id in unique_team_id} for player in players: @@ -117,7 +75,39 @@ def separate_teams_and_players(players: list[Player]) -> Tuple[dict, list[Player return active_teams, players_without_team -def split_courts(courts: dict, n) -> Tuple[dict, dict]: - first_half = {k: v for (k, v) in [x for x in courts.items()][:n]} - second_half = {k: v for (k, v) in [x for x in courts.items()][n:]} - return first_half, second_half +def get_placeholder_games(courts: list[Court], name: str) -> list[DisplayGame]: + p = db.session.query(Player).where(Player.first_name == name).first() + if p is None: + raise ValueError(f"Database isn't initialized.") + dummy_team = DisplayTeam(DisplayPlayer(p), DisplayPlayer(p)) + games = [DisplayGame(court.id, dummy_team, dummy_team) for court in courts] + return games + + +def get_team_games( + courts: list[Court], teams: dict[int, list[Player]] +) -> list[DisplayGame]: + games = [] + for court, (_, players) in zip(courts, teams.items()): + players = sample(players, app.config["PLAYER_PER_COURT"]) + players.sort(key=lambda x: x.level, reverse=True) + dp = [DisplayPlayer(p) for p in players] + games.append( + DisplayGame(court.id, DisplayTeam(dp[0], dp[3]), DisplayTeam(dp[1], dp[2])) + ) + return games + + +def get_shuffle_games( + courts: list[Court], players: Sequence[Player], total_levels: int +) -> list[DisplayGame]: + required_player_count = len(courts) * app.config["PLAYER_PER_COURT"] + selected_players = select_players(players, required_player_count) + court_players = group_by_four(selected_players, total_levels) + games = [] + for court, players in zip(courts, court_players): + dp = [DisplayPlayer(p) for p in players] + games.append( + DisplayGame(court.id, DisplayTeam(dp[0], dp[3]), DisplayTeam(dp[1], dp[2])) + ) + return games diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/d158cf8f4950_.py b/migrations/versions/d158cf8f4950_.py new file mode 100644 index 0000000..392d3bf --- /dev/null +++ b/migrations/versions/d158cf8f4950_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: d158cf8f4950 +Revises: +Create Date: 2025-07-06 15:31:24.124657 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd158cf8f4950' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('password_hash', sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_username')) + + op.drop_table('user') + # ### end Alembic commands ### -- cgit v1.3.1 From d5d64f2b1047f864811d23f19f109a6513c22a2a Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Sun, 13 Jul 2025 18:20:04 +0200 Subject: Working version with several bugs --- app/controller.py | 33 ++++++++++++----------- app/model.py | 68 +++++++++++++++++++++++++++++++---------------- app/routes.py | 7 ++--- app/templates/controls.j2 | 4 +-- app/templates/games.j2 | 12 ++++----- app/templates/macros.j2 | 2 +- app/utilities.py | 4 +-- 7 files changed, 78 insertions(+), 52 deletions(-) (limited to 'app/model.py') diff --git a/app/controller.py b/app/controller.py index 1937af8..5bdeba7 100644 --- a/app/controller.py +++ b/app/controller.py @@ -2,7 +2,7 @@ from math import floor from app import app, db from datetime import date from operator import attrgetter -from sqlalchemy import select, func +from sqlalchemy import select, func, Date from app.model import ( Player, Players, @@ -28,19 +28,19 @@ class Session: players: Players = Players() def __init__(self): - games = db.session.query(Game).filter(Game.date.date() == date.today()).all() - if len(games) > 0: - return - id = self.players.default_id() - player_data = {f"player_{i+1}": id for i in range(4)} - for court in self.courts.all(): - args = {"round_id": 0, "court_id": court.id} | player_data - db.session.add(Game(*args)) - db.session.commit() + with app.app_context(): + db.session.query(Game).delete() + id = self.players.default_id() + player_data = {f"player_{i+1}": id for i in range(4)} + for court in self.courts.all(): + args = {"round_id": 0, "court_id": court.id} | player_data + db.session.add(Game(**args)) + db.session.commit() def round(self) -> int: - query = select(func.max(Game.round_id)).where(Game.date.date() == date.today()) - round_id = db.session.scalars(query).first() + games = db.session.query(Game).filter(func.date(Game.date) == date.today()).all() + round_id = max([game.round_id for game in games]) + print(round_id) if round_id is None: raise ValueError(f"Database isn't initialized") return round_id @@ -48,7 +48,7 @@ class Session: def all(self) -> list[DisplayGame]: games = ( db.session.query(Game) - .filter(Game.date.date() == date.today()) + .filter(func.date(Game.date) == date.today()) .filter(Game.round_id == self.round()) .order_by(Game.court_id) .all() @@ -60,6 +60,7 @@ class Session: p = db.session.get(Player, getattr(game, f"player_{i+1}")) if p is None: raise ValueError(f"Player id mismatch.") + dp.append(p) t1 = DisplayTeam(DisplayPlayer(dp[0]), DisplayPlayer(dp[1])) t2 = DisplayTeam(DisplayPlayer(dp[2]), DisplayPlayer(dp[3])) db.session.get(Player, game.player_2) @@ -71,7 +72,9 @@ class Session: reserved_games = get_placeholder_games(reserved_courts, "RESERVED") active_courts = self.courts.active() active_players = self.players.active() - game_count = floor(len(active_players) / app.config["PLAYER_PER_COURT"]) + for player in active_players: + print(player) + game_count = floor(len(active_players) / app.config["PLAYERS_PER_COURT"]) game_count = min(len(active_courts), game_count) used_courts = active_courts[:game_count] unused_courts = active_courts[game_count:] @@ -97,7 +100,7 @@ class Session: return self.all() def reset(self) -> list[DisplayGame]: - db.session.query(Game).filter(Game.date.date() == date.today()).delete() + db.session.query(Game).filter(Game.date.cast(Date) == date.today()).delete() db.session.commit() self.__init__() return self.all() diff --git a/app/model.py b/app/model.py index 2833bb1..56921f4 100644 --- a/app/model.py +++ b/app/model.py @@ -31,11 +31,12 @@ class Court(db.Model): class Courts: def __init__(self): - if app.config["COURT_COUNT"] is not None: - db.session.query(Court).delete() - for i in range(app.config["COURT_COUNT"]): - db.session.add(Court()) - db.session.commit() + with app.app_context(): + if app.config["COURT_COUNT"] is not None: + db.session.query(Court).delete() + for i in range(int(app.config["COURT_COUNT"])): + db.session.add(Court()) + db.session.commit() def get(self, id: int) -> bool: court = db.session.get(Court, id) @@ -69,9 +70,17 @@ class Player(db.Model): id: Mapped[int] = mc(primary_key=True) first_name: Mapped[str] = mc(String(64), index=True, default="---") last_name: Mapped[str] = mc(String(64), index=True, default="---") - team: Mapped[int] = mc(Integer, index=True, default=0) - status: Mapped[bool] = mc(Boolean, default=False) - level: Mapped[int] = mc(Integer, default=app.config["MIN_PLAYER_LEVEL"]) + team: Mapped[int] = mc(Integer, index=True) + status: Mapped[bool] = mc(Boolean) + level: Mapped[int] = mc(Integer) + + def __init__(self, id, first_name, last_name, team = 0, status = False, level = int(app.config["MIN_PLAYER_LEVEL"])): + self.id = id + self.first_name = first_name + self.last_name = last_name + self.team = team + self.status = status + self.level = level def __repr__(self): return f"{self.first_name} {self.last_name} is {"active" if self.status else "inactive"}" @@ -80,8 +89,8 @@ class Player(db.Model): def initialize_random_database( player_count: int = 80, guest_per_level: int = 4 ) -> None: - db.session.add(Player(*{"first_name": "---", "last_name": ""})) - db.session.add(Player(*{"first_name": "RESERVED", "last_name": ""})) + db.session.add(Player(**{"id": 0, "first_name": "---", "last_name": ""})) + db.session.add(Player(**{"id": 1, "first_name": "RESERVED", "last_name": ""})) total_teams = 5 players_per_team = 5 for i in range(player_count): @@ -98,29 +107,32 @@ def initialize_random_database( app.config["MIN_PLAYER_LEVEL"], app.config["MAX_PLAYER_LEVEL"] ), } - db.session.add(Player(*args)) + db.session.add(Player(**args)) meta_data = {"first_name": "Guest", "team": 0, "status": False} for level_id, level in enumerate(app.config["GUEST_LEVELS"]): for player_id in range(guest_per_level): unique_id = (level_id * guest_per_level) + player_id + 1 args = meta_data | {"last_name": str(unique_id), "level": level} - db.session.add(Player(*args)) + db.session.add(Player(**args)) db.session.commit() class Players: def __init__(self) -> None: - if app.config["DATABASE_CSV"] is not None: - data = read_csv(app.config["DATABASE_CSV"]) - db.session.query(Player).delete() - for entry in data.to_dict("records"): - args = entry | {"status": False} - db.session.add(Player(*args)) - db.session.commit() - else: - if len(self.all()) == 0: - initialize_random_database() + with app.app_context(): + if app.config["DATABASE_CSV"] is not None: + data = read_csv(app.config["DATABASE_CSV"]) + db.session.query(Player).delete() + db.session.add(Player(**{"id": 0, "first_name": "---", "last_name": ""})) + db.session.add(Player(**{"id": 1, "first_name": "RESERVED", "last_name": ""})) + for entry in data.to_dict("records"): + args = entry | {"status": False} + db.session.add(Player(**args)) + db.session.commit() + else: + if len(self.all()) == 0: + initialize_random_database() def default_id(self): query = select(Player).where(Player.first_name == "---") @@ -184,12 +196,22 @@ class Game(db.Model): player_4: Mapped[int] = mc(ForeignKey(Player.id), index=True) date: Mapped[datetime] = mc(index=True, default=lambda: date.today()) + def __init__(self, round_id: int, court_id: int, player_1: int, player_2: int, player_3: int, player_4: int): + self.round_id = round_id + self.court_id = court_id + self.player_1 = player_1 + self.player_2 = player_2 + self.player_3 = player_3 + self.player_4 = player_4 + + class DisplayPlayer: def __init__(self, player: Player): self.id = player.id - self.name = f"{player.first_name} {player.last_name}" + self.first_name = player.first_name + self.last_name = player.last_name @dataclass diff --git a/app/routes.py b/app/routes.py index 2cc6009..bc85e48 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,7 +1,7 @@ from app import app from app.forms import LoginForm from app.controller import Session -from flask import render_template, request, url_for, Response, flash, redirect +from flask import render_template, request, url_for, Response, flash, redirect, get_template_attribute, render_template_string g = Session() @@ -50,8 +50,9 @@ def reset_games(): def toggle_player(player_request): id = int(player_request) player = g.players.toggle(id) - t = '{% from "macros.j2" import player_button %}{{player_button(player)}}' - return render_template(t, player=player) + button = get_template_attribute("macros.j2", "player_button") + t = '{% from "macros.j2" import player_button %}' + return render_template_string("{{player_button(player)}}", player=player, player_button=button) @app.route("/players", methods=["GET"]) diff --git a/app/templates/controls.j2 b/app/templates/controls.j2 index 0183c97..eba086d 100644 --- a/app/templates/controls.j2 +++ b/app/templates/controls.j2 @@ -1,8 +1,8 @@ {% from "macros.j2" import court_button %}
- {% for key, value in courts.items() %} - {{ court_button(key, value) }} + {% for court in courts %} + {{ court_button(court.id, court.status) }} {% endfor %}
diff --git a/app/templates/games.j2 b/app/templates/games.j2 index 46150f7..ccfd395 100644 --- a/app/templates/games.j2 +++ b/app/templates/games.j2 @@ -9,12 +9,12 @@ {% for game in games %} - - {{ game.court }} - {{ game.team1.player1.first }}{{ " " + game.team1.player1.last }} - {{ game.team1.player2.first }}{{ " " + game.team1.player2.last }} - {{ game.team2.player1.first }}{{ " " + game.team2.player1.last }} - {{ game.team2.player2.first }}{{ " " + game.team2.player2.last }} + + {{ game.court_id }} + {{ game.team_1.player_1.first_name }}{{ " " + game.team_1.player_1.last_name }} + {{ game.team_1.player_2.first_name }}{{ " " + game.team_1.player_2.last_name }} + {{ game.team_2.player_1.first_name }}{{ " " + game.team_2.player_1.last_name }} + {{ game.team_2.player_2.first_name }}{{ " " + game.team_2.player_2.last_name }} {% endfor %} diff --git a/app/templates/macros.j2 b/app/templates/macros.j2 index 2974744..a4b2fc1 100644 --- a/app/templates/macros.j2 +++ b/app/templates/macros.j2 @@ -7,7 +7,7 @@ {% macro player_button(player) -%} + hx-swap="outerHTML"> {{ player.first_name + " " + player.last_name}} {%- endmacro %} {% macro guest_control(level, value) -%} diff --git a/app/utilities.py b/app/utilities.py index 00dc14a..c84e1bf 100644 --- a/app/utilities.py +++ b/app/utilities.py @@ -89,7 +89,7 @@ def get_team_games( ) -> list[DisplayGame]: games = [] for court, (_, players) in zip(courts, teams.items()): - players = sample(players, app.config["PLAYER_PER_COURT"]) + players = sample(players, app.config["PLAYERS_PER_COURT"]) players.sort(key=lambda x: x.level, reverse=True) dp = [DisplayPlayer(p) for p in players] games.append( @@ -101,7 +101,7 @@ def get_team_games( def get_shuffle_games( courts: list[Court], players: Sequence[Player], total_levels: int ) -> list[DisplayGame]: - required_player_count = len(courts) * app.config["PLAYER_PER_COURT"] + required_player_count = len(courts) * app.config["PLAYERS_PER_COURT"] selected_players = select_players(players, required_player_count) court_players = group_by_four(selected_players, total_levels) games = [] -- cgit v1.3.1 From ec3af3f76ae1785bfb8c1dfbc69788e95fc7954d Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Mon, 14 Jul 2025 09:23:32 +0200 Subject: Working app, guests implementation pending --- app/config.py | 4 ++-- app/controller.py | 24 +++++++++++++++++------- app/forms.py | 3 ++- app/model.py | 44 ++++++++++++++++++++++++++++++++------------ app/routes.py | 29 ++++++++++++++++++++--------- app/utilities.py | 14 +++++++++++--- 6 files changed, 84 insertions(+), 34 deletions(-) (limited to 'app/model.py') diff --git a/app/config.py b/app/config.py index a6a60be..fa16e1f 100644 --- a/app/config.py +++ b/app/config.py @@ -5,9 +5,9 @@ from os.path import abspath, dirname, join class Config: SECRET_KEY = environ.get("SECRET_KEY") or None DATABASE_CSV = environ.get("DATABASE_CSV") or None - COURT_COUNT = environ.get("COURT_COUNT") or None SQLALCHEMY_DATABASE_URI = "sqlite:///" + join(abspath(dirname(__file__)), "app.db") + COURT_COUNT = 12 PLAYERS_PER_COURT = 4 GUEST_LEVELS = [1, 3, 5] MIN_PLAYER_LEVEL = 1 - MAX_PLAYER_LEVEL = 10 \ No newline at end of file + MAX_PLAYER_LEVEL = 10 diff --git a/app/controller.py b/app/controller.py index 5bdeba7..d8a178f 100644 --- a/app/controller.py +++ b/app/controller.py @@ -2,7 +2,7 @@ from math import floor from app import app, db from datetime import date from operator import attrgetter -from sqlalchemy import select, func, Date +from sqlalchemy import func, Date from app.model import ( Player, Players, @@ -29,6 +29,7 @@ class Session: def __init__(self): with app.app_context(): + games = db.session.query(Game) db.session.query(Game).delete() id = self.players.default_id() player_data = {f"player_{i+1}": id for i in range(4)} @@ -38,9 +39,10 @@ class Session: db.session.commit() def round(self) -> int: - games = db.session.query(Game).filter(func.date(Game.date) == date.today()).all() + games = ( + db.session.query(Game).filter(func.date(Game.date) == date.today()).all() + ) round_id = max([game.round_id for game in games]) - print(round_id) if round_id is None: raise ValueError(f"Database isn't initialized") return round_id @@ -60,7 +62,7 @@ class Session: p = db.session.get(Player, getattr(game, f"player_{i+1}")) if p is None: raise ValueError(f"Player id mismatch.") - dp.append(p) + dp.append(p) t1 = DisplayTeam(DisplayPlayer(dp[0]), DisplayPlayer(dp[1])) t2 = DisplayTeam(DisplayPlayer(dp[2]), DisplayPlayer(dp[3])) db.session.get(Player, game.player_2) @@ -72,8 +74,6 @@ class Session: reserved_games = get_placeholder_games(reserved_courts, "RESERVED") active_courts = self.courts.active() active_players = self.players.active() - for player in active_players: - print(player) game_count = floor(len(active_players) / app.config["PLAYERS_PER_COURT"]) game_count = min(len(active_courts), game_count) used_courts = active_courts[:game_count] @@ -93,8 +93,18 @@ class Session: def confirm(self) -> list[DisplayGame]: if len(self.proposal) == 0: return self.all() + round_number = self.round() + 1 for game in self.proposal: - db.session.add(game) + db.session.add( + Game( + round_number, + game.court_id, + game.team_1.player_1.id, + game.team_1.player_2.id, + game.team_2.player_1.id, + game.team_2.player_2.id, + ) + ) db.session.commit() self.proposal = [] return self.all() diff --git a/app/forms.py b/app/forms.py index 504824a..2e0929a 100644 --- a/app/forms.py +++ b/app/forms.py @@ -2,8 +2,9 @@ from flask_wtf import FlaskForm from wtforms.validators import DataRequired from wtforms import StringField, PasswordField, BooleanField, SubmitField + class LoginForm(FlaskForm): username = StringField("Username", validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()]) remember_me = BooleanField("Remember Me") - submit = SubmitField("Sign In") \ No newline at end of file + submit = SubmitField("Sign In") diff --git a/app/model.py b/app/model.py index 56921f4..2f8c609 100644 --- a/app/model.py +++ b/app/model.py @@ -74,7 +74,15 @@ class Player(db.Model): status: Mapped[bool] = mc(Boolean) level: Mapped[int] = mc(Integer) - def __init__(self, id, first_name, last_name, team = 0, status = False, level = int(app.config["MIN_PLAYER_LEVEL"])): + def __init__( + self, + id, + first_name, + last_name, + team=0, + status=False, + level=int(app.config["MIN_PLAYER_LEVEL"]), + ): self.id = id self.first_name = first_name self.last_name = last_name @@ -124,11 +132,15 @@ class Players: if app.config["DATABASE_CSV"] is not None: data = read_csv(app.config["DATABASE_CSV"]) db.session.query(Player).delete() - db.session.add(Player(**{"id": 0, "first_name": "---", "last_name": ""})) - db.session.add(Player(**{"id": 1, "first_name": "RESERVED", "last_name": ""})) + db.session.add( + Player(**{"id": 0, "first_name": "---", "last_name": ""}) + ) + db.session.add( + Player(**{"id": 1, "first_name": "RESERVED", "last_name": ""}) + ) for entry in data.to_dict("records"): args = entry | {"status": False} - db.session.add(Player(**args)) + db.session.add(Player(**args)) # type: ignore db.session.commit() else: if len(self.all()) == 0: @@ -163,12 +175,13 @@ class Players: return player def all(self) -> Tuple[list[Player], list[Player]]: - regulars = [ - p for p in db.session.query(Player).where(Player.first_name != "Guest") - ] - guests = [ - p for p in db.session.query(Player).where(Player.first_name == "Guest") - ] + players = ( + db.session.query(Player) + .where(Player.id > 1) + .order_by(Player.first_name, Player.last_name) + ) + regulars = [p for p in players if p.first_name != "Guest"] + guests = [p for p in players if p.first_name == "Guest"] guests.sort(key=lambda x: int(x.last_name)) return regulars, guests @@ -196,7 +209,15 @@ class Game(db.Model): player_4: Mapped[int] = mc(ForeignKey(Player.id), index=True) date: Mapped[datetime] = mc(index=True, default=lambda: date.today()) - def __init__(self, round_id: int, court_id: int, player_1: int, player_2: int, player_3: int, player_4: int): + def __init__( + self, + round_id: int, + court_id: int, + player_1: int, + player_2: int, + player_3: int, + player_4: int, + ): self.round_id = round_id self.court_id = court_id self.player_1 = player_1 @@ -205,7 +226,6 @@ class Game(db.Model): self.player_4 = player_4 - class DisplayPlayer: def __init__(self, player: Player): diff --git a/app/routes.py b/app/routes.py index bc85e48..cffbae9 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,14 +1,25 @@ from app import app from app.forms import LoginForm from app.controller import Session -from flask import render_template, request, url_for, Response, flash, redirect, get_template_attribute, render_template_string +from flask import ( + render_template, + request, + url_for, + Response, + flash, + redirect, + get_template_attribute, +) +from flask import render_template_string as render_string g = Session() + @app.route("/", methods=["GET"]) def home(): return render_template("index.j2", url_for=url_for) + @app.route("/login", methods=["GET", "POST"]) def login(): form = LoginForm() @@ -17,6 +28,7 @@ def login(): return redirect("/") return render_template("login.j2", form=form) + @app.route("/control", methods=["GET"]) def get_controls(): return render_template("controls.j2", courts=g.courts.all()) @@ -26,9 +38,7 @@ def get_controls(): def get_random_games(): g.levels = int(request.form["levels"].split()[0]) g.teams = request.form["team"] == "Teams" - return render_template( - "games.j2", games=g.propose(), title="Proposal" - ) + return render_template("games.j2", games=g.propose(), title="Proposal") @app.route("/confirm", methods=["POST"]) @@ -51,14 +61,15 @@ def toggle_player(player_request): id = int(player_request) player = g.players.toggle(id) button = get_template_attribute("macros.j2", "player_button") - t = '{% from "macros.j2" import player_button %}' - return render_template_string("{{player_button(player)}}", player=player, player_button=button) + return render_string("{{button(player)}}", player=player, button=button) @app.route("/players", methods=["GET"]) def get_list_of_players(): regulars, guests = g.players.all() - return render_template("players.j2", regulars=regulars, guests=guests, url_for=url_for) + return render_template( + "players.j2", regulars=regulars, guests=guests, url_for=url_for + ) @app.route("/reset_players", methods=["POST"]) @@ -73,8 +84,8 @@ def reset_all_players(): def toggle_court(court_number): id = int(court_number) status = g.courts.toggle(id) - t = '{% from "macros.j2" import court_button %}{{ court_button(id, status) }}' - return render_template(t, id=id, status=status) + button = get_template_attribute("macros.j2", "court_button") + return render_string("{{button(id, status)}}", id=id, status=status, button=button) @app.route("/courts", methods=["GET"]) diff --git a/app/utilities.py b/app/utilities.py index c84e1bf..133f9e1 100644 --- a/app/utilities.py +++ b/app/utilities.py @@ -1,7 +1,9 @@ from app import app, db +from datetime import date +from sqlalchemy import func from typing import Tuple, Sequence from random import shuffle, sample -from app.model import Player, DisplayPlayer, Court, DisplayGame, DisplayTeam +from app.model import Player, DisplayPlayer, Court, Game, DisplayGame, DisplayTeam def _shuffle_two_lists(a: list, b: list) -> Tuple[list, list]: @@ -26,13 +28,19 @@ def sample_list(indices: list, cost: list[int], count=None) -> list: def _normalize_skill(level: int, new_max: int) -> int: - new_level = round((new_max - 1) * (level / app.config["MAX_PLAYER_LEVEL"])) + 1 + scaling = (new_max - 1) / int(app.config["MAX_PLAYER_LEVEL"]) + new_level = round(scaling * level) + 1 return new_level def select_players(players: Sequence[Player], count: int) -> list[Player]: + games = db.session.query(Game).filter(func.date(Game.date) == date.today()).all() + player_identifiers = [] + for game in games: + for i in range(4): + player_identifiers.append(getattr(game, f"player_{i+1}")) identifiers = [player.id for player in players] - play_count = [int(player.id) for player in players] + play_count = [player_identifiers.count(player.id) for player in players] ids = sample_list(identifiers, play_count, count) selected_players = [player for player in players if player.id in ids] return selected_players -- cgit v1.3.1 From 729203409fb73fdbac19b8af9d647883a5ac96b0 Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Mon, 14 Jul 2025 12:25:55 +0200 Subject: Back to working --- app/config.py | 5 +- app/controller.py | 173 ++++++++++++++++++++++++++++----------- app/model.py | 207 ++++++++--------------------------------------- app/routes.py | 34 +++++++- app/static/custom.js | 10 ++- app/templates/games.j2 | 12 +-- app/templates/players.j2 | 17 ++++ app/utilities.py | 68 +++++++++++++--- 8 files changed, 286 insertions(+), 240 deletions(-) (limited to 'app/model.py') diff --git a/app/config.py b/app/config.py index fa16e1f..d7509a1 100644 --- a/app/config.py +++ b/app/config.py @@ -4,10 +4,11 @@ from os.path import abspath, dirname, join class Config: SECRET_KEY = environ.get("SECRET_KEY") or None - DATABASE_CSV = environ.get("DATABASE_CSV") or None SQLALCHEMY_DATABASE_URI = "sqlite:///" + join(abspath(dirname(__file__)), "app.db") COURT_COUNT = 12 PLAYERS_PER_COURT = 4 - GUEST_LEVELS = [1, 3, 5] MIN_PLAYER_LEVEL = 1 MAX_PLAYER_LEVEL = 10 + DEFAULTS = {"---": 1, "RESERVED": 2} + GUEST_COUNT = 4 + GUESTS = {"Beginner": 1, "Novice": 3, "Intermediate": 5} \ No newline at end of file diff --git a/app/controller.py b/app/controller.py index d8a178f..1c2c5bf 100644 --- a/app/controller.py +++ b/app/controller.py @@ -1,15 +1,14 @@ from math import floor from app import app, db from datetime import date +from typing import Sequence +from pandas import DataFrame from operator import attrgetter -from sqlalchemy import func, Date +from sqlalchemy import func, select, not_ from app.model import ( Player, - Players, - Courts, + Court, Game, - DisplayPlayer, - DisplayTeam, DisplayGame, ) from app.utilities import ( @@ -17,9 +16,103 @@ from app.utilities import ( get_team_games, get_shuffle_games, separate_teams_and_players, + initialize_random_database, + initialize_default_players, ) +class Courts: + + def __init__(self): + with app.app_context(): + courts = db.session.query(Court).all() + if len(courts) == 0: + for _ in range(app.config["COURT_COUNT"]): + db.session.add(Court()) + db.session.commit() + + def toggle(self, id: int) -> bool: + court = db.session.get(Court, id) + if court is None: + raise ValueError(f"Court {id} doesn't exist.") + new_status = not court.status + court.status = new_status + db.session.commit() + return new_status + + def active(self): + return db.session.query(Court).where(Court.status).all() + + def inactive(self): + return db.session.query(Court).where(not_(Court.status)).all() + + def all(self) -> list[Court]: + return db.session.query(Court).order_by(Court.id).all() + + +class Players: + + def __init__(self) -> None: + with app.app_context(): + if len(self.regulars()) == 0: + initialize_random_database() + + def update(self, data: DataFrame): + initialize_default_players() + for entry in data.to_dict("records"): + args = entry | {"status": False} + db.session.add(Player(**args)) # type: ignore + db.session.commit() + + def get(self, id: int) -> bool: + player = db.session.get(Player, id) + if player is None: + raise ValueError(f"User {id} doesn't exist.") + return player.status + + def toggle(self, id: int) -> Player: + player = db.session.get(Player, id) + if player is None: + raise ValueError(f"User {id} doesn't exist.") + player.status = not player.status + db.session.commit() + return player + + def regulars(self) -> list[Player]: + filter_list = list(app.config["GUESTS"].keys()) + list( + app.config["DEFAULTS"].keys() + ) + return ( + db.session.query(Player) + .where(Player.id > 1) + .where(Player.first_name.not_in(filter_list)) + .order_by(Player.first_name, Player.last_name) + .all() + ) + + def guests(self) -> list[Player]: + return ( + db.session.query(Player) + .where(Player.id > 1) + .where(Player.first_name.in_(app.config["GUESTS"].keys())) + .order_by(Player.first_name, Player.last_name) + .all() + ) + + def active(self) -> Sequence[Player]: + query = ( + select(Player) + .where(Player.status) + .order_by(Player.first_name, Player.last_name) + ) + return db.session.scalars(query).all() + + def reset(self): + for player in db.session.scalars(select(Player)): + player.status = False + db.session.commit() + + class Session: levels: int = 10 teams: bool = False @@ -27,27 +120,33 @@ class Session: courts: Courts = Courts() players: Players = Players() + def _today(self): + games = ( + db.session.query(Game).filter(func.date(Game.date) == date.today()).all() + ) + return games + def __init__(self): with app.app_context(): - games = db.session.query(Game) - db.session.query(Game).delete() - id = self.players.default_id() - player_data = {f"player_{i+1}": id for i in range(4)} - for court in self.courts.all(): - args = {"round_id": 0, "court_id": court.id} | player_data - db.session.add(Game(**args)) - db.session.commit() + if len(self._today()) == 0: + query = select(Player).where(Player.first_name == "---") + player = db.session.scalars(query).first() + if player is None: + raise ValueError(f"Database isn't initialized.") + player_data = {f"player_{i+1}": player.id for i in range(4)} + for court in self.courts.all(): + args = {"round_id": 0, "court_id": court.id} | player_data + db.session.add(Game(**args)) + db.session.commit() def round(self) -> int: - games = ( - db.session.query(Game).filter(func.date(Game.date) == date.today()).all() - ) + games = self._today() round_id = max([game.round_id for game in games]) if round_id is None: raise ValueError(f"Database isn't initialized") return round_id - def all(self) -> list[DisplayGame]: + def current_round(self) -> list[DisplayGame]: games = ( db.session.query(Game) .filter(func.date(Game.date) == date.today()) @@ -55,22 +154,11 @@ class Session: .order_by(Game.court_id) .all() ) - dg = [] - for game in games: - dp = [] - for i in range(4): - p = db.session.get(Player, getattr(game, f"player_{i+1}")) - if p is None: - raise ValueError(f"Player id mismatch.") - dp.append(p) - t1 = DisplayTeam(DisplayPlayer(dp[0]), DisplayPlayer(dp[1])) - t2 = DisplayTeam(DisplayPlayer(dp[2]), DisplayPlayer(dp[3])) - db.session.get(Player, game.player_2) - dg.append(DisplayGame(game.court_id, t1, t2)) - return dg + games = [DisplayGame(game) for game in games] + return games def propose(self) -> list[DisplayGame]: - reserved_courts = [court for court in self.courts.all() if not court.status] + reserved_courts = self.courts.inactive() reserved_games = get_placeholder_games(reserved_courts, "RESERVED") active_courts = self.courts.active() active_players = self.players.active() @@ -87,30 +175,21 @@ class Session: team_games = get_team_games(team_courts, active_teams) shuffle_games = get_shuffle_games(shuffle_courts, active_players, self.levels) self.proposal = shuffle_games + team_games + blank_games + reserved_games - self.proposal.sort(key=attrgetter("court_id")) + self.proposal.sort(key=attrgetter("court.id")) return self.proposal def confirm(self) -> list[DisplayGame]: if len(self.proposal) == 0: - return self.all() - round_number = self.round() + 1 + return self.current_round() + round_id = self.round() + 1 for game in self.proposal: - db.session.add( - Game( - round_number, - game.court_id, - game.team_1.player_1.id, - game.team_1.player_2.id, - game.team_2.player_1.id, - game.team_2.player_2.id, - ) - ) + db.session.add(game.to_game(round_id)) db.session.commit() self.proposal = [] - return self.all() + return self.current_round() def reset(self) -> list[DisplayGame]: - db.session.query(Game).filter(Game.date.cast(Date) == date.today()).delete() + db.session.query(Game).filter(func.date(Game.date) == date.today()).delete() db.session.commit() self.__init__() - return self.all() + return self.current_round() diff --git a/app/model.py b/app/model.py index 2f8c609..3ec663e 100644 --- a/app/model.py +++ b/app/model.py @@ -1,14 +1,9 @@ -from math import floor from app import app, db -from pandas import read_csv -from dataclasses import dataclass from sqlalchemy.orm import Mapped from datetime import date, datetime -from random import randint, getrandbits -from typing import Tuple, Optional, Sequence +from typing import Optional, Annotated from sqlalchemy.orm import mapped_column as mc -from names import get_first_name, get_last_name -from sqlalchemy import String, Boolean, Integer, ForeignKey, select +from sqlalchemy import String, Boolean, Integer, ForeignKey class User(db.Model): @@ -28,48 +23,10 @@ class Court(db.Model): return f"Court {self.id} is {"active" if self.status else "inactive"}" -class Courts: - - def __init__(self): - with app.app_context(): - if app.config["COURT_COUNT"] is not None: - db.session.query(Court).delete() - for i in range(int(app.config["COURT_COUNT"])): - db.session.add(Court()) - db.session.commit() - - def get(self, id: int) -> bool: - court = db.session.get(Court, id) - if court is None: - raise ValueError(f"Court {id} doesn't exist.") - return court.status - - def toggle(self, id: int) -> bool: - court = db.session.get(Court, id) - if court is None: - raise ValueError(f"Court {id} doesn't exist.") - new_status = not court.status - court.status = new_status - db.session.commit() - return new_status - - def active(self): - return db.session.query(Court).where(Court.status).all() - - def all(self) -> list[Court]: - courts = [court for court in db.session.query(Court).order_by(Court.id)] - return courts - - def reset(self): - for court in db.session.scalars(select(Court)): - court.status = True - db.session.commit() - - class Player(db.Model): id: Mapped[int] = mc(primary_key=True) - first_name: Mapped[str] = mc(String(64), index=True, default="---") - last_name: Mapped[str] = mc(String(64), index=True, default="---") + first_name: Mapped[str] = mc(String(64), index=True) + last_name: Mapped[str] = mc(String(64), index=True) team: Mapped[int] = mc(Integer, index=True) status: Mapped[bool] = mc(Boolean) level: Mapped[int] = mc(Integer) @@ -94,111 +51,6 @@ class Player(db.Model): return f"{self.first_name} {self.last_name} is {"active" if self.status else "inactive"}" -def initialize_random_database( - player_count: int = 80, guest_per_level: int = 4 -) -> None: - db.session.add(Player(**{"id": 0, "first_name": "---", "last_name": ""})) - db.session.add(Player(**{"id": 1, "first_name": "RESERVED", "last_name": ""})) - total_teams = 5 - players_per_team = 5 - for i in range(player_count): - if i < total_teams * players_per_team: - team = floor(i / players_per_team) + 1 - else: - team = 0 - args = { - "first_name": get_first_name(), - "last_name": get_last_name(), - "team": team, - "status": bool(getrandbits(1)), - "level": randint( - app.config["MIN_PLAYER_LEVEL"], app.config["MAX_PLAYER_LEVEL"] - ), - } - db.session.add(Player(**args)) - meta_data = {"first_name": "Guest", "team": 0, "status": False} - for level_id, level in enumerate(app.config["GUEST_LEVELS"]): - for player_id in range(guest_per_level): - unique_id = (level_id * guest_per_level) + player_id + 1 - args = meta_data | {"last_name": str(unique_id), "level": level} - db.session.add(Player(**args)) - db.session.commit() - - -class Players: - - def __init__(self) -> None: - with app.app_context(): - if app.config["DATABASE_CSV"] is not None: - data = read_csv(app.config["DATABASE_CSV"]) - db.session.query(Player).delete() - db.session.add( - Player(**{"id": 0, "first_name": "---", "last_name": ""}) - ) - db.session.add( - Player(**{"id": 1, "first_name": "RESERVED", "last_name": ""}) - ) - for entry in data.to_dict("records"): - args = entry | {"status": False} - db.session.add(Player(**args)) # type: ignore - db.session.commit() - else: - if len(self.all()) == 0: - initialize_random_database() - - def default_id(self): - query = select(Player).where(Player.first_name == "---") - player = db.session.scalars(query).first() - if player is None: - raise ValueError(f"Database isn't initialized.") - return player.id - - def reserved_id(self): - query = select(Player).where(Player.first_name == "RESERVED") - player = db.session.scalars(query).first() - if player is None: - raise ValueError(f"Database isn't initialized.") - return player.id - - def get(self, id: int) -> bool: - player = db.session.get(Player, id) - if player is None: - raise ValueError(f"User {id} doesn't exist.") - return player.status - - def toggle(self, id: int) -> Player: - player = db.session.get(Player, id) - if player is None: - raise ValueError(f"User {id} doesn't exist.") - player.status = not player.status - db.session.commit() - return player - - def all(self) -> Tuple[list[Player], list[Player]]: - players = ( - db.session.query(Player) - .where(Player.id > 1) - .order_by(Player.first_name, Player.last_name) - ) - regulars = [p for p in players if p.first_name != "Guest"] - guests = [p for p in players if p.first_name == "Guest"] - guests.sort(key=lambda x: int(x.last_name)) - return regulars, guests - - def active(self) -> Sequence[Player]: - query = ( - select(Player) - .where(Player.status) - .order_by(Player.first_name, Player.last_name) - ) - return db.session.scalars(query).all() - - def reset(self): - for player in db.session.scalars(select(Player)): - player.status = False - db.session.commit() - - class Game(db.Model): id: Mapped[int] = mc(primary_key=True) round_id: Mapped[int] = mc(Integer, index=True) @@ -211,14 +63,15 @@ class Game(db.Model): def __init__( self, - round_id: int, + round_id: Optional[int], court_id: int, player_1: int, player_2: int, player_3: int, player_4: int, ): - self.round_id = round_id + if round_id is not None: + self.round_id = round_id self.court_id = court_id self.player_1 = player_1 self.player_2 = player_2 @@ -226,22 +79,32 @@ class Game(db.Model): self.player_4 = player_4 -class DisplayPlayer: - - def __init__(self, player: Player): - self.id = player.id - self.first_name = player.first_name - self.last_name = player.last_name - - -@dataclass -class DisplayTeam: - player_1: DisplayPlayer - player_2: DisplayPlayer - - -@dataclass class DisplayGame: - court_id: int - team_1: DisplayTeam - team_2: DisplayTeam + court: Court + team_1: Annotated[list[Player], 2] + team_2: Annotated[list[Player], 2] + + def __init__(self, game: Game): + court = db.session.get(Court, game.court_id) + if court is None: + raise ValueError(f"Unknown court") + self.court = court + players = [] + for i in range(4): + player_id = getattr(game, f"player_{i+1}") + p = db.session.get(Player, player_id) + if p is None: + raise ValueError(f"Player ID: {player_id} mismatch.") + players.append(p) + self.team_1 = players[:2] + self.team_2 = players[2:] + + def to_game(self, round_id) -> Game: + return Game( + round_id, + self.court.id, + self.team_1[0].id, + self.team_1[1].id, + self.team_2[0].id, + self.team_2[1].id, + ) diff --git a/app/routes.py b/app/routes.py index cffbae9..ce9b714 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,6 +1,8 @@ from app import app +from pandas import read_csv from app.forms import LoginForm from app.controller import Session +from werkzeug.utils import secure_filename from flask import ( render_template, request, @@ -48,7 +50,9 @@ def confirm_games(): @app.route("/clear", methods=["POST"]) def clear_games(): - return render_template("games.j2", games=g.all(), title=f"Round: {g.round()}") + return render_template( + "games.j2", games=g.current_round(), title=f"Round: {g.round()}" + ) @app.route("/reset", methods=["POST"]) @@ -66,9 +70,11 @@ def toggle_player(player_request): @app.route("/players", methods=["GET"]) def get_list_of_players(): - regulars, guests = g.players.all() return render_template( - "players.j2", regulars=regulars, guests=guests, url_for=url_for + "players.j2", + regulars=g.players.regulars(), + guests=g.players.guests(), + url_for=url_for, ) @@ -79,6 +85,28 @@ def reset_all_players(): response.headers["HX-Refresh"] = "true" return response +@app.route("/update_players", methods=["POST"]) +def update_all_players(): + file = request.files.get("players") + response = Response("File name or data has issues!", 400) + response.headers["HX-Refresh"] = "true" + if file is None: + return response + elif file.filename is None: + return response + file_name = secure_filename(file.filename) + try: + data = read_csv(file_name) + required_columns = ["id","first_name","last_name","level","team"] + if not all([item in data.columns for item in required_columns]): + raise ImportError(f"All columns aren't available in the data.") + except: + return response + g.players.update(data) + response = Response("Players updated!") + response.headers["HX-Refresh"] = "true" + return response + @app.route("/court-toggle/", methods=["POST"]) def toggle_court(court_number): diff --git a/app/static/custom.js b/app/static/custom.js index 2e4b049..47f64ca 100644 --- a/app/static/custom.js +++ b/app/static/custom.js @@ -84,4 +84,12 @@ document.getElementById("timer-dec").addEventListener('click', ()=> { minsPerRound -= 1; resetTimer(); } -}); \ No newline at end of file +}); + +const fileInput = document.querySelector("#file-js input[type=file]"); + fileInput.onchange = () => { + if (fileInput.files.length > 0) { + const fileName = document.querySelector("#file-js .file-name"); + fileName.textContent = fileInput.files[0].name; + } +}; \ No newline at end of file diff --git a/app/templates/games.j2 b/app/templates/games.j2 index ccfd395..dff1e0f 100644 --- a/app/templates/games.j2 +++ b/app/templates/games.j2 @@ -9,12 +9,12 @@ {% for game in games %} - - {{ game.court_id }} - {{ game.team_1.player_1.first_name }}{{ " " + game.team_1.player_1.last_name }} - {{ game.team_1.player_2.first_name }}{{ " " + game.team_1.player_2.last_name }} - {{ game.team_2.player_1.first_name }}{{ " " + game.team_2.player_1.last_name }} - {{ game.team_2.player_2.first_name }}{{ " " + game.team_2.player_2.last_name }} + + {{ game.court.id }} + {{ game.team_1[0].first_name }}{{ " " + game.team_1[0].last_name }} + {{ game.team_1[1].first_name }}{{ " " + game.team_1[1].last_name }} + {{ game.team_2[0].first_name }}{{ " " + game.team_2[0].last_name }} + {{ game.team_2[1].first_name }}{{ " " + game.team_2[1].last_name }} {% endfor %} diff --git a/app/templates/players.j2 b/app/templates/players.j2 index 0beeee9..16e3878 100644 --- a/app/templates/players.j2 +++ b/app/templates/players.j2 @@ -25,6 +25,23 @@ +
+
+
+ + +
+
+
{{ footer() }} diff --git a/app/utilities.py b/app/utilities.py index 133f9e1..cd75d2a 100644 --- a/app/utilities.py +++ b/app/utilities.py @@ -1,9 +1,12 @@ +from math import floor from app import app, db from datetime import date from sqlalchemy import func from typing import Tuple, Sequence from random import shuffle, sample -from app.model import Player, DisplayPlayer, Court, Game, DisplayGame, DisplayTeam +from random import randint, getrandbits +from names import get_first_name, get_last_name +from app.model import Player, Court, Game, DisplayGame def _shuffle_two_lists(a: list, b: list) -> Tuple[list, list]: @@ -87,8 +90,10 @@ def get_placeholder_games(courts: list[Court], name: str) -> list[DisplayGame]: p = db.session.query(Player).where(Player.first_name == name).first() if p is None: raise ValueError(f"Database isn't initialized.") - dummy_team = DisplayTeam(DisplayPlayer(p), DisplayPlayer(p)) - games = [DisplayGame(court.id, dummy_team, dummy_team) for court in courts] + player_data = {"round_id": None} | {f"player_{i+1}": p.id for i in range(4)} + games = [ + DisplayGame(Game(**({"court_id": court.id} | player_data))) for court in courts # type: ignore + ] return games @@ -99,10 +104,10 @@ def get_team_games( for court, (_, players) in zip(courts, teams.items()): players = sample(players, app.config["PLAYERS_PER_COURT"]) players.sort(key=lambda x: x.level, reverse=True) - dp = [DisplayPlayer(p) for p in players] - games.append( - DisplayGame(court.id, DisplayTeam(dp[0], dp[3]), DisplayTeam(dp[1], dp[2])) + game = Game( + None, court.id, players[0].id, players[3].id, players[1].id, players[2].id ) + games.append(DisplayGame(game)) return games @@ -114,8 +119,53 @@ def get_shuffle_games( court_players = group_by_four(selected_players, total_levels) games = [] for court, players in zip(courts, court_players): - dp = [DisplayPlayer(p) for p in players] - games.append( - DisplayGame(court.id, DisplayTeam(dp[0], dp[3]), DisplayTeam(dp[1], dp[2])) + game = Game( + None, court.id, players[0].id, players[3].id, players[1].id, players[2].id ) + games.append(DisplayGame(game)) return games + + +def initialize_default_players(): + db.session.query(Player).delete() + for k, v in app.config["DEFAULTS"].items(): + db.session.add(Player(**{"id": v, "first_name": k, "last_name": ""})) + guest_offset = 10000 + for i, (k, v) in enumerate(app.config["GUESTS"].items()): + level_offset = app.config["GUEST_COUNT"] * i + for j in range(app.config["GUEST_COUNT"]): + db.session.add( + Player( + **{ + "id": guest_offset + level_offset + j, + "first_name": k, + "last_name": f"{j + 1}", + "level": v, + } + ) + ) + db.session.commit() + + +def initialize_random_database(): + initialize_default_players() + player_count = 80 + total_teams = 5 + players_per_team = 5 + for i in range(player_count): + if i < total_teams * players_per_team: + team = floor(i / players_per_team) + 1 + else: + team = 0 + args = { + "id": 3 + i, + "first_name": get_first_name(), + "last_name": get_last_name(), + "team": team, + "status": bool(getrandbits(1)), + "level": randint( + app.config["MIN_PLAYER_LEVEL"], app.config["MAX_PLAYER_LEVEL"] + ), + } + db.session.add(Player(**args)) + db.session.commit() -- cgit v1.3.1