From 0da8dc16fb26e9df5d0efd7c7a463e354d8e00cf Mon Sep 17 00:00:00 2001 From: Karan Jayachandra Date: Sun, 6 Jul 2025 12:58:07 +0200 Subject: Transitioned back to a traditional flask app --- app/__init__.py | 26 ++++++++ app/controller.py | 99 +++++++++++++++++++++++++++++++ app/data.py | 76 ++++++++++++++++++++++++ app/model.py | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/routes.py | 80 +++++++++++++++++++++++++ app/utilities.py | 123 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 578 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/controller.py create mode 100644 app/data.py create mode 100644 app/model.py create mode 100644 app/routes.py create mode 100644 app/utilities.py (limited to 'app') 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/", 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/", 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 -- cgit v1.3.1