summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--app/__init__.py11
-rw-r--r--app/config.py14
-rw-r--r--app/controller.py195
-rw-r--r--app/forms.py10
-rw-r--r--app/model.py110
-rw-r--r--app/routes.py121
-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)bin15406 -> 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.j221
-rw-r--r--app/templates/index.j2 (renamed from src/match_up/templates/index.j2)9
-rw-r--r--app/templates/login.j225
-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.py171
-rw-r--r--matchup.py1
-rw-r--r--migrations/README1
-rw-r--r--migrations/alembic.ini50
-rw-r--r--migrations/env.py113
-rw-r--r--migrations/script.py.mako24
-rw-r--r--migrations/versions/c08245a9c93f_.py87
-rw-r--r--migrations/versions/d158cf8f4950_.py39
-rw-r--r--src/match_up/__init__.py26
-rw-r--r--src/match_up/controller.py99
-rw-r--r--src/match_up/data.py76
-rw-r--r--src/match_up/model.py174
-rw-r--r--src/match_up/routes.py80
-rw-r--r--src/match_up/templates/games.j221
-rw-r--r--src/match_up/utilities.py123
37 files changed, 1035 insertions, 604 deletions
diff --git a/.gitignore b/.gitignore
index 2b11eda..a04357f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
index 64eab0a..64eab0a 100644
--- a/src/match_up/static/favicon.ico
+++ b/app/static/favicon.ico
Binary files differ
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