diff options
| author | Karan Jayachandra <mail@karanjayachandra.com> | 2025-07-14 13:33:24 +0200 |
|---|---|---|
| committer | Karan Jayachandra <mail@karanjayachandra.com> | 2025-07-14 13:33:24 +0200 |
| commit | 7e4c451c052c9fc33136d184cdedec251f9ef6fd (patch) | |
| tree | 5cef1d2b3f7bb61551dd105974e66b11b81fff98 | |
| parent | 078e792c73f1583433c745695a3b1a9630cedeb3 (diff) | |
| parent | 729203409fb73fdbac19b8af9d647883a5ac96b0 (diff) | |
Merge branch 'develop' into 'main'
Develop
See merge request KaranJayachandra/match_up!14
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | app/__init__.py | 11 | ||||
| -rw-r--r-- | app/config.py | 14 | ||||
| -rw-r--r-- | app/controller.py | 195 | ||||
| -rw-r--r-- | app/forms.py | 10 | ||||
| -rw-r--r-- | app/model.py | 110 | ||||
| -rw-r--r-- | app/routes.py | 121 | ||||
| -rw-r--r-- | app/static/bulma.min.css (renamed from src/match_up/static/bulma.min.css) | 0 | ||||
| -rw-r--r-- | app/static/custom.css (renamed from src/match_up/static/custom.css) | 0 | ||||
| -rw-r--r-- | app/static/custom.js (renamed from src/match_up/static/custom.js) | 10 | ||||
| -rw-r--r-- | app/static/favicon.ico (renamed from src/match_up/static/favicon.ico) | bin | 15406 -> 15406 bytes | |||
| -rw-r--r-- | app/static/fontawesome.js (renamed from src/match_up/static/fontawesome.js) | 0 | ||||
| -rw-r--r-- | app/static/htmx.min.js (renamed from src/match_up/static/htmx.min.js) | 0 | ||||
| -rw-r--r-- | app/static/logo.png (renamed from src/match_up/static/logo.png) | 0 | ||||
| -rw-r--r-- | app/static/notyf.min.css (renamed from src/match_up/static/notyf.min.css) | 0 | ||||
| -rw-r--r-- | app/static/notyf.min.js (renamed from src/match_up/static/notyf.min.js) | 0 | ||||
| -rw-r--r-- | app/templates/controls.j2 (renamed from src/match_up/templates/controls.j2) | 4 | ||||
| -rw-r--r-- | app/templates/games.j2 | 21 | ||||
| -rw-r--r-- | app/templates/index.j2 (renamed from src/match_up/templates/index.j2) | 9 | ||||
| -rw-r--r-- | app/templates/login.j2 | 25 | ||||
| -rw-r--r-- | app/templates/macros.j2 (renamed from src/match_up/templates/macros.j2) | 2 | ||||
| -rw-r--r-- | app/templates/players.j2 (renamed from src/match_up/templates/players.j2) | 17 | ||||
| -rw-r--r-- | app/utilities.py | 171 | ||||
| -rw-r--r-- | matchup.py | 1 | ||||
| -rw-r--r-- | migrations/README | 1 | ||||
| -rw-r--r-- | migrations/alembic.ini | 50 | ||||
| -rw-r--r-- | migrations/env.py | 113 | ||||
| -rw-r--r-- | migrations/script.py.mako | 24 | ||||
| -rw-r--r-- | migrations/versions/c08245a9c93f_.py | 87 | ||||
| -rw-r--r-- | migrations/versions/d158cf8f4950_.py | 39 | ||||
| -rw-r--r-- | src/match_up/__init__.py | 26 | ||||
| -rw-r--r-- | src/match_up/controller.py | 99 | ||||
| -rw-r--r-- | src/match_up/data.py | 76 | ||||
| -rw-r--r-- | src/match_up/model.py | 174 | ||||
| -rw-r--r-- | src/match_up/routes.py | 80 | ||||
| -rw-r--r-- | src/match_up/templates/games.j2 | 21 | ||||
| -rw-r--r-- | src/match_up/utilities.py | 123 |
37 files changed, 1035 insertions, 604 deletions
@@ -172,4 +172,7 @@ data .DS_Store # Databases -*.db
\ No newline at end of file +*.db + +# Environmental variables +*.flaskenv
\ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2e0a8be --- /dev/null +++ b/app/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..d7509a1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,14 @@ +from os import environ +from os.path import abspath, dirname, join + + +class Config: + SECRET_KEY = environ.get("SECRET_KEY") or None + SQLALCHEMY_DATABASE_URI = "sqlite:///" + join(abspath(dirname(__file__)), "app.db") + COURT_COUNT = 12 + PLAYERS_PER_COURT = 4 + 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 new file mode 100644 index 0000000..1c2c5bf --- /dev/null +++ b/app/controller.py @@ -0,0 +1,195 @@ +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, select, not_ +from app.model import ( + Player, + Court, + Game, + DisplayGame, +) +from app.utilities import ( + get_placeholder_games, + 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 + proposal: list[DisplayGame] = [] + 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(): + 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 = 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 current_round(self) -> list[DisplayGame]: + games = ( + db.session.query(Game) + .filter(func.date(Game.date) == date.today()) + .filter(Game.round_id == self.round()) + .order_by(Game.court_id) + .all() + ) + games = [DisplayGame(game) for game in games] + return games + + def propose(self) -> list[DisplayGame]: + reserved_courts = self.courts.inactive() + 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["PLAYERS_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[DisplayGame]: + if len(self.proposal) == 0: + return self.current_round() + round_id = self.round() + 1 + for game in self.proposal: + db.session.add(game.to_game(round_id)) + db.session.commit() + self.proposal = [] + return self.current_round() + + def reset(self) -> list[DisplayGame]: + db.session.query(Game).filter(func.date(Game.date) == date.today()).delete() + db.session.commit() + self.__init__() + return self.current_round() diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..2e0929a --- /dev/null +++ b/app/forms.py @@ -0,0 +1,10 @@ +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") diff --git a/app/model.py b/app/model.py new file mode 100644 index 0000000..3ec663e --- /dev/null +++ b/app/model.py @@ -0,0 +1,110 @@ +from app import app, db +from sqlalchemy.orm import Mapped +from datetime import date, datetime +from typing import Optional, Annotated +from sqlalchemy.orm import mapped_column as mc +from sqlalchemy import String, Boolean, Integer, ForeignKey + + +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 __repr__(self): + return f"Court {self.id} is {"active" if self.status else "inactive"}" + + +class Player(db.Model): + id: Mapped[int] = mc(primary_key=True) + 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) + + 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"}" + + +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__( + self, + round_id: Optional[int], + court_id: int, + player_1: int, + player_2: int, + player_3: int, + player_4: int, + ): + 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 + self.player_3 = player_3 + self.player_4 = player_4 + + +class DisplayGame: + 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 new file mode 100644 index 0000000..ce9b714 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,121 @@ +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, + 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() + if form.validate_on_submit(): + flash(f"User {form.username} is requesting to 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()) + + +@app.route("/propose", methods=["POST"]) +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") + + +@app.route("/confirm", methods=["POST"]) +def confirm_games(): + 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=g.current_round(), title=f"Round: {g.round()}" + ) + + +@app.route("/reset", methods=["POST"]) +def reset_games(): + return render_template("games.j2", games=g.reset(), title=f"Round: {g.round()}") + + +@app.route("/player-toggle/<player_request>", methods=["POST"]) +def toggle_player(player_request): + id = int(player_request) + player = g.players.toggle(id) + button = get_template_attribute("macros.j2", "player_button") + return render_string("{{button(player)}}", player=player, button=button) + + +@app.route("/players", methods=["GET"]) +def get_list_of_players(): + return render_template( + "players.j2", + regulars=g.players.regulars(), + guests=g.players.guests(), + url_for=url_for, + ) + + +@app.route("/reset_players", methods=["POST"]) +def reset_all_players(): + g.players.reset() + response = Response("Players reset!") + 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/<court_number>", methods=["POST"]) +def toggle_court(court_number): + id = int(court_number) + status = g.courts.toggle(id) + 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"]) +def get_list_of_courts(): + return render_template("courts.j2", courts=g.courts.all()) diff --git a/src/match_up/static/bulma.min.css b/app/static/bulma.min.css index eaa3fb5..eaa3fb5 100644 --- a/src/match_up/static/bulma.min.css +++ b/app/static/bulma.min.css diff --git a/src/match_up/static/custom.css b/app/static/custom.css index fafc51a..fafc51a 100644 --- a/src/match_up/static/custom.css +++ b/app/static/custom.css diff --git a/src/match_up/static/custom.js b/app/static/custom.js index 2e4b049..47f64ca 100644 --- a/src/match_up/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/src/match_up/static/favicon.ico b/app/static/favicon.ico Binary files differindex 64eab0a..64eab0a 100644 --- a/src/match_up/static/favicon.ico +++ b/app/static/favicon.ico diff --git a/src/match_up/static/fontawesome.js b/app/static/fontawesome.js index b08e9a6..b08e9a6 100644 --- a/src/match_up/static/fontawesome.js +++ b/app/static/fontawesome.js diff --git a/src/match_up/static/htmx.min.js b/app/static/htmx.min.js index de5f0f1..de5f0f1 100644 --- a/src/match_up/static/htmx.min.js +++ b/app/static/htmx.min.js diff --git a/src/match_up/static/logo.png b/app/static/logo.png index 7d2d8c7..7d2d8c7 100644 --- a/src/match_up/static/logo.png +++ b/app/static/logo.png diff --git a/src/match_up/static/notyf.min.css b/app/static/notyf.min.css index dbb5a16..dbb5a16 100644 --- a/src/match_up/static/notyf.min.css +++ b/app/static/notyf.min.css diff --git a/src/match_up/static/notyf.min.js b/app/static/notyf.min.js index f85c84f..f85c84f 100644 --- a/src/match_up/static/notyf.min.js +++ b/app/static/notyf.min.js diff --git a/src/match_up/templates/controls.j2 b/app/templates/controls.j2 index 0183c97..eba086d 100644 --- a/src/match_up/templates/controls.j2 +++ b/app/templates/controls.j2 @@ -1,8 +1,8 @@ {% from "macros.j2" import court_button %} <div class="fixed-grid has-6-cols"> <div class="grid"> - {% for key, value in courts.items() %} - {{ court_button(key, value) }} + {% for court in courts %} + {{ court_button(court.id, court.status) }} {% endfor %} </div> </div> diff --git a/app/templates/games.j2 b/app/templates/games.j2 new file mode 100644 index 0000000..dff1e0f --- /dev/null +++ b/app/templates/games.j2 @@ -0,0 +1,21 @@ +<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth" id="games"> +<thead> + <th colspan="5" align="center"> {{ title }} </th> +</thead> +<thead> + <th align="center"> Court </th> + <th colspan="2" align="center"> Team 1 </th> + <th colspan="2" align="center"> Team 2 </th> +</thead> +<tbody> +{% for game in games %} + <tr {% if game.team_1[0].first_name == 'RESERVED' %} class="is-danger" {% endif %}> + <th align="center" style="width: 8%;">{{ game.court.id }}</th> + <td style="width: 23%;"><strong>{{ game.team_1[0].first_name }}</strong>{{ " " + game.team_1[0].last_name }}</th> + <td style="width: 23%;"><strong>{{ game.team_1[1].first_name }}</strong>{{ " " + game.team_1[1].last_name }}</th> + <td style="width: 23%;"><strong>{{ game.team_2[0].first_name }}</strong>{{ " " + game.team_2[0].last_name }}</th> + <td style="width: 23%;"><strong>{{ game.team_2[1].first_name }}</strong>{{ " " + game.team_2[1].last_name }}</th> + </tr> +{% endfor %} +</tbody> +</table>
\ No newline at end of file diff --git a/src/match_up/templates/index.j2 b/app/templates/index.j2 index ce0d531..656b187 100644 --- a/src/match_up/templates/index.j2 +++ b/app/templates/index.j2 @@ -7,6 +7,15 @@ {{ navbar(url_for) }} <div hx-post="/clear" hx-swap="outerHTML" hx-trigger="load"></div> <div hx-get="/control" hx-swap="outerHTML" hx-trigger="load"></div> + {% with messages = get_flashed_messages() %} + {% if messages %} + <ul> + {% for message in messages %} + <li>{{ message }}</li> + {% endfor %} + </ul> + {% endif %} + {% endwith %} {{ footer() }} </section> </body> diff --git a/app/templates/login.j2 b/app/templates/login.j2 new file mode 100644 index 0000000..fe898cb --- /dev/null +++ b/app/templates/login.j2 @@ -0,0 +1,25 @@ +{% from "macros.j2" import header, footer, navbar %} +<!doctype html> +<html lang="en"> + {{ header(url_for) }} + <body> + <section class="section"> + {{ navbar(url_for) }} + <h1>Sign In</h1> + <form action="" method="post" novalidate> + {{ form.hidden_tag() }} + <p> + {{ form.username.label }}<br> + {{ form.username(size=32) }} + </p> + <p> + {{ form.password.label }}<br> + {{ form.password(size=32) }} + </p> + <p>{{ form.remember_me() }} {{ form.remember_me.label }}</p> + <p>{{ form.submit() }}</p> + </form> + {{ footer() }} + </section> + </body> +</html> diff --git a/src/match_up/templates/macros.j2 b/app/templates/macros.j2 index 2974744..a4b2fc1 100644 --- a/src/match_up/templates/macros.j2 +++ b/app/templates/macros.j2 @@ -7,7 +7,7 @@ {% macro player_button(player) -%} <button class="button is-responsive {% if player.status %}is-success{% else %}is-danger {% endif %}" hx-post="{{ "/player-toggle/" ~ player.id}}" - hx-swap="outerHTML"> {{ player.first + " " + player.last}} </button> + hx-swap="outerHTML"> {{ player.first_name + " " + player.last_name}} </button> {%- endmacro %} {% macro guest_control(level, value) -%} diff --git a/src/match_up/templates/players.j2 b/app/templates/players.j2 index 0beeee9..16e3878 100644 --- a/src/match_up/templates/players.j2 +++ b/app/templates/players.j2 @@ -25,6 +25,23 @@ <button class="button is-dark is-responsive" hx-post="/reset_players">RESET!</button> </div> + <div class="column"> + <form method="POST" enctype="multipart/form-data" hx-post="/update_players"> + <div id="file-js" class="file has-name is-fullwidth"> + <label class="file-label"> + <input class="file-input" type="file" name="players" /> + <span class="file-cta"> + <span class="file-icon"> + <i class="fas fa-upload"></i> + </span> + <span class="file-label"> Choose a file⦠</span> + </span> + <span class="file-name"> No file uploaded </span> + </label> + <button class="button is-dark is-responsive">UPDATE!</button> + </div> + </form> + </div> {{ footer() }} </section> </body> diff --git a/app/utilities.py b/app/utilities.py new file mode 100644 index 0000000..cd75d2a --- /dev/null +++ b/app/utilities.py @@ -0,0 +1,171 @@ +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 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]: + indices = list(range(len(a))) + shuffle(indices) + a = [a[index] for index in indices] + b = [b[index] for index in indices] + return a, b + + +def _pick_from_list_after_sorting_other(a: list, b: list[int]) -> list: + indices = [i[0] for i in sorted(enumerate(b), key=lambda x: x[1])] + return [a[index] for index in indices] + + +def sample_list(indices: list, cost: list[int], count=None) -> list: + if count is None: + count = len(indices) + indices, cost = _shuffle_two_lists(indices, cost) + indices = _pick_from_list_after_sorting_other(indices, cost)[:count] + return indices + + +def _normalize_skill(level: int, new_max: int) -> int: + 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 = [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 + + +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), 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 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: + team = player.team + grouped_players_by_team[team].append(player) + active_teams = {} + players_without_team = [] + for team_id in grouped_players_by_team: + if team_id == 0: + players_without_team += [ + player for player in grouped_players_by_team[team_id] + ] + else: + if len(grouped_players_by_team[team_id]) >= 4: + active_teams[team_id] = grouped_players_by_team[team_id] + else: + players_without_team += [ + player for player in grouped_players_by_team[team_id] + ] + return active_teams, players_without_team + + +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.") + 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 + + +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["PLAYERS_PER_COURT"]) + players.sort(key=lambda x: x.level, reverse=True) + game = Game( + None, court.id, players[0].id, players[3].id, players[1].id, players[2].id + ) + games.append(DisplayGame(game)) + return games + + +def get_shuffle_games( + courts: list[Court], players: Sequence[Player], total_levels: int +) -> list[DisplayGame]: + 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 = [] + for court, players in zip(courts, court_players): + 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() 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/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/c08245a9c93f_.py b/migrations/versions/c08245a9c93f_.py new file mode 100644 index 0000000..5755bcc --- /dev/null +++ b/migrations/versions/c08245a9c93f_.py @@ -0,0 +1,87 @@ +"""empty message + +Revision ID: c08245a9c93f +Revises: d158cf8f4950 +Create Date: 2025-07-12 21:27:46.932139 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c08245a9c93f' +down_revision = 'd158cf8f4950' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('court', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('status', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('player', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(length=64), nullable=False), + sa.Column('last_name', sa.String(length=64), nullable=False), + sa.Column('team', sa.Integer(), nullable=False), + sa.Column('status', sa.Boolean(), nullable=False), + sa.Column('level', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('player', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_player_first_name'), ['first_name'], unique=False) + batch_op.create_index(batch_op.f('ix_player_last_name'), ['last_name'], unique=False) + batch_op.create_index(batch_op.f('ix_player_team'), ['team'], unique=False) + + op.create_table('game', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('round_id', sa.Integer(), nullable=False), + sa.Column('court_id', sa.Integer(), nullable=False), + sa.Column('player_1', sa.Integer(), nullable=False), + sa.Column('player_2', sa.Integer(), nullable=False), + sa.Column('player_3', sa.Integer(), nullable=False), + sa.Column('player_4', sa.Integer(), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['court_id'], ['court.id'], ), + sa.ForeignKeyConstraint(['player_1'], ['player.id'], ), + sa.ForeignKeyConstraint(['player_2'], ['player.id'], ), + sa.ForeignKeyConstraint(['player_3'], ['player.id'], ), + sa.ForeignKeyConstraint(['player_4'], ['player.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_game_court_id'), ['court_id'], unique=False) + batch_op.create_index(batch_op.f('ix_game_date'), ['date'], unique=False) + batch_op.create_index(batch_op.f('ix_game_player_1'), ['player_1'], unique=False) + batch_op.create_index(batch_op.f('ix_game_player_2'), ['player_2'], unique=False) + batch_op.create_index(batch_op.f('ix_game_player_3'), ['player_3'], unique=False) + batch_op.create_index(batch_op.f('ix_game_player_4'), ['player_4'], unique=False) + batch_op.create_index(batch_op.f('ix_game_round_id'), ['round_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_game_round_id')) + batch_op.drop_index(batch_op.f('ix_game_player_4')) + batch_op.drop_index(batch_op.f('ix_game_player_3')) + batch_op.drop_index(batch_op.f('ix_game_player_2')) + batch_op.drop_index(batch_op.f('ix_game_player_1')) + batch_op.drop_index(batch_op.f('ix_game_date')) + batch_op.drop_index(batch_op.f('ix_game_court_id')) + + op.drop_table('game') + with op.batch_alter_table('player', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_player_team')) + batch_op.drop_index(batch_op.f('ix_player_last_name')) + batch_op.drop_index(batch_op.f('ix_player_first_name')) + + op.drop_table('player') + op.drop_table('court') + # ### end Alembic commands ### 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 ### diff --git a/src/match_up/__init__.py b/src/match_up/__init__.py deleted file mode 100644 index bccd0e7..0000000 --- a/src/match_up/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import Flask -from waitress import serve -from argparse import ArgumentParser -from importlib.resources import path -from match_up.controller import RoundGenerator - - -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) diff --git a/src/match_up/controller.py b/src/match_up/controller.py deleted file mode 100644 index b00bf6b..0000000 --- a/src/match_up/controller.py +++ /dev/null @@ -1,99 +0,0 @@ -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 ( - _create_placeholders, - _select_players, - _create_shuffle_games, - _create_team_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 - - 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 _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 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 - ) - games = active_games + inactive_games - games.sort(key=attrgetter("court")) - self.proposed_games = games - self.proposed_players = selected_players - return self.proposed_games - - 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 reset(self): - self.round = 0 - self.games = _create_placeholders(self.courts.get_courts(), "---") - self.players.reset_game_count() - return self.games diff --git a/src/match_up/data.py b/src/match_up/data.py deleted file mode 100644 index c00dde2..0000000 --- a/src/match_up/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/src/match_up/model.py b/src/match_up/model.py deleted file mode 100644 index 467310b..0000000 --- a/src/match_up/model.py +++ /dev/null @@ -1,174 +0,0 @@ -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() diff --git a/src/match_up/routes.py b/src/match_up/routes.py deleted file mode 100644 index e5a3b99..0000000 --- a/src/match_up/routes.py +++ /dev/null @@ -1,80 +0,0 @@ -from match_up import _app, _rg -from jinja2 import Environment, PackageLoader -from flask import render_template, request, url_for, Response - -re = Environment(loader=PackageLoader("match_up")) - - -@_app.route("/", methods=["GET"]) -def home(): - return re.get_template("index.j2").render(url_for=url_for) - - -@_app.route("/control", methods=["GET"]) -def get_controls(): - return render_template("controls.j2", courts=_rg.courts.get_courts()) - - -@_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" - ) - - -@_app.route("/confirm", methods=["POST"]) -def confirm_games(): - return render_template("games.j2", games=_rg.confirm(), title=f"Round: {_rg.round}") - - -@_app.route("/clear", methods=["POST"]) -def clear_games(): - return render_template("games.j2", games=_rg.clear(), title=f"Round: {_rg.round}") - - -@_app.route("/reset", methods=["POST"]) -def reset_games(): - return render_template("games.j2", games=_rg.reset(), title=f"Round: {_rg.round}") - - -@_app.route("/player-toggle/<player_request>", 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)}}' - ) - return render_template(t, player=player) - - -@_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 - ) - - -@_app.route("/reset_players", methods=["POST"]) -def reset_all_players(): - _rg.players.reset_all_players() - response = Response("Players reset!") - response.headers["HX-Refresh"] = "true" - return response - - -@_app.route("/court-toggle/<court_number>", 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) }}' - ) - 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()) diff --git a/src/match_up/templates/games.j2 b/src/match_up/templates/games.j2 deleted file mode 100644 index 46150f7..0000000 --- a/src/match_up/templates/games.j2 +++ /dev/null @@ -1,21 +0,0 @@ -<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth" id="games"> -<thead> - <th colspan="5" align="center"> {{ title }} </th> -</thead> -<thead> - <th align="center"> Court </th> - <th colspan="2" align="center"> Team 1 </th> - <th colspan="2" align="center"> Team 2 </th> -</thead> -<tbody> -{% for game in games %} - <tr {% if game.team1.player1.first == 'RESERVED' %} class="is-danger" {% endif %}> - <th align="center" style="width: 8%;">{{ game.court }}</th> - <td style="width: 23%;"><strong>{{ game.team1.player1.first }}</strong>{{ " " + game.team1.player1.last }}</th> - <td style="width: 23%;"><strong>{{ game.team1.player2.first }}</strong>{{ " " + game.team1.player2.last }}</th> - <td style="width: 23%;"><strong>{{ game.team2.player1.first }}</strong>{{ " " + game.team2.player1.last }}</th> - <td style="width: 23%;"><strong>{{ game.team2.player2.first }}</strong>{{ " " + game.team2.player2.last }}</th> - </tr> -{% endfor %} -</tbody> -</table>
\ No newline at end of file diff --git a/src/match_up/utilities.py b/src/match_up/utilities.py deleted file mode 100644 index 7140a5f..0000000 --- a/src/match_up/utilities.py +++ /dev/null @@ -1,123 +0,0 @@ -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 - - -def _shuffle_two_lists(a: list, b: list) -> Tuple[list, list]: - indices = list(range(len(a))) - shuffle(indices) - a = [a[index] for index in indices] - b = [b[index] for index in indices] - return a, b - - -def _pick_from_list_after_sorting_other(a: list, b: list[int]) -> list: - indices = [i[0] for i in sorted(enumerate(b), key=lambda x: x[1])] - return [a[index] for index in indices] - - -def sample_list(indices: list, cost: list[int], count=None) -> list: - if count is None: - count = len(indices) - indices, cost = _shuffle_two_lists(indices, cost) - indices = _pick_from_list_after_sorting_other(indices, cost)[:count] - 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 - return new_level - - -def _select_players(players: list[Player], count: int) -> list[Player]: - identifiers = [player.id for player in players] - play_count = [int(player.count) 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]]: - 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] - 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]]: - 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: - team = player.team - grouped_players_by_team[team].append(player) - active_teams = {} - players_without_team = [] - for team_id in grouped_players_by_team: - if team_id == 0: - players_without_team += [ - player for player in grouped_players_by_team[team_id] - ] - else: - if len(grouped_players_by_team[team_id]) >= 4: - active_teams[team_id] = grouped_players_by_team[team_id] - else: - players_without_team += [ - player for player in grouped_players_by_team[team_id] - ] - 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 |
