aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitattributes1
-rw-r--r--README.md67
-rw-r--r--app.py11
-rw-r--r--controller.py4
-rw-r--r--docs/game_screen.png3
-rw-r--r--docs/player_screen.png3
-rw-r--r--model.py33
-rw-r--r--static/logo.pngbin24967 -> 130 bytes
-rw-r--r--templates/controls.j29
-rw-r--r--templates/courts.j28
-rw-r--r--templates/index.j248
-rw-r--r--templates/macros.j24
-rw-r--r--templates/players.j258
-rw-r--r--utilities.py34
14 files changed, 199 insertions, 84 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..24a8e87
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/README.md b/README.md
index 49c1d28..83a8a1f 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,65 @@
# Match Up
-## Scope
+Match Up! is a flask web application to create pairings of players from a pool and assign to a limited number of slots.
-This is a simple application build using flask, htmx and Bulma to generate matches for badminton.
+Here it is in action:
+
+![Game Page](https://gitlab.com/KaranJayachandra/match_up/-/raw/feature/docs/docs/game_screen.png?ref_type=heads)
+
+![Player Page](https://gitlab.com/KaranJayachandra/match_up/-/raw/feature/docs/docs/player_screen.png?ref_type=heads)
+
+Some additional features of this application are:
+
+- Propose pairings to create a round
+- Timer to limit the time for each round
+- Create pairings based on player level
+- Prioritize players with fewer played games
+- Blocking spaces that aren't available any more
+- Blocking players that aren't available any more
+- Regular and guest players
+
+What this application ***IS***:
+
+- Simple: Easy to modify, extend and use
+
+What this application ***IS NOT***:
+
+- Secure: This was designed to be used locally on a machine
+- Performant: It was designed for use by a single admin user
+
+## How to run
+
+Please install [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) and [python](https://realpython.com/installing-python/) on your machine before proceeding further. Once installed, open the terminal to a folder of your choosing and run the following commands to setup the application.
+
+```bash
+git clone https://gitlab.com/KaranJayachandra/match_up.git
+cd match_up
+python -m venv .venv
+.venv\Scripts\activate
+pip install -r requirements.txt
+```
+
+Now create a file called .env with the environment variables needed by the application. An example is shown below.
+
+```text
+DATABASE = "database.csv"
+COURTS = 10
+APP_SETTINGS = "config.DevelopmentConfig"
+```
+
+The *DATABASE* variable contains the list of players which is just a simple CSV file with two columns, the first containing their name and the second containing their skill level from 1 to 10. The *COURTS* variable is the number of spaces or slots you have for creating pairings. *APP_SETTINGS* should be chosen between "config.DevelopmentConfig" or "config.ProductionConfig" based on the deployment.
+
+The application can then be run using (ensure you are in the virtual environment if you closed the terminal after the previous steps):
+
+```bash
+flask run
+```
+
+The application should be running now and the terminal should display the ip and port number. Most modern terminals will allow you to navigate directly to the application by clicking on the displayed address.
+
+## Technology used
+
+This is a simple application build using [flask](https://flask.palletsprojects.com/en/3.0.x/), [htmx](https://htmx.org/) and [Bulma](https://bulma.io/). Flask acts as the backend that generates HTML responses using the wonderful [Jinja](https://jinja.palletsprojects.com/en/3.1.x/) templating language. Interactivity of the application is done using HTMX using simple GET and POST methods to the backend. The application is styled using the Bulma with its easy to used CSS classes.
## Startup Automation
@@ -16,3 +73,9 @@ pythonw -m flask run -p <port-number>
```
Create a task using Task Scheduler to run this script on start up.
+
+## Background / Motivation
+
+This part of the README focuses on the motivation of why this application was built and contains no technical value. Please read on in case you want to know more about the technological choices made.
+
+> I am part of a badminton club where an old Microsoft Access application was being used to run the games played every week. The application started crashing after a good run of a few years and also lacked a few badly needed features. Instead of fixing the old application, I took it upon myself to create something using tools that are more suited for such an application. I wanted to prioritize delivery speed for performance and hence choose Python. More over, the sheer number of Python programmers allows for the application to be easily maintained by others. I myself have no idea how Microsoft Access works. Since it was quite a small application, I went with Flask as it was more than enough for the features that I needed instead of Django. I have a background in electrical engineering and having never made frontends before, went with the simplest option for the user interface after a bit of research. HTMX required no need of me writing JavaScript and was by far the most appealing option. The website needed some basic styling and after looking at a few CSS frameworks, I settled with Bulma. It game me everything I needed. The application is currently about to be deployed at the badminton club.
diff --git a/app.py b/app.py
index f145354..d7d3b65 100644
--- a/app.py
+++ b/app.py
@@ -27,6 +27,11 @@ def get_time():
return render_template(t, mins=mins, secs=secs)
+@app.route("/control", methods=["GET"])
+def get_controls():
+ return render_template("controls.j2", courts=rg.courts.get_courts())
+
+
@app.route("/guests", methods=["GET"])
def get_guests():
t = re.from_string('{% from "macros.j2" import guests %}{{guests(count)}}')
@@ -35,12 +40,14 @@ def get_guests():
@app.route("/decrement", methods=["POST"])
def increment_guests():
- return render_template("guests.j2", guests=rg.players.decrement_guests())
+ t = re.from_string('{% from "macros.j2" import guests %}{{guests(count)}}')
+ return render_template(t, count=rg.players.decrement_guests())
@app.route("/increment", methods=["POST"])
def decrement_guests():
- return render_template("guests.j2", guests=rg.players.increment_guests())
+ t = re.from_string('{% from "macros.j2" import guests %}{{guests(count)}}')
+ return render_template(t, count=rg.players.increment_guests())
@app.route("/propose", methods=["POST"])
diff --git a/controller.py b/controller.py
index 2e09e9f..e6dc827 100644
--- a/controller.py
+++ b/controller.py
@@ -1,8 +1,8 @@
from typing import Tuple
from operator import attrgetter
from dataclasses import dataclass
-from utilities import sample_list
-from model import Team, Game, PlayerList, CourtList, Timer, MAX_LEVEL, PLAYER_PER_COURT
+from utilities import sample_list, Timer
+from model import Team, Game, PlayerList, CourtList, MAX_LEVEL, PLAYER_PER_COURT
def _create_placeholders(courts: dict, name: str) -> list[Game]:
diff --git a/docs/game_screen.png b/docs/game_screen.png
new file mode 100644
index 0000000..9a5d7ac
--- /dev/null
+++ b/docs/game_screen.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:46a0160d790741331ab483376e7995f01993f5381e31953da39265ef56333910
+size 174380
diff --git a/docs/player_screen.png b/docs/player_screen.png
new file mode 100644
index 0000000..f56d136
--- /dev/null
+++ b/docs/player_screen.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5d875f87b821c3d0633f37da1dd828cd71d6fd48dfffc6e4845c72a2332d09d1
+size 167110
diff --git a/model.py b/model.py
index c69ba48..f768d0f 100644
--- a/model.py
+++ b/model.py
@@ -1,7 +1,6 @@
from math import floor
from csv import reader
from typing import Tuple
-from time import time, strftime
from dataclasses import dataclass
MIN_LEVEL = 1
@@ -102,35 +101,3 @@ class PlayerList:
}
players.update(guests)
return players
-
-
-@dataclass
-class Timer:
- ref: float = None
- running: bool = False
- max_mins: int = 1
- default: str = ("00", "00")
-
- def __post_init__(self):
- self.max_seconds = self.max_mins * 60
-
- def start(self):
- self.ref = time() + self.max_seconds
- self.running = True
-
- def stop(self):
- self.ref = None
- self.running = False
-
- def get(self):
- if not self.running:
- return self.default
- total_seconds = self.ref - time()
- if total_seconds < 0:
- self.stop()
- return self.default
- mins = f"{int(total_seconds // 60):02d}"
- secs = f"{int(total_seconds % 60):02d}"
- print(mins)
- print(secs)
- return mins, secs
diff --git a/static/logo.png b/static/logo.png
index b81df6c..7d2d8c7 100644
--- a/static/logo.png
+++ b/static/logo.png
Binary files differ
diff --git a/templates/controls.j2 b/templates/controls.j2
index 1799e01..7a03f9a 100644
--- a/templates/controls.j2
+++ b/templates/controls.j2
@@ -1,3 +1,4 @@
+{% from "macros.j2" import court %}
<div class="columns">
<div class="column">
<div class="field has-addons">
@@ -48,4 +49,10 @@
hx-target="#games">Reset</button>
</div>
</div>
-<div hx-post="/clear" hx-swap="outerHTML" hx-trigger="revealed"></div>
+<div class="fixed-grid has-12-cols">
+<div class="grid">
+{% for key, value in courts.items() %}
+ {{ court(key, value) }}
+{% endfor %}
+</div>
+</div> \ No newline at end of file
diff --git a/templates/courts.j2 b/templates/courts.j2
deleted file mode 100644
index d650298..0000000
--- a/templates/courts.j2
+++ /dev/null
@@ -1,8 +0,0 @@
-{% from "macros.j2" import court %}
-<div class="fixed-grid has-12-cols">
-<div class="grid">
-{% for key, value in courts.items() %}
- {{ court(key, value) }}
-{% endfor %}
-</div>
-</div> \ No newline at end of file
diff --git a/templates/index.j2 b/templates/index.j2
index fcea4e3..764eae8 100644
--- a/templates/index.j2
+++ b/templates/index.j2
@@ -11,33 +11,29 @@
</head>
<body>
<section class="section">
- <nav class="level">
- <div class="level-left">
- <div class="level-item">
- <figure class="image container is-128x128">
- <img src="{{ url_for('static', filename='logo.png') }}"/>
- </figure>
+ <nav class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <figure class="image container is-128x128">
+ <img src="{{ url_for('static', filename='logo.png') }}"/>
+ </figure>
+ </div>
</div>
- </div>
- <div class="level-item">
- <p class="title">Match Up!</p>
- </div>
- <div class="level-right">
- <div hx-get="/time" hx-swap="outerHTML" hx-trigger="revealed"></div>
- </div>
- </nav>
- </section>
- <section class="section">
- <p class="title">Games</p>
- {% include 'controls.j2' %}
- </section>
- <section class="section">
- <h2 class="title">Courts</h2>
- <div hx-get="/courts" hx-swap="outerHTML" hx-trigger="revealed"></div>
- </section>
- <section class="section">
- <h2 class="title">Players</h2>
- <div hx-get="/players" hx-swap="outerHTML" hx-trigger="revealed"></div>
+ <p class="level-item has-text-centered">
+ <a class="title link is-info is-underlined" href="/">Games</a>
+ </p>
+ <div class="level-item has-text-centered">
+ <p class="title is-1">Match Up!</p>
+ </div>
+ <p class="level-item has-text-centered">
+ <a class="title link is-info" href="/players">Players</a>
+ </p>
+ <div class="level-right">
+ <div hx-get="/time" hx-swap="outerHTML" hx-trigger="load"></div>
+ </div>
+ </nav>
+ <div hx-post="/clear" hx-swap="outerHTML" hx-trigger="load"></div>
+ <div hx-get="/control" hx-swap="outerHTML" hx-trigger="load"></div>
</section>
<footer class="footer">
<div class="content has-text-centered">
diff --git a/templates/macros.j2 b/templates/macros.j2
index 7117d11..65d886f 100644
--- a/templates/macros.j2
+++ b/templates/macros.j2
@@ -1,7 +1,7 @@
{% macro court(id, status) -%}
<button class= "button is-large is-responsive {% if status %}is-success{% else %}is-danger{% endif %}"
hx-post="{{ "/court-toggle/" ~ id}}"
- hx-swap="outerHTML">{{ id }}</button>
+ hx-swap="outerHTML"> Court {{ id }}</button>
{%- endmacro %}
{% macro player(id, status, name) -%}
@@ -18,6 +18,6 @@
{% macro timer(mins, secs) -%}
<div hx-get="/time" hx-swap="outerHTML" hx-trigger="every 1s">
- <p class="title">Timer: {{mins}}:{{secs}}</p>
+ <p class="title">⏲️ {{mins}}:{{secs}}</p>
</div>
{%- endmacro %} \ No newline at end of file
diff --git a/templates/players.j2 b/templates/players.j2
index 4eedc54..0e851ab 100644
--- a/templates/players.j2
+++ b/templates/players.j2
@@ -1,9 +1,51 @@
{% from "macros.j2" import player %}
-<div class="fixed-grid has-5-cols">
-<div class="grid">
-{% for id, value in players.items() %}
- <div class="cell">
- {{ player(id, value.status, value.name) }}
- </div>
-{% endfor %}
-</div> \ No newline at end of file
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Match Up!</title>
+ <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
+ <link rel="stylesheet" href="{{ url_for('static', filename='bulma.min.css') }}">
+ <link rel="stylesheet" href="{{ url_for('static', filename='custom.css') }}">
+ <script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
+ </head>
+ <body>
+ <section class="section">
+ <nav class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <figure class="image container is-128x128">
+ <img src="{{ url_for('static', filename='logo.png') }}"/>
+ </figure>
+ </div>
+ </div>
+ <p class="level-item has-text-centered">
+ <a class="title link is-info" href="/">Games</a>
+ </p>
+ <div class="level-item has-text-centered">
+ <p class="title is-1">Match Up!</p>
+ </div>
+ <p class="level-item has-text-centered">
+ <a class="title link is-info is-underlined" href="/players">Players</a>
+ </p>
+ <div class="level-right">
+ <div hx-get="/time" hx-swap="outerHTML" hx-trigger="load"></div>
+ </div>
+ </nav>
+ <div class="fixed-grid has-5-cols">
+ <div class="grid">
+ {% for id, value in players.items() %}
+ <div class="cell">
+ {{ player(id, value.status, value.name) }}
+ </div>
+ {% endfor %}
+ </div>
+ </section>
+ <footer class="footer">
+ <div class="content has-text-centered">
+ Made by <a href="https://karanjayachandra.com/">Karan</a> using <a href="https://flask.palletsprojects.com">Flask</a>, <a href="https://htmx.org/">HTMX</a> and <a href="https://bulma.io">Bulma</a>
+ </div>
+ </footer>
+ </body>
+</html> \ No newline at end of file
diff --git a/utilities.py b/utilities.py
index 1ad24b8..7ba140e 100644
--- a/utilities.py
+++ b/utilities.py
@@ -1,5 +1,9 @@
+from time import time
from typing import Tuple
from random import shuffle
+from dataclasses import dataclass
+
+SECS_IN_MIN = 60
def _shuffle_two_lists(a: list, b: list) -> Tuple[list, list]:
@@ -21,3 +25,33 @@ def sample_list(indices: list, cost: list[int], count: int = None) -> list:
indices, cost = _shuffle_two_lists(indices, cost)
indices = _pick_from_list_after_sorting_other(indices, cost)[:count]
return indices
+
+
+@dataclass
+class Timer:
+ ref: float = None
+ running: bool = False
+ max_mins: int = 20
+ default: str = ("00", "00")
+
+ def __post_init__(self):
+ self.max_seconds = self.max_mins * SECS_IN_MIN
+
+ def start(self):
+ self.ref = time() + self.max_seconds
+ self.running = True
+
+ def stop(self):
+ self.ref = None
+ self.running = False
+
+ def get(self):
+ if not self.running:
+ return self.default
+ total_seconds = self.ref - time()
+ if total_seconds < 0:
+ self.stop()
+ return self.default
+ mins = f"{int(total_seconds // SECS_IN_MIN):02d}"
+ secs = f"{int(total_seconds % SECS_IN_MIN):02d}"
+ return mins, secs