aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/__init__.py26
-rw-r--r--app/controller.py99
-rw-r--r--app/data.py76
-rw-r--r--app/model.py174
-rw-r--r--app/routes.py80
-rw-r--r--app/utilities.py123
6 files changed, 578 insertions, 0 deletions
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..bccd0e7
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,26 @@
+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/app/controller.py b/app/controller.py
new file mode 100644
index 0000000..b00bf6b
--- /dev/null
+++ b/app/controller.py
@@ -0,0 +1,99 @@
+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/app/data.py b/app/data.py
new file mode 100644
index 0000000..c00dde2
--- /dev/null
+++ b/app/data.py
@@ -0,0 +1,76 @@
+from dataclasses import dataclass
+from sqlalchemy import Column, String, Integer
+from sqlalchemy.ext.declarative import declarative_base
+
+
+MIN_LEVEL = 1
+MAX_LEVEL = 10
+PLAYER_PER_COURT = 4
+GUEST_LEVELS = [1, 3, 5]
+GUESTS_PER_LEVEL = 4
+
+BASE = declarative_base()
+
+
+class Player(BASE):
+ __tablename__ = "players"
+ id = Column("id", Integer, primary_key=True)
+ first = Column("first", String)
+ last = Column("last", String)
+ level = Column("level", Integer)
+ team = Column("team", Integer)
+ status = Column("status", Integer)
+ count = Column("count", Integer)
+
+ def __init__(
+ self,
+ id: int,
+ first: str = "",
+ last: str = "",
+ level: int = 0,
+ team: int = 0,
+ status: bool = True,
+ count: int = 0,
+ ):
+ self.id = id
+ self.first = first
+ self.last = last
+ self.status = status
+ self.team = team
+ self.level = level
+ self.count = count
+
+ def __repr__(self):
+ team = f" of Team {self.team}" if self.team else ""
+ status = "active" if self.status else "inactive"
+ return f"{self.first} {self.last} (Level {self.level}){team} is {status} and has played {self.count} games"
+
+
+class DisplayPlayer:
+ id: int
+ first: str
+ last: str
+ status: bool
+
+ def __init__(self, player: Player):
+ self.id = int(player.id)
+ self.first = str(player.first)
+ self.last = str(player.last)
+ self.status = bool(player.status)
+
+ def __repr__(self):
+ status = "active" if self.status else "inactive"
+ return f"{self.first} {self.last} is {status}"
+
+
+@dataclass
+class Team:
+ player1: DisplayPlayer
+ player2: DisplayPlayer
+
+
+@dataclass
+class Game:
+ court: int
+ team1: Team
+ team2: Team
diff --git a/app/model.py b/app/model.py
new file mode 100644
index 0000000..467310b
--- /dev/null
+++ b/app/model.py
@@ -0,0 +1,174 @@
+from math import floor
+from typing import Tuple
+from os.path import isfile
+from pandas import read_csv
+from dataclasses import dataclass
+from importlib.resources import path
+from random import getrandbits, randint
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy import create_engine, not_
+from names import get_first_name, get_last_name
+from match_up.data import (
+ MIN_LEVEL,
+ MAX_LEVEL,
+ PLAYER_PER_COURT,
+ BASE,
+ GUEST_LEVELS,
+ GUESTS_PER_LEVEL,
+ Player,
+ DisplayPlayer,
+)
+
+
+@dataclass
+class CourtList:
+ total: int = 12
+
+ def __post_init__(self) -> None:
+ self.courts = {i + 1: True for i in range(self.total)}
+
+ def get_court_status(self, court_number: int) -> bool:
+ return self.courts[court_number]
+
+ def toggle_court_status(self, court_number: int) -> bool:
+ new_status = not self.courts[court_number]
+ self.courts[court_number] = new_status
+ return new_status
+
+ def get_courts(self) -> dict:
+ return self.courts
+
+ def separate_courts(self) -> Tuple[dict, dict]:
+ active_courts = {k: v for k, v in self.courts.items() if v}
+ inactive_courts = {k: v for k, v in self.courts.items() if not v}
+ return active_courts, inactive_courts
+
+
+def _init_guests(session) -> None:
+ for level_id, level in enumerate(GUEST_LEVELS):
+ for player_id in range(GUESTS_PER_LEVEL):
+ unique_id = (level_id * GUESTS_PER_LEVEL) + player_id + 1
+ player = Player(
+ id=-unique_id,
+ first="Guest",
+ last=str(unique_id),
+ level=level,
+ status=False,
+ count=0,
+ )
+ session.add(player)
+ session.commit()
+
+
+def _init_database_from_csv(session, csv_location: str) -> None:
+ data = read_csv(csv_location)
+ session.query(Player).delete()
+ for _, entry in data.iterrows():
+ player = Player(
+ id=entry["id"],
+ first=entry["first"],
+ last=entry["last"],
+ level=entry["skill"],
+ team=entry["team"],
+ status=False,
+ count=0,
+ )
+ session.add(player)
+ session.commit()
+ _init_guests(session)
+
+
+def _init_random_database(session, player_count: int = 80) -> None:
+ total_teams = 5
+ players_per_team = 5
+ for i in range(player_count):
+ if i < total_teams * players_per_team:
+ team = floor(i / players_per_team) + 1
+ else:
+ team = 0
+ player = Player(
+ id=i,
+ first=get_first_name(),
+ last=get_last_name(),
+ level=randint(MIN_LEVEL, MAX_LEVEL),
+ team=team,
+ status=bool(getrandbits(1)),
+ count=0,
+ )
+ session.add(player)
+ session.commit()
+ _init_guests(session)
+
+
+class PlayerList:
+
+ def __init__(self, csv_location=None) -> None:
+ with path("match_up", "players.db") as p:
+ self.db_location = p
+ db_file_found = True
+ if not isfile(self.db_location):
+ db_file_found = False
+ self._init_database()
+ if csv_location is not None:
+ _init_database_from_csv(self.session, csv_location)
+ else:
+ if not db_file_found:
+ _init_random_database(self.session)
+
+ def _init_database(self):
+ engine = create_engine("sqlite:///" + str(self.db_location))
+ BASE.metadata.create_all(bind=engine)
+ self.session = sessionmaker(bind=engine)()
+
+ def get_player_status(self, id: int) -> bool:
+ return self.session.query(Player).filter(Player.id == id).first().status
+
+ def toggle_player_status(self, id: int) -> DisplayPlayer:
+ player = self.session.query(Player).filter(Player.id == id).first()
+ player.status = not player.status
+ self.session.commit()
+ return DisplayPlayer(player)
+
+ def get_all_players(self) -> Tuple[list[DisplayPlayer], list[DisplayPlayer]]:
+ all_players = [
+ DisplayPlayer(p)
+ for p in self.session.query(Player)
+ .order_by(Player.first, Player.last)
+ .all()
+ ]
+ regulars = [player for player in all_players if player.first != "Guest"]
+ guests = [player for player in all_players if player.first == "Guest"]
+ guests.sort(key=lambda x: int(x.last))
+ return regulars, guests
+
+ def get_possible_game_count(self) -> int:
+ total_players = len(self.get_players())
+ possible_games = floor(total_players / PLAYER_PER_COURT)
+ return possible_games
+
+ def get_players(self) -> list[Player]:
+ active_players = (
+ self.session.query(Player)
+ .order_by(Player.first, Player.last)
+ .filter(not_(Player.status == 0))
+ .all()
+ )
+ return active_players
+
+ def increment_game_count(self, player_ids: list[int]):
+ players = self.session.query(Player).filter(Player.id.in_(player_ids))
+ for player in players:
+ player.count += 1
+ self.session.commit()
+
+ def reset_game_count(self):
+ players = self.session.query(Player)
+ for player in players:
+ player.count = 0
+ self.session.commit()
+
+ def reset_all_players(self):
+ players = self.session.query(Player)
+ for player in players:
+ player.status = 0
+ self.session.commit()
diff --git a/app/routes.py b/app/routes.py
new file mode 100644
index 0000000..e5a3b99
--- /dev/null
+++ b/app/routes.py
@@ -0,0 +1,80 @@
+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/app/utilities.py b/app/utilities.py
new file mode 100644
index 0000000..7140a5f
--- /dev/null
+++ b/app/utilities.py
@@ -0,0 +1,123 @@
+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