From 7a5272e88c2ed8deef61150aed0b919b064ebfed Mon Sep 17 00:00:00 2001 From: Yanchi88 Date: Tue, 7 Jul 2026 18:25:39 +0200 Subject: [PATCH] Fix import/scoring bugs, split PDF module, modernize admin UI Bug fixes: - Fix Excel import INSERT with 11 placeholders for 10 columns in refresh() - Remove leftover breakpoint() in getNationalRecordsAida that froze the server - Fix setRegistration call passing warn as change_type in refresh() - Fix formatDiveTime computing minutes with % instead of // - Fix undefined names: 'name' in changeCompName, 'log' in updateStartList, 'lane_df' in dropUnusedColumns, 'self.NR' in getNationalRecordsAida - Add missing sqlite3 import in compy_data.py - Fix broken @property @staticmethod combination on CompyData.version - Fix storeResults crash on starts without a recorded result - Remove dead comp_type == "CMAS" branch in computePoints (never matched) - Fix clubs never importing from Excel ('club' vs 'Club' key check) - Add missing 'content:' in lane list PDF page counter - Remove os.chdir side effect in getSavedCompetitions that broke relative database paths - Sync schemas with code: judge.salt, competition.publish_results, competition_athlete.eligible_national, break.block (--init_db now produces a working database) Refactoring: - Extract PDF generation (start/lane/result lists) into compy_pdf.py - Extract shared constants into compy_constants.py - Make weasyprint import lazy so the app runs without it (PDF export degrades gracefully, helps macOS setups missing pango) - Replace system pip-freeze requirements.txt with actual dependencies - Remove debug print()s and test.html dumps, replace deprecated utcnow UI: - Modernize admin page with a light theme matching the judge/results color system (tab bar, cards, striped tables, styled forms) - Fix invalid font-family "sans" in judge/results stylesheets - Restyle 404 page Tooling: - Add tools/generate_test_data.py: generates an AIDA-style competition Excel file (30 athletes, 3 days, CWT/STA/DYN, results + DNS entries) - Document test data workflow and admin URL in Readme --- Readme.md | 16 + compy_constants.py | 34 ++ compy_data.py | 541 +----------------- compy_flask.py | 6 +- compy_pdf.py | 527 +++++++++++++++++ compy_utilities.py | 3 +- requirements.txt | 119 +--- schemas/break.sql | 2 +- schemas/competition.sql | 2 +- schemas/competition_athlete.sql | 3 +- schemas/judges.sql | 2 +- static/compy.css | 390 ++++++++++++- static/judge.css | 2 +- static/results.css | 2 +- templates/404.html | 24 +- templates/template.html | 2 +- .../generate_test_data.cpython-310.pyc | Bin 0 -> 5442 bytes tools/generate_test_data.py | 198 +++++++ 18 files changed, 1218 insertions(+), 655 deletions(-) create mode 100644 compy_constants.py create mode 100644 compy_pdf.py create mode 100644 tools/__pycache__/generate_test_data.cpython-310.pyc create mode 100644 tools/generate_test_data.py diff --git a/Readme.md b/Readme.md index 7324625..9c1fff1 100644 --- a/Readme.md +++ b/Readme.md @@ -73,6 +73,22 @@ Install Weasyprint https://doc.courtbouillon.org/weasyprint/stable/first_steps.h - Linux: `python3 compy.py` - Windows: `python3.exe compy.py` - Navigate your browser to `localhost:5000` + - The admin interface is at `localhost:5000/admin?auth=XXXXXX` where `XXXXXX` are the first 6 + characters of your `FLASK_SECRET_KEY` from `.env` + +## Test data + +A generator for a realistic sample competition (30 athletes, 3 days: CWT, STA, DYN, +results and cards for past days, DNS entries, open results for today) lives in +`tools/generate_test_data.py`: + + - `python3 tools/generate_test_data.py test_competition.xlsx` + - Start the server and open the admin page + - Set a competition name, save, choose the generated file and press "Refresh data" + +Alternatively a pre-seeded `compy.sqlite` with the competition "Compy Test Open 2026" +(including `test_competition.xlsx`) may already be present, in which case you can simply +load it from "Load competition" on the Settings tab. ## Want to help? diff --git a/compy_constants.py b/compy_constants.py new file mode 100644 index 0000000..0609a77 --- /dev/null +++ b/compy_constants.py @@ -0,0 +1,34 @@ + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# ━━━━━━━━━━━━━ +# ┏┓┏┓┳┳┓┏┓┓┏ +# ┃ ┃┃┃┃┃┃┃┗┫ +# ┗┛┗┛┛ ┗┣┛┗┛ +# ━━━━━━━━━━━━━ +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# Competition organization tool +# for freediving competitions. +# +# Copyright 2023 - Arno Mayrhofer +# +# Licensed under the GNU AGPL +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# Authors: +# +# - Arno Mayrhofer +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +"""Shared domain constants for Compy.""" + +INVALID_DATE = "0000-00-00" +INVALID_TIME = "99:99" +POOL_DISCIPLINES = ["STA", "DNF", "DYN", "DYNB"] +DEPTH_DISCIPLINES = ["FIM", "CNF", "CWT", "CWTB"] +DISCIPLINES = DEPTH_DISCIPLINES + POOL_DISCIPLINES +FEDERATIONS = ["aida", "cmas"] diff --git a/compy_data.py b/compy_data.py index d99a33a..4ccfbbc 100644 --- a/compy_data.py +++ b/compy_data.py @@ -39,37 +39,24 @@ import math import random import os -import json -import glob -from datetime import datetime, timedelta, time +import sqlite3 +from datetime import datetime, timedelta, time, timezone from io import BytesIO -import flask -try: - import weasyprint as wp -except ImportError: - print("Could not find weasyprint. Install with 'pip3 install weasyprint'") - exit(-1) import base64 from PIL import Image -from io import BytesIO -import numpy as np import regex import sys import athlete from compy_config import CompyConfig +from compy_constants import (INVALID_DATE, INVALID_TIME, POOL_DISCIPLINES, + DEPTH_DISCIPLINES, DISCIPLINES, FEDERATIONS) +from compy_pdf import PdfReportMixin import compy_utilities as u from openpyxl import load_workbook -INVALID_DATE="0000-00-00" -INVALID_TIME="99:99" -POOL_DISCIPLINES = ["STA", "DNF", "DYN", "DYNB"] -DEPTH_DISCIPLINES=["FIM", "CNF", "CWT", "CWTB"] -DISCIPLINES = DEPTH_DISCIPLINES + POOL_DISCIPLINES -FEDERATIONS=["aida", "cmas"] - -class CompyData: +class CompyData(PdfReportMixin): version_ = None @@ -94,7 +81,7 @@ def __init__(self, db, app, comp_id = -1): self.name_ = "undefined" with self.app_.app_context(): - #self.updateNationalRecords(); + #self.updateNationalRecords() # try and find it first if comp_id == -1: c_id = self.db_.execute("SELECT id FROM competition WHERE name=?", self.name_) @@ -107,7 +94,6 @@ def __init__(self, db, app, comp_id = -1): self.load(comp_id) @property - @staticmethod def version(self): if self.version_ is None: base_path = os.path.dirname(os.path.realpath(__file__)) @@ -304,14 +290,14 @@ def refresh(self): # read second sheet (list of athletes) df = pd.read_excel(self.comp_file_, sheet_name="Athletes and Judges", skiprows=1) for i,r in df.iterrows(): - a = athlete.Athlete.fromArgs(r['Id'], r['FirstName'], r['LastName'], r['Gender'], r['Country'], r['Club'] if 'club' in r else "", self.db_) + a = athlete.Athlete.fromArgs(r['Id'], r['FirstName'], r['LastName'], r['Gender'], r['Country'], r['Club'] if 'Club' in r else "", self.db_) logging.debug("Athlete: %s %s %s %s %s", r['Id'], r['FirstName'], r['LastName'], r['Gender'], r['Country']) a.associateWithComp(self.id_) logging.debug("Number of athletes: %d", self.number_of_athletes) if sr_ids is not None: for sr in sr_ids: - self.setRegistration(sr[0], True, False, "specialranking") + self.setRegistration(sr[0], True, "specialranking", warn=False) self.save() @@ -351,12 +337,11 @@ def refresh(self): card = "nan" penalty = "nan" block = blocks[dis] - print(block, dis, blocks) if rp is not None: self.db_.execute('''INSERT INTO start (competition_athlete_id, discipline, lane, OT, AP, rp, card, penalty, remarks, block) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', (ca_id[0][0], dis, lane, u.convTime(ot), ap, rp, card, penalty, remarks, block)) else: self.db_.execute('''INSERT INTO start @@ -385,7 +370,6 @@ def setRegistration(self, athlete_id, is_checked, change_type, warn=True): return 1 def getSavedCompetitions(self): - os.chdir(self.config.storage_folder) saved_comp_info = [] comps = self.db_.execute("SELECT id, name, save_date FROM competition") if comps is None: @@ -443,7 +427,7 @@ def load(self, comp_id): FROM competition WHERE id=?''', comp_id) if load_data is None: - logging.error("Could not find competition with id '" + id + "'") + logging.error("Could not find competition with id '" + str(comp_id) + "'") self.id_ = None return None else: @@ -651,7 +635,7 @@ def updateStartList(self, day, block, to_remove, startlist): WHERE s.id == ? AND ca.competition_id == ?''', (ca_id, self.id_)) if ca_id is None: - log.warning("Invalid athlete not added to competition") + logging.warning("Invalid athlete not added to competition") continue ot = self.cleanTime(startlist[i]["OT"]) dive_time = self.getMinFromTime(self.cleanTime(startlist[i]["Dive Time"])) if "Dive Time" in startlist[i].keys() else 0 @@ -669,13 +653,13 @@ def updateStartList(self, day, block, to_remove, startlist): '''INSERT INTO start (competition_athlete_id, discipline, block, lane, OT, AP, PB, dive_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', - (ca_id[0][0], discipline, block, lane, u.convTime(ot), ap, pb, dive_time)); + (ca_id[0][0], discipline, block, lane, u.convTime(ot), ap, pb, dive_time)) else: #update start self.db_.execute( '''UPDATE start SET discipline=?, block=?, lane=?, OT=?, AP=?, PB=?, dive_time=? WHERE id == ?''', - (discipline, block, lane, u.convTime(ot), ap, pb, dive_time, ca_id[0][0])); + (discipline, block, lane, u.convTime(ot), ap, pb, dive_time, ca_id[0][0])) return 0 def convertPerformance(self, val, dis): @@ -695,107 +679,6 @@ def getWTfromOT(self, ot): wt = str(math.floor(wtf/60)) + ":" + str(wtf%60).zfill(2) return wt - def getStartListPDF(self, day="all", block="all", in_memory=False): - if day=="all" and block=="all": - day_block = self.getBlocks() - files = [] - for d in day_block: - for block in day_block[d].keys(): - files.append(self.getStartListPDF(d, block, True)) - pages = [] - for doc in files: - for page in doc.pages: - pages.append(page) - merged_pdf = files[0].copy(pages) - fname = os.path.join(self.config.download_folder, self.name + "_start_lists.pdf") - merged_pdf.write_pdf(fname) - return fname - start_df = pd.DataFrame(self.getStartList(day, block)) - start_df.drop("Id", axis=1, inplace=True) - blocks = self.getBlocks() - block_disciplines = blocks[day][int(block)]['dis_s'] - if self.comp_type == "aida": - start_df.drop("PB", axis=1, inplace=True) - elif block_disciplines == "STA": - start_df["AP"] = start_df.apply(lambda row: row["AP"] if row['Discipline'] == "STA" else "", axis=1) - self.dropUnusedColumns(block_disciplines, start_df) - html_string = start_df.to_html(index=False, justify="left", classes="df_table") - day_obj = datetime.strptime(day, "%Y-%m-%d") - human_day = day_obj.strftime("%d. %m. %Y") - dis = self.db_.execute('SELECT disciplines FROM block WHERE competition_id==? AND id==?', (self.id_, block)) - if dis is None: - return None - disciplines = self.disciplineIntToStr(dis[0][0]) - html_string = """ - - - - - -
-

{}

-

Start list {} - {}

-
- {} - - - - """.format(self.name, disciplines, human_day, html_string, self.sponsor_img_data, self.sponsor_img_width, self.sponsor_img_height) - html = wp.HTML(string=html_string, base_url="/") - #fname = os.path.join(self.config.download_folder, "test.html") - #with open(fname, "w") as f: - # f.write(html_string) - if in_memory: - return html.render() - else: - fname = os.path.join(self.config.download_folder, self.name + "_start_list_" + day + "_" + disciplines.replace(', ', '_') + ".pdf") - html.write_pdf(fname) - return fname - def getLaneList(self, day, block, lane): lane_db = self.laneStyleConverter(lane, True) db_out = self.db_.execute('''SELECT a.first_name, a.last_name, s.AP, s.OT, a.country, a.gender, a.id, s.id, s.PB, s.discipline, s.RP, s.card, s.remarks, s.dive_time @@ -835,215 +718,6 @@ def getNr(self, country, cls, gender, discipline, convert=False): else: return "" - # Drops columsn which are not used under certain conditions, e.g. dive time for pool disciplines. - def dropUnusedColumns(self, block_disciplines, df): - block_comma_idx = block_disciplines.find(",") - if block_comma_idx == -1: - # drop discipline if only one - if "Discipline" in df.columns.tolist(): - df.drop("Discipline", axis=1, inplace=True) - elif "Dis" in df.columns.tolist(): - df.drop("Dis", axis=1, inplace=True) - - # drop AP for CMAS, except for STA - if self.comp_type == "cmas" and block_disciplines != "STA": - lane_df.drop("AP", axis=1, inplace=True) - - # drop dive time for pool disciplines - if not block_disciplines in DEPTH_DISCIPLINES: - df.drop("Dive Time", axis=1, inplace=True) - - elif not block_disciplines[:block_comma_idx] in DEPTH_DISCIPLINES: - df.drop("Dive Time", axis=1, inplace=True) - - def getLaneListPDF(self, safety=False, day="all", block="all", lane="all", in_memory=False): - if day=="all" and block=="all": - blocks = self.getBlocks() - files = [] - for day in blocks: - for block in blocks[day].keys(): - for lane in blocks[day][block]['lanes']: - files.append(self.getLaneListPDF(safety, day, block, lane, True)) - pages = [] - for doc in files: - for page in doc.pages: - pages.append(page) - merged_pdf = files[0].copy(pages) - fname = os.path.join(self.config.download_folder, self.name + "_lane_lists.pdf") - merged_pdf.write_pdf(fname) - return fname - ret, content = self.getLaneList(day, block, lane) - lane_df = pd.DataFrame(content['lane_list']) - lane_df.drop("id", axis=1, inplace=True) - lane_df.drop("s_id", axis=1, inplace=True) - lane_df.drop("RP", axis=1, inplace=True) - lane_df.drop("Card", axis=1, inplace=True) - lane_df.drop("Remarks", axis=1, inplace=True) - if safety: - lane_df.drop("Nat", axis=1, inplace=True) - lane_df.drop("NR", axis=1, inplace=True) - - blocks = self.getBlocks() - block_str = blocks[day][int(block)]['dis_s'] - block_str_underscore = block_str.replace(', ', '_') - has_multiple_dis = ',' in block_str - self.dropUnusedColumns(block_str, lane_df) - if not safety: - lane_df["RP"] = "" - lane_df["Card"] = "" - lane_df["Remarks"] = "" - col_weight = { - "OT": 4, - "Name": 25, - "Nat": 4, - "AP": 4, - "Dive Time": 4, - "Dis": 4, - "RP": 10, - "Card": 10, - "Remarks": 40, - "PB": 4, - "NR": 4} - cols = lane_df.columns.tolist() - if not safety: - dive_time_col = ["Dive Time"] if "Dive Time" in cols else [] - dis_col = ["Dis"] if has_multiple_dis else [] - if self.comp_type == "aida": - cols = ['OT', 'Name', 'Nat', 'AP'] + dive_time_col + dis_col + ['RP', 'Card', 'Remarks', 'PB', 'NR'] - else: - ap_col = ['AP'] if block_str == "STA" else [] - cols = ['OT', 'Name', 'Nat'] + ap_col + ['PB'] + dive_time_col + dis_col + ['RP', 'Card', 'Remarks', 'NR'] - sum_col_weight = sum([col_weight[c] for c in cols]) - lane_df = lane_df[cols] - df_html = lane_df.to_html(index=False, justify="left", classes="df_table") - day_obj = datetime.strptime(day, "%Y-%m-%d") - human_day = day_obj.strftime("%d. %m. %Y") - html_string = """ - - - - - -
-

Safety lane list {} - lane {} - {}

-
- {} - - - - """.format(block_str, lane, human_day, df_html) - else: - html_string += """ - @page {{ - margin: 4cm 1cm 1.5cm 2.5cm; - size: A4 landscape; - @top-right {{ - counter(page) "/" counter(pages); - }} - }} - header {{ - /* subtract @page margin */ - top: -4cm; - height: 4cm; - text-align: center; - vertical-align: center; - }} - footer {{ - /* subtract @page margin */ - bottom: -1.5cm; - height: 1.5cm; - text-align: left; - vertical-align: center; - }} - - - -
-

{}

-

Lane list {} - lane {} - {}

-
- {} - - - - """.format(self.name, block_str, lane, human_day, df_html) - html = wp.HTML(string=html_string, base_url="/") - fname = os.path.join(self.config.download_folder, "test.html") - with open(fname, "w") as f: - f.write(html_string) - if in_memory: - return html.render() - else: - fname = os.path.join(self.config.download_folder, self.name + "_lane_list_" + day + "_" + block_str_underscore + "_" + lane + ".pdf") - html.write_pdf(fname) - return fname - def getResult(self, discipline, gender, country, with_empty=True): if self.comp_file is None: return -1, None @@ -1066,7 +740,7 @@ def getResult(self, discipline, gender, country, with_empty=True): if country != 'International': cmd += " AND a.country = ?" args += (country, ) - cmd += self.addEligibleCheck(); + cmd += self.addEligibleCheck() if discipline == self.special_ranking_name: cmd += " AND ca.special_ranking" @@ -1202,171 +876,15 @@ def sortResultsWeightsAida(self, r): def computePoints(self, rp, penalty, card, remarks, discipline): if card == "RED" or remarks == "DNS" or rp is None: return 0. - if self.comp_type == "CMAS": - return max(rp - penalty, 0.0) - else: - if discipline == "STA": - return max(int(rp)*0.2 - penalty, 0.0) - elif discipline in POOL_DISCIPLINES: - if self.comp_type == "aida": - return max(int(rp)*0.5 - penalty, 0.0) - else: - return max(int(rp*2.)*0.25 - penalty, 0.0) + if discipline == "STA": + return max(int(rp)*0.2 - penalty, 0.0) + elif discipline in POOL_DISCIPLINES: + if self.comp_type == "aida": + return max(int(rp)*0.5 - penalty, 0.0) else: - return max(int(rp) - penalty, 0.0) - - def getResultPDF(self, discipline="all", gender="all", country="all", in_memory=False, top3=False): - if discipline=="all" and gender=="all": - dwd = self.getDaysWithDisciplinesLanes() - files = [] - gender_list = ["F", "M"] - if top3: - gender_list = [gender_list] # if gender is a list, one pdf will contain both genders, for top 3 - for d in self.getDisciplines(): - for g in gender_list: - for c in self.getCountries(True): - pdf = self.getResultPDF(d, g, c, True, top3) - if pdf is not None: - files.append(pdf) - pages = [] - for doc in files: - for page in doc.pages: - pages.append(page) - merged_pdf = files[0].copy(pages) - fname = os.path.join(self.config.download_folder, self.name + "_results") - if top3: - fname += "_top3" - fname += ".pdf" - merged_pdf.write_pdf(fname) - return fname - - html_string = """ - {} -
-

{}

-

Result {} - {}""".format(self.getHtmlHeader(), self.name, discipline, country) - if top3: - gender_list = gender # for top 3, this is ["F", "M"] + return max(int(rp*2.)*0.25 - penalty, 0.0) else: - gender_str = "Female" if gender == "F" else "Male" - html_string += " - " + gender_str - gender_list = [gender] # for all others a string - html_string += """

-
- """ - for g in gender_list: - ret, content = self.getResult(discipline, g, country, False) - if content is None: - return None - result = content['results'] - result_keys = content['keys'] - result_df = pd.DataFrame(result) - if len(result_df.index) == 0: - return None - if self.comp_type == "cmas": - result_df.drop("Points", axis=1, inplace=True) - if "Id" in result_df.columns.tolist(): - result_df.drop("Id", axis=1, inplace=True) - if "AP_float" in result_df.columns.tolist(): - result_df.drop("AP_float", axis=1, inplace=True) - if "OT" in result_df.columns.tolist(): - result_df.drop("OT", axis=1, inplace=True) - if "JudgeRemarks" in result_df.columns.tolist(): - result_df.drop("JudgeRemarks", axis=1, inplace=True) - if top3: - gender_str = "Female" if g == "F" else "Male" - html_string += "

" + gender_str + "

\n" - # drop all entries where rank is > 3 - # first find one where this is true - result_df['Rank'] = pd.to_numeric(result_df['Rank'], errors='coerce') - remainder = result_df[(result_df['Rank'] > 3)] - if len(remainder.index) != 0: - index_to_drop_after = remainder.idxmin(numeric_only=True)[0] - # keep only ones before - result_df = result_df.loc[:index_to_drop_after-1] - # convert back to strings - result_df['Rank'] = result_df['Rank'].replace(np.nan, 0).astype(int).astype(str).replace('0', '') - # remove all Red cards - result_df = result_df[(result_df['Card'] != "RED")] - html_string += result_df.to_html(index=False, justify="left", classes="df_table") + "\n" - html_string = html_string.replace("<b>", "") - html_string = html_string.replace("</b>", "") - html_string += self.getHtmlFooter(); - - html = wp.HTML(string=html_string, base_url="/") - fname = os.path.join(self.config.download_folder, "test.html") - with open(fname, "w") as f: - f.write(html_string) - if in_memory: - return html.render() - else: - fname = os.path.join(self.config.download_folder, self.name + "_result_" + discipline + "_" + gender + "_" + country + ".pdf") - html.write_pdf(fname) - return fname - - def getHtmlHeader(self): - html_header = """ - - - - - """ - return html_header - - def getHtmlFooter(self): - html_footer = """ - - - - """.format(self.sponsor_img_data, self.sponsor_img_width, self.sponsor_img_height) - return html_footer + return max(int(rp) - penalty, 0.0) def changeSelectedCountry(self, country): if country == "none": @@ -1459,10 +977,7 @@ def parseTime(self, date): return str(date) def formatDiveTime(self, dive_time_seconds): - minutes = 0 - seconds = 0 - if dive_time_seconds >= 60: - minutes = dive_time_seconds % 60 + minutes = dive_time_seconds // 60 seconds = dive_time_seconds - minutes*60 return str(minutes) + ":" + str(seconds).zfill(2) @@ -1896,7 +1411,7 @@ def getFourStarts(self, current, offset): ORDER BY s.lane LIMIT 4'''.format(comp, order, order) min_shift = 3 if self.comp_type == "cmas" else 2 - now = datetime.utcnow() + timedelta(minutes=min_shift) + timedelta(milliseconds=offset) + now = datetime.now(timezone.utc) + timedelta(minutes=min_shift) + timedelta(milliseconds=offset) today = now.year*10000 + now.month*100 + now.day time = now.hour*100 + now.minute db_out = self.db_.execute(cmd, (self.id_, today*10000 + time)) @@ -1987,7 +1502,7 @@ def getIndividualResults(r): 'points': r['Points'] if 'Points' in keys else None } for r in results]} except Exception as e: - print(e.msg) + logging.debug("Failed to get result list: %s", e) return -1, None def isMainDiscipline(self, discipline): @@ -2067,6 +1582,12 @@ def storeResults(self, fpath): df.at[i, 'Remarks'] = 'DNS' continue db_row = db_out[j] + if db_row[0] is None: + # start exists but has no result recorded yet + df.at[i, 'Card'] = '' + df.at[i, 'Remarks'] = db_row[3] if db_row[3] is not None else '' + db_out.pop(j) + continue if s[1] == "STA": rp_min = int(db_row[0] / 60) df.at[i, 'Meters or Min.1'] = rp_min diff --git a/compy_flask.py b/compy_flask.py index 0811fce..0fe3c82 100644 --- a/compy_flask.py +++ b/compy_flask.py @@ -315,7 +315,7 @@ def changeCompName(self): data['status'] = 'success' if data['file_exists']: data["status_msg"] = "File exists" - data["prev_name"] = name + data["prev_name"] = data['name'] else: data["status_msg"] = "Successfully changed competition name to '" + comp_name + "'" data["prev_name"] = "" @@ -705,7 +705,7 @@ def getAthletes(self): return data, 200 def nationalRecords(self): - return self.handleRequest(request, None, CompyData.updateNationalRecords); + return self.handleRequest(request, None, CompyData.updateNationalRecords) def getJudgeComp(self, comp_id, judge_id, return_json = False): judge_hash = request.args.get('hash') @@ -743,7 +743,7 @@ def isValidJudge(self, request, request_id): def getJudgeAthletes(self): request_id = uuid.uuid4() if not self.isValidJudge(request, request_id): - content = {"version": CompyData.version} + content = {"version": self.data_.version} return make_response(render_template('404.html', **content), 404) return self.laneListNew(request_id) diff --git a/compy_pdf.py b/compy_pdf.py new file mode 100644 index 0000000..f21b707 --- /dev/null +++ b/compy_pdf.py @@ -0,0 +1,527 @@ + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# ━━━━━━━━━━━━━ +# ┏┓┏┓┳┳┓┏┓┓┏ +# ┃ ┃┃┃┃┃┃┃┗┫ +# ┗┛┗┛┛ ┗┣┛┗┛ +# ━━━━━━━━━━━━━ +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# Competition organization tool +# for freediving competitions. +# +# Copyright 2023 - Arno Mayrhofer +# +# Licensed under the GNU AGPL +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# Authors: +# +# - Arno Mayrhofer +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +"""PDF report generation for Compy (start lists, lane lists, results). + +This module contains the PdfReportMixin which is mixed into CompyData. +All methods here only read competition state and produce weasyprint PDFs. +""" + +import logging +import os +from datetime import datetime + +import numpy as np +import pandas as pd + +from compy_constants import DEPTH_DISCIPLINES + +_wp = None +_wp_failed = False + + +def weasyprint(): + """Lazily import weasyprint so the app can run without it (no PDF export). + + On macOS weasyprint needs its native libraries: brew install pango + (and possibly: export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib). + """ + global _wp, _wp_failed + if _wp is None and not _wp_failed: + try: + import weasyprint + _wp = weasyprint + except (ImportError, OSError) as e: + _wp_failed = True + logging.error("PDF export disabled, weasyprint could not be loaded: %s", e) + logging.error("Install it with 'pip3 install weasyprint'. On macOS also run " + "'brew install pango' (and if the libraries are still not found: " + "'export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib').") + return _wp + + +class PdfReportMixin: + + def getStartListPDF(self, day="all", block="all", in_memory=False): + wp = weasyprint() + if wp is None: + return None + if day == "all" and block == "all": + day_block = self.getBlocks() + files = [] + for d in day_block: + for block in day_block[d].keys(): + files.append(self.getStartListPDF(d, block, True)) + pages = [] + for doc in files: + for page in doc.pages: + pages.append(page) + merged_pdf = files[0].copy(pages) + fname = os.path.join(self.config.download_folder, self.name + "_start_lists.pdf") + merged_pdf.write_pdf(fname) + return fname + start_df = pd.DataFrame(self.getStartList(day, block)) + start_df.drop("Id", axis=1, inplace=True) + blocks = self.getBlocks() + block_disciplines = blocks[day][int(block)]['dis_s'] + if self.comp_type == "aida": + start_df.drop("PB", axis=1, inplace=True) + elif block_disciplines == "STA": + start_df["AP"] = start_df.apply(lambda row: row["AP"] if row['Discipline'] == "STA" else "", axis=1) + self.dropUnusedColumns(block_disciplines, start_df) + html_string = start_df.to_html(index=False, justify="left", classes="df_table") + day_obj = datetime.strptime(day, "%Y-%m-%d") + human_day = day_obj.strftime("%d. %m. %Y") + dis = self.db_.execute('SELECT disciplines FROM block WHERE competition_id==? AND id==?', (self.id_, block)) + if dis is None: + return None + disciplines = self.disciplineIntToStr(dis[0][0]) + html_string = """ + + + + + +
+

{}

+

Start list {} - {}

+
+ {} + + + + """.format(self.name, disciplines, human_day, html_string, self.sponsor_img_data, self.sponsor_img_width, self.sponsor_img_height) + html = wp.HTML(string=html_string, base_url="/") + if in_memory: + return html.render() + else: + fname = os.path.join(self.config.download_folder, self.name + "_start_list_" + day + "_" + disciplines.replace(', ', '_') + ".pdf") + html.write_pdf(fname) + return fname + + # Drops columns which are not used under certain conditions, e.g. dive time for pool disciplines. + def dropUnusedColumns(self, block_disciplines, df): + block_comma_idx = block_disciplines.find(",") + if block_comma_idx == -1: + # drop discipline if only one + if "Discipline" in df.columns.tolist(): + df.drop("Discipline", axis=1, inplace=True) + elif "Dis" in df.columns.tolist(): + df.drop("Dis", axis=1, inplace=True) + + # drop AP for CMAS, except for STA + if self.comp_type == "cmas" and block_disciplines != "STA": + df.drop("AP", axis=1, inplace=True) + + # drop dive time for pool disciplines + if not block_disciplines in DEPTH_DISCIPLINES: + df.drop("Dive Time", axis=1, inplace=True) + + elif not block_disciplines[:block_comma_idx] in DEPTH_DISCIPLINES: + df.drop("Dive Time", axis=1, inplace=True) + + def getLaneListPDF(self, safety=False, day="all", block="all", lane="all", in_memory=False): + wp = weasyprint() + if wp is None: + return None + if day == "all" and block == "all": + blocks = self.getBlocks() + files = [] + for day in blocks: + for block in blocks[day].keys(): + for lane in blocks[day][block]['lanes']: + files.append(self.getLaneListPDF(safety, day, block, lane, True)) + pages = [] + for doc in files: + for page in doc.pages: + pages.append(page) + merged_pdf = files[0].copy(pages) + fname = os.path.join(self.config.download_folder, self.name + "_lane_lists.pdf") + merged_pdf.write_pdf(fname) + return fname + ret, content = self.getLaneList(day, block, lane) + lane_df = pd.DataFrame(content['lane_list']) + lane_df.drop("id", axis=1, inplace=True) + lane_df.drop("s_id", axis=1, inplace=True) + lane_df.drop("RP", axis=1, inplace=True) + lane_df.drop("Card", axis=1, inplace=True) + lane_df.drop("Remarks", axis=1, inplace=True) + if safety: + lane_df.drop("Nat", axis=1, inplace=True) + lane_df.drop("NR", axis=1, inplace=True) + + blocks = self.getBlocks() + block_str = blocks[day][int(block)]['dis_s'] + block_str_underscore = block_str.replace(', ', '_') + has_multiple_dis = ',' in block_str + self.dropUnusedColumns(block_str, lane_df) + if not safety: + lane_df["RP"] = "" + lane_df["Card"] = "" + lane_df["Remarks"] = "" + col_weight = { + "OT": 4, + "Name": 25, + "Nat": 4, + "AP": 4, + "Dive Time": 4, + "Dis": 4, + "RP": 10, + "Card": 10, + "Remarks": 40, + "PB": 4, + "NR": 4} + cols = lane_df.columns.tolist() + if not safety: + dive_time_col = ["Dive Time"] if "Dive Time" in cols else [] + dis_col = ["Dis"] if has_multiple_dis else [] + if self.comp_type == "aida": + cols = ['OT', 'Name', 'Nat', 'AP'] + dive_time_col + dis_col + ['RP', 'Card', 'Remarks', 'PB', 'NR'] + else: + ap_col = ['AP'] if block_str == "STA" else [] + cols = ['OT', 'Name', 'Nat'] + ap_col + ['PB'] + dive_time_col + dis_col + ['RP', 'Card', 'Remarks', 'NR'] + sum_col_weight = sum([col_weight[c] for c in cols]) + lane_df = lane_df[cols] + df_html = lane_df.to_html(index=False, justify="left", classes="df_table") + day_obj = datetime.strptime(day, "%Y-%m-%d") + human_day = day_obj.strftime("%d. %m. %Y") + html_string = """ + + + + + +
+

Safety lane list {} - lane {} - {}

+
+ {} + + + + """.format(block_str, lane, human_day, df_html) + else: + html_string += """ + @page {{ + margin: 4cm 1cm 1.5cm 2.5cm; + size: A4 landscape; + @top-right {{ + content: counter(page) "/" counter(pages); + }} + }} + header {{ + /* subtract @page margin */ + top: -4cm; + height: 4cm; + text-align: center; + vertical-align: center; + }} + footer {{ + /* subtract @page margin */ + bottom: -1.5cm; + height: 1.5cm; + text-align: left; + vertical-align: center; + }} + + + +
+

{}

+

Lane list {} - lane {} - {}

+
+ {} + + + + """.format(self.name, block_str, lane, human_day, df_html) + html = wp.HTML(string=html_string, base_url="/") + if in_memory: + return html.render() + else: + fname = os.path.join(self.config.download_folder, self.name + "_lane_list_" + day + "_" + block_str_underscore + "_" + lane + ".pdf") + html.write_pdf(fname) + return fname + + def getResultPDF(self, discipline="all", gender="all", country="all", in_memory=False, top3=False): + wp = weasyprint() + if wp is None: + return None + if discipline == "all" and gender == "all": + files = [] + gender_list = ["F", "M"] + if top3: + gender_list = [gender_list] # if gender is a list, one pdf will contain both genders, for top 3 + for d in self.getDisciplines(): + for g in gender_list: + for c in self.getCountries(True): + pdf = self.getResultPDF(d, g, c, True, top3) + if pdf is not None: + files.append(pdf) + pages = [] + for doc in files: + for page in doc.pages: + pages.append(page) + merged_pdf = files[0].copy(pages) + fname = os.path.join(self.config.download_folder, self.name + "_results") + if top3: + fname += "_top3" + fname += ".pdf" + merged_pdf.write_pdf(fname) + return fname + + html_string = """ + {} +
+

{}

+

Result {} - {}""".format(self.getHtmlHeader(), self.name, discipline, country) + if top3: + gender_list = gender # for top 3, this is ["F", "M"] + else: + gender_str = "Female" if gender == "F" else "Male" + html_string += " - " + gender_str + gender_list = [gender] # for all others a string + html_string += """

+
+ """ + for g in gender_list: + ret, content = self.getResult(discipline, g, country, False) + if content is None: + return None + result = content['results'] + result_df = pd.DataFrame(result) + if len(result_df.index) == 0: + return None + if self.comp_type == "cmas": + result_df.drop("Points", axis=1, inplace=True) + if "Id" in result_df.columns.tolist(): + result_df.drop("Id", axis=1, inplace=True) + if "AP_float" in result_df.columns.tolist(): + result_df.drop("AP_float", axis=1, inplace=True) + if "OT" in result_df.columns.tolist(): + result_df.drop("OT", axis=1, inplace=True) + if "JudgeRemarks" in result_df.columns.tolist(): + result_df.drop("JudgeRemarks", axis=1, inplace=True) + if top3: + gender_str = "Female" if g == "F" else "Male" + html_string += "

" + gender_str + "

\n" + # drop all entries where rank is > 3 + # first find one where this is true + result_df['Rank'] = pd.to_numeric(result_df['Rank'], errors='coerce') + remainder = result_df[(result_df['Rank'] > 3)] + if len(remainder.index) != 0: + index_to_drop_after = remainder.idxmin(numeric_only=True)[0] + # keep only ones before + result_df = result_df.loc[:index_to_drop_after-1] + # convert back to strings + result_df['Rank'] = result_df['Rank'].replace(np.nan, 0).astype(int).astype(str).replace('0', '') + # remove all Red cards + result_df = result_df[(result_df['Card'] != "RED")] + html_string += result_df.to_html(index=False, justify="left", classes="df_table") + "\n" + html_string = html_string.replace("<b>", "") + html_string = html_string.replace("</b>", "") + html_string += self.getHtmlFooter() + + html = wp.HTML(string=html_string, base_url="/") + if in_memory: + return html.render() + else: + fname = os.path.join(self.config.download_folder, self.name + "_result_" + discipline + "_" + gender + "_" + country + ".pdf") + html.write_pdf(fname) + return fname + + def getHtmlHeader(self): + html_header = """ + + + + + """ + return html_header + + def getHtmlFooter(self): + html_footer = """ + + + + """.format(self.sponsor_img_data, self.sponsor_img_width, self.sponsor_img_height) + return html_footer diff --git a/compy_utilities.py b/compy_utilities.py index 1337150..de42bab 100644 --- a/compy_utilities.py +++ b/compy_utilities.py @@ -49,7 +49,6 @@ def convTime(time): return None def getNationalRecordsAida(): - breakpoint() empty_req = requests.post('https://www.aidainternational.org/public_pages/all_national_records.php', data={}) html = empty_req.text start = html.find('id="nationality"') @@ -91,7 +90,7 @@ def getNationalRecordsAida(): p_dis = re.compile("[0-9]+") result = float(p_dis.search(res_str).group(0)) points = float(p.search(entries[i*10 + 6]).group(1)) - nrs[self.NR(federation="aida", country=c_ioc, cls="", gender=gender, discipline=dis)] = result + nrs[NR(federation="aida", country=c_ioc, cls="", gender=gender, discipline=dis)] = result logging.debug("National records:") logging.debug("Country | Gender | Discipline | Result | Points") for key, val in nrs.items(): diff --git a/requirements.txt b/requirements.txt index 46aba92..2150aa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,107 +1,12 @@ -argcomplete==3.5.1 -Beaker==1.12.1 -beautifulsoup4==4.12.3 -blinker==1.8.2 -blivet==3.10.0 -blivet-gui==2.5.0 -boto3==1.35.46 -botocore==1.35.46 -Brlapi==0.8.5 -Brotli==1.1.0 -certifi==2024.6.2 -cffi==1.17.1 -charset-normalizer==3.3.2 -click==8.1.7 -country-converter==1.2 -cryptography==41.0.7 -cssselect2==0.7.0 -cupshelpers==1.0 -dasbus==1.7 -dbus-python==1.3.2 -distro==1.9.0 -dnf==4.19.0 -et-xmlfile==1.1.0 -fedora-third-party==0.10 -file-magic==0.4.0 -Flask==3.0.3 -fonttools==4.53.0 -fros==1.1 -html5lib==1.1 -humanize==3.13.1 -idna==3.7 -iso639==0.1.4 -itsdangerous==2.2.0 -Jinja2==3.1.4 -jmespath==1.0.1 -langtable==0.0.68 -libcomps==0.1.20 -libdnf==0.73.0 -louis==3.28.0 -lxml==5.1.0 -Mako==1.2.3 -MarkupSafe==2.1.5 -mutagen==1.47.0 -nftables==0.1 -numpy==2.0.0 -olefile==0.47 -openpyxl==3.1.5 -packaging==24.2 -pandas==2.2.2 -Paste==3.7.1 -pexpect==4.9.0 -pid==2.2.3 -pillow==10.4.0 -ply==3.11 -productmd==1.38 -ptyprocess==0.7.0 -pwquality==1.4.5 -pycairo==1.25.1 -pycparser==2.22 -pycrypto==2.6.1 -pycryptodomex==3.21.0 -pycups==2.0.4 -pydyf==0.10.0 -pyenchant==3.2.2 -PyGObject==3.48.1 -pykickstart==3.52 -pyOpenSSL==24.2.1 -pyparted==3.13.0 -pyphen==0.15.0 -PySocks==1.7.1 -python-augeas==1.1.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.0.1 -python-meh==0.51 -python-pam==2.0.2 -pytz==2024.1 -pyudev==0.24.1 -pyxdg==0.27 -PyYAML==6.0.1 -qrcode==8.0 -regex==2024.5.15 -requests==2.32.3 -requests-file==2.0.0 -requests-ftp==0.3.1 -rpm==4.19.1.1 -s3transfer==0.10.3 -selinux @ file:///builddir/build/BUILD/libselinux-3.7/src -sepolicy @ file:///builddir/build/BUILD/selinux-3.7/python/sepolicy -setools==4.5.1 -setuptools==75.3.0 -simpleaudio==1.0.4 -simpleline==1.9.0 -six==1.16.0 -sos==4.7.2 -soupsieve==2.5 -systemd-python==235 -Tempita==0.5.2 -tinycss2==1.3.0 -tzdata==2024.1 -urllib3==2.2.2 -weasyprint==62.3 -webencodings==0.5.1 -websockets==13.1 -Werkzeug==3.0.3 -xkbregistry==0.3 -yt-dlp==2024.9.27 -zopfli==0.2.3 +country-converter>=1.2 +Flask>=3.0 +numpy +openpyxl +packaging +pandas>=2.0 +pillow +python-dotenv +qrcode +regex +requests +weasyprint>=60 diff --git a/schemas/break.sql b/schemas/break.sql index 98c5b39..34c40c0 100644 --- a/schemas/break.sql +++ b/schemas/break.sql @@ -3,7 +3,7 @@ DROP TABLE IF EXISTS break; CREATE TABLE break ( id INTEGER PRIMARY KEY AUTOINCREMENT, competition_id INTEGER NOT NULL, - discipline TEXT NOT NULL, + block INTEGER NOT NULL, duration INTEGER NOT NULL, idx INTEGER NOT NULL ); diff --git a/schemas/competition.sql b/schemas/competition.sql index 5032f14..7dab82d 100644 --- a/schemas/competition.sql +++ b/schemas/competition.sql @@ -14,5 +14,5 @@ CREATE TABLE competition ( selected_country TEXT, disciplines INTEGER NOT NULL, special_ranking_name TEXT, - publish_result INTEGER NOT NULL DEFAULT 0 + publish_results INTEGER NOT NULL DEFAULT 0 ); diff --git a/schemas/competition_athlete.sql b/schemas/competition_athlete.sql index 1c3cf98..4ccb71c 100644 --- a/schemas/competition_athlete.sql +++ b/schemas/competition_athlete.sql @@ -7,5 +7,6 @@ CREATE TABLE competition_athlete ( special_ranking BOOL NOT NULL DEFAULT 0, paid BOOL NOT NULL DEFAULT 0, medical_checked BOOL NOT NULL DEFAULT 0, - registered BOOL NOT NULL DEFAULT 0 + registered BOOL NOT NULL DEFAULT 0, + eligible_national BOOL NOT NULL DEFAULT 0 ); diff --git a/schemas/judges.sql b/schemas/judges.sql index b53effe..8e9648f 100644 --- a/schemas/judges.sql +++ b/schemas/judges.sql @@ -4,6 +4,6 @@ CREATE TABLE judge ( id INTEGER PRIMARY KEY AUTOINCREMENT, first_name TEXT NOT NULL, last_name TEXT NOT NULL, - qr_code TEXT NOT NULL, + salt TEXT NOT NULL, competition_id INTEGER NOT NULL ); diff --git a/static/compy.css b/static/compy.css index 38e972e..49587d4 100644 --- a/static/compy.css +++ b/static/compy.css @@ -1,40 +1,384 @@ +/* + * Compy admin stylesheet + * Shares the color system of judge.css / results.css. + */ + +:root { + --dark: #002F6C; + --medium: #005B96; + --light: #00A4CC; + --highlight: #AEECEF; + --white: #F0F8FF; + --card-yellow: #ECDE41; + --card-red: #F83741; + --bg: #F2F6FA; + --surface: #FFFFFF; + --border: #D5E1EC; + --text: #1C2B3A; + --text-muted: #5B7288; + --radius: 8px; + --shadow: 0 1px 3px rgba(0, 47, 108, 0.10), 0 4px 14px rgba(0, 47, 108, 0.06); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.45; +} + +body { + padding: 0 24px 48px 24px; + max-width: 1200px; + margin: 0 auto; +} + +/* ---- Header ---- */ + +h1 { + margin: 0 -24px 12px -24px; + padding: 18px 24px; + background: linear-gradient(90deg, var(--dark), var(--medium)); + color: var(--white); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.5px; +} + +#timers { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 10px 16px; + margin-bottom: 16px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} + +#timers #time, +#timers #countdown { + color: var(--dark); + font-weight: 600; +} + +/* ---- Main navigation (tab bar) ---- */ + +#main_nav { + width: 100%; + border-collapse: separate; + border-spacing: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 4px; + margin-bottom: 20px; +} + +#main_nav td { + text-align: center; + border-radius: 6px; + transition: background 0.15s ease; +} + +#main_nav td:hover { + background: var(--highlight); +} + +#main_nav td a { + display: block; + padding: 9px 6px; + color: var(--medium); + text-decoration: none; + font-weight: inherit; + white-space: nowrap; +} + +/* ---- Content sections (tabs) ---- */ + +#settings, #judges, #athletes, #registration, #breaks, +#start_lists, #lane_lists, #results, #records { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 20px 24px; + margin-bottom: 24px; +} + +#settings > p:first-child { + margin-top: 0; + font-size: 17px; + font-weight: 600; + color: var(--dark); +} + +#settings > div, +#settings form > div { + margin-bottom: 14px; +} + +#settings label { + color: var(--text-muted); + font-size: 13px; +} + +/* ---- Tables ---- */ + +table { + border-collapse: collapse; +} + +#judges_table, #athletes_table, #registration_table, +#breaks_content table, #sl_content table, #ll_content table, +#results_content table, .df_table { + width: 100%; + background: var(--surface); + margin: 8px 0; +} + +#judges_table td, #athletes_table td, #registration_table td, +#breaks_content td, #sl_content td, #ll_content td, +#results_content td, .df_table td, .df_table th, +#breaks_content th, #sl_content th, #ll_content th, #results_content th { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +/* first row acts as a header in dynamically built tables */ +#judges_table tr:first-child td, +#athletes_table tr:first-child td, +#registration_table tr:first-child td, +#breaks_content table tr:first-child td, +#sl_content table tr:first-child td, +#ll_content table tr:first-child td, +#results_content table tr:first-child td, +.df_table th { + background: var(--dark); + color: var(--white); + font-weight: 600; + font-size: 13px; + letter-spacing: 0.3px; + border-bottom: none; +} + +#judges_table tr:first-child td:first-child, +#athletes_table tr:first-child td:first-child, +#registration_table tr:first-child td:first-child { + border-top-left-radius: 6px; +} + +#judges_table tr:first-child td:last-child, +#athletes_table tr:first-child td:last-child, +#registration_table tr:first-child td:last-child { + border-top-right-radius: 6px; +} + +#judges_table tr:nth-child(even), +#athletes_table tr:nth-child(even), +#registration_table tr:nth-child(even), +#results_content table tr:nth-child(even), +#sl_content table tr:nth-child(even), +#ll_content table tr:nth-child(even), +#breaks_content table tr:nth-child(even) { + background: #F7FAFD; +} + +/* ---- Buttons ---- */ + +button { + background: var(--medium); + color: var(--white); + border: none; + border-radius: 6px; + padding: 7px 14px; + font-size: 14px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s ease, transform 0.05s ease; +} + +button:hover { + background: var(--dark); +} + +button:active { + transform: translateY(1px); +} + +button.delete, button.delete_comp_button, button.remove { + background: transparent; + color: var(--card-red); + border: 1px solid var(--card-red); +} + +button.delete:hover, button.delete_comp_button:hover, button.remove:hover { + background: var(--card-red); + color: var(--white); +} + +button.up, button.down, button.break, button.edit, button.show, +button.sl_sort, button.load_comp_button { + background: transparent; + color: var(--medium); + border: 1px solid var(--light); +} + +button.up:hover, button.down:hover, button.break:hover, button.edit:hover, +button.show:hover, button.sl_sort:hover, button.load_comp_button:hover { + background: var(--highlight); + color: var(--dark); +} + +/* ---- Inputs ---- */ + +input[type="text"], input[type="number"], select { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 7px 10px; + font-size: 14px; + font-family: inherit; + color: var(--text); + margin: 2px 0; +} + +input[type="text"]:focus, input[type="number"]:focus, select:focus { + outline: 2px solid var(--light); + outline-offset: 0; + border-color: var(--light); +} + +input[type="file"] { + font-size: 13px; + color: var(--text-muted); + margin: 4px 0; +} + +input[type="radio"], input[type="checkbox"] { + accent-color: var(--medium); +} + +/* ---- Links ---- */ + +a { + color: var(--medium); +} + +a:hover { + color: var(--dark); +} + +#sl_date_menu a, #sl_discipline_menu a, +#ll_date_menu a, #ll_discipline_menu a, #ll_lane_menu a, +#breaks_date_menu a, +#result_discipline_menu a, #result_gender_menu a, #result_country_menu a { + display: inline-block; + padding: 4px 10px; + margin: 2px 2px 6px 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + text-decoration: none; + font-size: 13px; +} + +#sl_date_menu a:hover, #sl_discipline_menu a:hover, +#ll_date_menu a:hover, #ll_discipline_menu a:hover, #ll_lane_menu a:hover, +#breaks_date_menu a:hover, +#result_discipline_menu a:hover, #result_gender_menu a:hover, #result_country_menu a:hover { + background: var(--highlight); + border-color: var(--light); +} + +/* competition list */ + +#competition_list { + list-style: none; + padding: 0; + margin: 8px 0; + max-width: 640px; +} + +#competition_list li { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 6px; + background: var(--surface); +} + +/* status message */ + +#file_upload_status { + margin: 12px 0; + padding: 10px 14px; + border-radius: 6px; + background: var(--highlight); + color: var(--dark); + border: 1px solid var(--light); +} + +/* overlay */ + +#overlay_box { + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; +} + +/* ---- Utilities (used by compy.js) ---- */ + .stop-scrolling { - height: 100%; - overflow: hidden; - position: fixed; + height: 100%; + overflow: hidden; + position: fixed; } .loader { - width: 15px; - height: 15px; - border-radius: 50%; - position: relative; - animation: rotate 1s linear infinite; - display: inline-block; + width: 15px; + height: 15px; + border-radius: 50%; + position: relative; + animation: rotate 1s linear infinite; + display: inline-block; } .loader::before { - content: ""; - box-sizing: border-box; - position: absolute; - inset: 0px; - border-radius: 50%; - border: 5px solid #111; - animation: prixClipFix 2s linear infinite ; + content: ""; + box-sizing: border-box; + position: absolute; + inset: 0px; + border-radius: 50%; + border: 5px solid var(--dark); + animation: prixClipFix 2s linear infinite; } @keyframes rotate { - 100% {transform: rotate(360deg)} + 100% {transform: rotate(360deg)} } @keyframes prixClipFix { - 0% {clip-path:polygon(50% 50%,0 0,0 0,0 0,0 0,0 0)} - 25% {clip-path:polygon(50% 50%,0 0,100% 0,100% 0,100% 0,100% 0)} - 50% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,100% 100%,100% 100%)} - 75% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 100%)} - 100% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 0)} + 0% {clip-path:polygon(50% 50%,0 0,0 0,0 0,0 0,0 0)} + 25% {clip-path:polygon(50% 50%,0 0,100% 0,100% 0,100% 0,100% 0)} + 50% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,100% 100%,100% 100%)} + 75% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 100%)} + 100% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 0)} } #save_comp { - display: none; + display: none; } diff --git a/static/judge.css b/static/judge.css index 1ae0da1..89a3920 100644 --- a/static/judge.css +++ b/static/judge.css @@ -13,7 +13,7 @@ html, body { padding: 0; background-image: linear-gradient(var(--medium), var(--dark)); height: 100%; - font-family: "sans"; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-attachment: fixed; color: var(--medium); } diff --git a/static/results.css b/static/results.css index 8692fb1..cd79802 100644 --- a/static/results.css +++ b/static/results.css @@ -13,7 +13,7 @@ html, body { padding: 0; background-image: linear-gradient(var(--medium), var(--dark)); height: 100%; - font-family: "sans"; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-attachment: fixed; color: var(--medium); } diff --git a/templates/404.html b/templates/404.html index d50ea12..adc2e6f 100644 --- a/templates/404.html +++ b/templates/404.html @@ -28,11 +28,29 @@ --> Compy {{version}} + -

Compy {{version}}


-

404 - Page not found


- Maybe it went diving? +
+

Compy {{version}}

+

404 — Page not found

+

Maybe it went diving?

+
diff --git a/templates/template.html b/templates/template.html index 18eca7d..fb09bd6 100644 --- a/templates/template.html +++ b/templates/template.html @@ -45,7 +45,7 @@

Compy {{version}}

- +
diff --git a/tools/__pycache__/generate_test_data.cpython-310.pyc b/tools/__pycache__/generate_test_data.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fcaa8a8095976efcac834ebb44174bd37bf484e GIT binary patch literal 5442 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 T`9*{3_^IZ&nKju9(A@t6o@Slk literal 0 HcmV?d00001 diff --git a/tools/generate_test_data.py b/tools/generate_test_data.py new file mode 100644 index 0000000..492c9cd --- /dev/null +++ b/tools/generate_test_data.py @@ -0,0 +1,198 @@ +#!/usr/bin/python3 +# +# Generates an AIDA-International-style competition Excel file that Compy +# can import via "Refresh data" (or the /upload_file endpoint). +# +# Usage: python3 tools/generate_test_data.py [output.xlsx] +# +# Produces a 3-day competition with 30 athletes (CWT, STA, DYN), +# results for days that are in the past, DNS entries, and empty +# results for future days. + +import random +import sys +import uuid +from datetime import date, timedelta + +from openpyxl import Workbook + +random.seed(42) + +ATHLETES = [ + # first, last, gender, country, club + ("Anna", "Berger", "F", "AUT", "Apnea Vienna"), + ("Lukas", "Steiner", "M", "AUT", "Apnea Vienna"), + ("Marie", "Novak", "F", "CZE", "Freedive Praha"), + ("Tomas", "Dvorak", "M", "CZE", "Freedive Praha"), + ("Jana", "Kovacova", "F", "SVK", "Blue Hole Bratislava"), + ("Peter", "Molnar", "M", "SVK", "Blue Hole Bratislava"), + ("Clara", "Schmidt", "F", "GER", "Apnoe Berlin"), + ("Felix", "Wagner", "M", "GER", "Apnoe Berlin"), + ("Sophie", "Mueller", "F", "GER", "Deep Munich"), + ("Jonas", "Fischer", "M", "GER", "Deep Munich"), + ("Chiara", "Rossi", "F", "ITA", "Apnea Torino"), + ("Marco", "Bianchi", "M", "ITA", "Apnea Torino"), + ("Elena", "Ricci", "F", "ITA", "Y-40 Divers"), + ("Luca", "Moretti", "M", "ITA", "Y-40 Divers"), + ("Camille", "Laurent", "F", "FRA", "Nice Freedive"), + ("Hugo", "Moreau", "M", "FRA", "Nice Freedive"), + ("Lea", "Dubois", "F", "FRA", "AIDA Marseille"), + ("Nathan", "Petit", "M", "FRA", "AIDA Marseille"), + ("Ivana", "Horvat", "F", "CRO", "Adriatic Apnea"), + ("Ante", "Kovacevic", "M", "CRO", "Adriatic Apnea"), + ("Maja", "Zupan", "F", "SLO", "Bled Freediving"), + ("Luka", "Kranjc", "M", "SLO", "Bled Freediving"), + ("Zofia", "Kowalska", "F", "POL", "Warsaw Apnea"), + ("Jakub", "Nowak", "M", "POL", "Warsaw Apnea"), + ("Carmen", "Garcia", "F", "ESP", "Apnea Canarias"), + ("Diego", "Martinez", "M", "ESP", "Apnea Canarias"), + ("Emma", "Jansen", "F", "NED", "Dutch Depth Collective"), + ("Daan", "Visser", "M", "NED", "Dutch Depth Collective"), + ("Nora", "Haugen", "F", "NOR", "Oslo Fridykking"), + ("Erik", "Berg", "M", "NOR", "Oslo Fridykking"), +] + +# discipline per day; lanes available per discipline +DAY_PLAN = [ + ("CWT", 4), # day 1: depth + ("STA", 4), # day 2: pool static + ("DYN", 4), # day 3: pool dynamic +] + +CARDS_WEIGHTED = ["WHITE"]*7 + ["YELLOW"]*2 + ["RED"] +REMARKS_WHITE = ["OK", "OK", "OK", "CLEAN DIVE"] +REMARKS_YELLOW = ["EARLY TURN", "UNDER AP"] +REMARKS_RED = ["LANYARD", "SURFACE PROTOCOL", "BLACKOUT"] + + +def perf_ap(dis, gender): + """Announced performance per discipline.""" + if dis == "CWT": + base = random.randint(35, 85) + (5 if gender == "M" else 0) + return base, None # meters + if dis == "STA": + m = random.randint(3, 6) + s = random.choice([0, 15, 30, 45]) + return m, s # minutes, seconds + if dis == "DYN": + base = random.randint(50, 175) + (10 if gender == "M" else 0) + return base, None # meters + raise ValueError(dis) + + +def perf_rp(dis, ap_main, ap_sec, card): + """Realized performance derived from AP and card.""" + if card == "WHITE": + if dis == "STA": + total = ap_main*60 + (ap_sec or 0) + random.randint(0, 40) + return total // 60, total % 60 + return ap_main + random.randint(0, 6), None + else: # YELLOW (turned early / under AP), RED still has an RP + if dis == "STA": + total = max(60, ap_main*60 + (ap_sec or 0) - random.randint(10, 60)) + return total // 60, total % 60 + return max(10, ap_main - random.randint(1, 15)), None + + +def main(outfile): + today = date.today() + start = today - timedelta(days=2) # two days done, today still running + days = [start + timedelta(days=i) for i in range(len(DAY_PLAN))] + + athletes = [(str(uuid.uuid4()),) + a for a in ATHLETES] + + wb = Workbook() + + # --- Event sheet --- + ws = wb.active + ws.title = "Event" + ws.append(["Event", ""]) + ws.append(["Name:", "Compy Test Open " + str(today.year)]) + ws.append(["Starts:", days[0].isoformat()]) + ws.append(["Ends:", days[-1].isoformat()]) + ws.append(["Disciplines:", ",".join(d for d, _ in DAY_PLAN)]) + ws.append(["Federation:", "AIDA"]) + + # --- Athletes and Judges sheet --- + ws = wb.create_sheet("Athletes and Judges") + ws.append(["Athletes"] + [""]*5) + ws.append(["Id", "FirstName", "LastName", "Gender", "Country", "Club"]) + for aid, first, last, gender, country, club in athletes: + ws.append([aid, first, last, gender, country, club]) + + # --- Settings sheet (copied verbatim by Compy when storing results) --- + ws = wb.create_sheet("Settings") + ws.append(["Setting", "Value"]) + ws.append(["Countdown", "AIDA"]) + + # --- one sheet per competition day --- + header = ["Diver Id", "Discipline", "Name", "OT", "Zone", + "Meters or Min", "Sec(STA only)", # AP (F, G) + "Meters or Min", "Sec(STA only)", # RP (H, I) + "Pen(UNDER AP)", "Pen(other)", # J, K + "Card", "Remarks"] + + for day, (dis, n_lanes) in zip(days, DAY_PLAN): + ws = wb.create_sheet(day.isoformat()) + ws.append([""]*5 + ["AP", "", "RP", "", "Penalties", "", "", ""]) + ws.append(header) + + starters = athletes[:] # everyone starts every day + random.shuffle(starters) + day_done = day < date.today() + + ot_hour, ot_min = 9, 0 + lane = 1 + for aid, first, last, gender, country, club in starters: + ap_main, ap_sec = perf_ap(dis, gender) + ot = "%02d:%02d" % (ot_hour, ot_min) + + row = [aid, dis, first + " " + last, ot, lane, + ap_main, ap_sec if ap_sec is not None else None] + + if day_done: + if random.random() < 0.07: # a few no-shows + row += [None, None, None, None, None, "DNS"] + else: + card = random.choice(CARDS_WEIGHTED) + rp_main, rp_sec = perf_rp(dis, ap_main, ap_sec, card) + pen_other = 1.0 if (card == "YELLOW" and random.random() < 0.3) else 0 + pen_under = 0. + if card == "YELLOW": + # AIDA under-AP penalty: 0.2/s for STA, 0.5/m dynamic, 1/m depth + if dis == "STA": + ap_v = ap_main*60 + (ap_sec or 0) + rp_v = rp_main*60 + (rp_sec or 0) + factor = 0.2 + else: + ap_v, rp_v = ap_main, rp_main + factor = 0.5 if dis[0] == "D" else 1. + pen_under = round(max(ap_v - rp_v, 0)*factor, 1) + if card == "WHITE": + remarks = random.choice(REMARKS_WHITE) + elif card == "YELLOW": + remarks = random.choice(REMARKS_YELLOW) + else: + remarks = random.choice(REMARKS_RED) + row += [rp_main, rp_sec, pen_under, pen_other, card, remarks] + else: + row += [None, None, None, None, None, None] + + ws.append(row) + + lane += 1 + if lane > n_lanes: + lane = 1 + ot_min += 15 if dis != "STA" else 12 + if ot_min >= 60: + ot_min -= 60 + ot_hour += 1 + + wb.save(outfile) + print("Wrote", outfile) + print("Competition days:", ", ".join(d.isoformat() for d in days)) + print("Athletes:", len(athletes)) + + +if __name__ == '__main__': + main(sys.argv[1] if len(sys.argv) > 1 else "test_competition.xlsx")