A lightweight, web-based tournament management system built with Python (Flask) and HTMX. Designed to handle Taekwondo competitions, including bracket generation, ring management, and live scoring.
- Division Management: Create and manage competition divisions (e.g., Weight class, Belt rank).
- Competitor Management: Add competitors to specific divisions.
- Automatic Bracket Generation: Generates single-elimination brackets, automatically handling byes for uneven numbers of competitors.
- Ring Management: Create and monitor multiple competition rings.
- Match Scheduling: Assign matches to specific rings and order them.
- Live Scorekeeping: Dedicated interface for scorekeepers to record results and advance winners through the bracket automatically.
- Public Display: Live view of upcoming matches and ring status.
- Backend: Python, Flask, SQLAlchemy
- Database: SQLite (Auto-generated)
- Frontend: HTML, HTMX (for dynamic interactions)
- Clone the repository.
- Install the required dependencies:
uv sync
- Create a
.envfile with the required environment variables:# PostgreSQL database connection user=<db_user> password=<db_password> host=<db_host> port=<db_port> dbname=<db_name> # Supabase authentication SUPABASE_URL=https://<your-project>.supabase.co SUPABASE_KEY=<your-supabase-anon-key> # Flask session secret key (generate a strong random value) SECRET_KEY=<your-secret-key>
- Run the application:
uv run flask run
Notes:
On the first run, this will automatically create the
tournament.dbSQLite database.You can specify a different port with
--port <port_number>. Be sure to update the URLs below accordingly.
- Open your browser and navigate to:
- Public Home:
http://localhost:5000/ - Admin Dashboard:
http://localhost:5000/admin
- Public Home:
- Navigate to the Admin Dashboard.
- Create Rings: Add the rings available for the tournament (e.g., "Ring 1", "Ring 2").
- Create Divisions: Define the categories for competition.
- Click "Manage" on a Division.
- Add competitors by name (supports bulk add via newlines).
- Click Generate Bracket. This will create the match tree based on the number of competitors.
- Click Manage & Schedule Bracket to view the tree.
- In the Bracket Manager, you can assign specific matches to a Ring and give them a sequence number (e.g., Match 101).
- Scorekeepers: Navigate to
/ring/<ring_id>/scorekeeper. They will see a list of scheduled matches for their ring. - Recording Results: Scorekeepers select the winner. The system automatically updates the bracket, moving the winner to the next round.
app.py: Main application logic, database models, and routes.templates/: Contains HTML templates (e.g.,index.html,admin.html,bracket_view.html).scripts/: Management utilities and migrations.
Run scripts from the repository root:
uv run scripts/init_db.py
uv run scripts/reset_db.py
uv run scripts/test_db.py
uv run scripts/update_secrets.py --env dev- Ring: Physical location for matches.
- Division: Category of competition.
- Competitor: Athlete information.
- Match: Links competitors, tracks winners, and maintains the bracket tree structure (
next_match_id).
The /api/v1 prefix provides a stable, versioned JSON REST API for all tournament data.
All endpoints require Bearer-token authentication.
Every /api/v1 request must include an Authorization header with a valid API token:
Authorization: Bearer <your-api-token>
Tokens are created and revoked via the Admin → API Tokens page
(/admin/api-tokens). Each token is shown only once at creation time;
store it securely.
# Example: obtain token via the admin UI, then export it
export TKD_TOKEN="<paste-your-token-here>"
BASE="http://localhost:5000"Requests with a missing or invalid token receive:
HTTP/1.1 401 Unauthorized
{
"data": null,
"error": { "code": "UNAUTHORIZED", "message": "Invalid or revoked token.", "details": {} }
}Every response is wrapped in a consistent JSON envelope:
Success
{ "data": <payload>, "error": null }Error
{
"data": null,
"error": {
"code": "BAD_REQUEST",
"message": "Human-readable description.",
"details": { "field": "name" }
}
}error.details always contains at least {}. When the error is field-specific
it includes a "field" key identifying the offending input field, plus any
additional actionable context (e.g., "valid_values", "valid_winner_ids").
| Code | Meaning |
|---|---|
| 200 | OK — read or update succeeded |
| 201 | Created — new resource created |
| 400 | Bad Request — invalid input; see error.details |
| 401 | Unauthorized — missing or invalid Bearer token |
| 404 | Not Found — resource does not exist |
| 409 | Conflict |
| 415 | Unsupported Media Type — POST/PUT/PATCH body must be application/json |
| 422 | Unprocessable Entity |
| 500 | Internal Server Error |
POST, PUT, and PATCH requests that include a body must send
Content-Type: application/json. Body-less POSTs (e.g., generate_bracket)
are accepted without a Content-Type header.
List all rings.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/rings{ "data": [{ "id": 1, "name": "Ring 1" }], "error": null }Create a ring.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✓ | Ring name |
curl -X POST $BASE/api/v1/rings \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Ring 1"}'HTTP/1.1 201 Created
{ "data": { "id": 1, "name": "Ring 1" }, "error": null }Fetch a single ring.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/rings/1Update a ring's name.
curl -X PATCH $BASE/api/v1/rings/1 \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Ring A"}'Delete a ring.
curl -X DELETE -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/rings/1{ "data": { "id": 1, "deleted": true }, "error": null }List all divisions.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/divisions{ "data": [{ "id": 1, "name": "Male Under 80kg", "event_type": "kyorugi" }], "error": null }Create a division.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✓ | Division name |
event_type |
string | "kyorugi" (default) or "poomsae" |
curl -X POST $BASE/api/v1/divisions \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Male Under 80kg", "event_type": "kyorugi"}'HTTP/1.1 201 Created
{ "data": { "id": 1, "name": "Male Under 80kg", "event_type": "kyorugi" }, "error": null }Validation error example (invalid event_type):
HTTP/1.1 400 Bad Request
{
"data": null,
"error": {
"code": "BAD_REQUEST",
"message": "Invalid event type.",
"details": { "field": "event_type", "valid_values": ["kyorugi", "poomsae"] }
}
}Fetch a single division.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/divisions/1Rename a division.
curl -X PATCH $BASE/api/v1/divisions/1 \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Female Under 57kg"}'Delete a division and all its competitors and matches.
curl -X DELETE -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/divisions/1List all competitors, optionally filtered by division.
curl -H "Authorization: Bearer $TKD_TOKEN" "$BASE/api/v1/competitors?division_id=1"Add a competitor to a division.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✓ | Competitor name |
division_id |
integer | ✓ | ID of the division |
position |
integer | Seed/roster order (default: null) |
curl -X POST $BASE/api/v1/competitors \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Alice Smith", "division_id": 1}'HTTP/1.1 201 Created
{ "data": { "id": 1, "name": "Alice Smith", "division_id": 1, "position": null }, "error": null }Fetch a single competitor.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/competitors/1Update a competitor's name, division, or position.
curl -X PATCH $BASE/api/v1/competitors/1 \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Alice Johnson", "position": 2}'Remove a competitor.
curl -X DELETE -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/competitors/1Generate (or regenerate) a single-elimination bracket for a division. At least 2 competitors must exist. The request body is optional.
curl -X POST -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/divisions/1/generate_bracketHTTP/1.1 201 Created
{ "data": { "division_id": 1, "competitors": 4, "matches_created": 3 }, "error": null }Error when fewer than 2 competitors exist:
HTTP/1.1 400 Bad Request
{
"data": null,
"error": {
"code": "BAD_REQUEST",
"message": "At least 2 competitors are required to generate a bracket.",
"details": {}
}
}Retrieve all matches in the bracket for a division.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/divisions/1/bracket{
"data": [
{
"match_id": 1, "round_name": "Semi-Final", "status": "Pending",
"ring_id": null, "next_match_id": 3,
"competitor1": { "id": 1, "name": "Alice Smith" },
"competitor2": { "id": 2, "name": "Bob Jones" },
"winner_id": null
}
],
"error": null
}List matches, optionally filtered by division.
curl -H "Authorization: Bearer $TKD_TOKEN" "$BASE/api/v1/matches?division_id=1"Create a match manually.
| Field | Type | Required | Description |
|---|---|---|---|
division_id |
integer | ✓ | Division this match belongs to |
ring_id |
integer | Ring the match is assigned to | |
competitor1_id |
integer | First competitor | |
competitor2_id |
integer | Second competitor | |
next_match_id |
integer | ID of the next bracket match | |
round_name |
string | e.g., "Semi-Final", "Final" |
|
match_number |
integer | Scheduling number (e.g., 101) |
curl -X POST $BASE/api/v1/matches \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"division_id": 1, "round_name": "Final"}'Fetch a single match.
curl -H "Authorization: Bearer $TKD_TOKEN" $BASE/api/v1/matches/1Update non-terminal match fields. To complete a match use the /result endpoint.
Allowed status values via PATCH: Pending, In Progress.
Terminal statuses (Completed, Disqualification, Completed (Bye)) must be
set via POST /api/v1/matches/<id>/result.
curl -X PATCH $BASE/api/v1/matches/1 \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ring_id": 2, "status": "In Progress"}'Validation error when setting a terminal status via PATCH:
HTTP/1.1 400 Bad Request
{
"data": null,
"error": {
"code": "BAD_REQUEST",
"message": "Cannot set a terminal status via PATCH. Use the /matches/<id>/result endpoint to complete a match.",
"details": { "field": "status", "allowed_statuses": ["In Progress", "Pending"] }
}
}Record the outcome of a match and automatically advance the winner to the next bracket match.
| Field | Type | Required | Description |
|---|---|---|---|
status |
string | ✓ | "Completed" or "Disqualification" |
winner_id |
integer | ✓ | Competitor ID of the winner (must be a participant) |
curl -X POST $BASE/api/v1/matches/1/result \
-H "Authorization: Bearer $TKD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "Completed", "winner_id": 42}'{ "data": { "match_id": 1, "status": "Completed", "winner_id": 42 }, "error": null }Validation error when winner_id is not a participant:
HTTP/1.1 400 Bad Request
{
"data": null,
"error": {
"code": "BAD_REQUEST",
"message": "winner_id must be a participant in this match.",
"details": { "field": "winner_id", "valid_winner_ids": [42, 43] }
}
}The following routes pre-date the /api/v1 API. They remain functional but
are deprecated and will be removed in a future release once all clients
have migrated. Every response from these routes includes:
Deprecation: true
Link: </api/v1>; rel="successor-version"
| Legacy endpoint | Replacement |
|---|---|
GET /rings |
GET /api/v1/rings |
POST /rings |
POST /api/v1/rings |
GET /divisions |
GET /api/v1/divisions |
POST /divisions |
POST /api/v1/divisions |
PUT /divisions/<id> |
PATCH /api/v1/divisions/<id> |
DELETE /divisions/<id> |
DELETE /api/v1/divisions/<id> |
POST /matches/<id>/result |
POST /api/v1/matches/<id>/result |
POST /divisions/<id>/generate_bracket |
POST /api/v1/divisions/<id>/generate_bracket |
GET /divisions/<id>/bracket |
GET /api/v1/divisions/<id>/bracket |
GET /divisions/<id>/bracket_ui |
GET /ui/divisions/<id>/bracket (HTMX fragment) |
PUT /matches/<id>/schedule |
PATCH /api/v1/matches/<id>* |
* Scheduling semantics change in v1: The legacy
PUT /matches/<id>/scheduleendpoint accepted aring_sequencefield (1–99) and automatically derivedmatch_numberasring_id * 100 + ring_sequence, and performed conflict checks to prevent two matches sharing the same number on a ring.
PATCH /api/v1/matches/<id>does not acceptring_sequenceand does not perform conflict checks. Clients migrating from the legacy schedule route must:
- Compute
match_numberexplicitly (e.g.,ring_id * 100 + desired_sequence) and send it directly.- Send
ring_idseparately as a field in the same PATCH request.- Handle scheduling conflict detection independently if required.