Compare commits

...

118 Commits

Author SHA1 Message Date
b485791d25 Bug: Update method signature in single-user raceguess page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-12-08 20:01:30 +01:00
e8c8e35e05 Disable info cards regarding season picks
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 11s
2024-12-08 19:57:24 +01:00
15305b2f3e Bug: Fix statistics issues caused by driver substitutions
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 16s
2024-12-08 19:52:02 +01:00
0dc4b22c72 Commented out leaderboard diagram extension for season points
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 16s
2024-12-08 19:38:31 +01:00
cef40a9e8b Implement season guess evaluation
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-12-08 19:25:06 +01:00
c509746688 Template: Mark wrong season guesses with red border
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 27s
2024-12-08 17:22:35 +01:00
9cdd7267db Bug: Fix missing inactive drivers in season guesses 2024-12-08 17:22:14 +01:00
61d247508f Bug: Fix duplicated place numbering in points model
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-10-19 14:01:46 +02:00
f38a5f2e6d Bug: Call delete_memoized with function references
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 33s
2024-10-19 13:41:14 +02:00
95760baebf Flake: Add sqlitebrowser 2024-10-19 13:40:51 +02:00
34434cc7cc Replace memoize with cached for teams_sorted_by_points
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 2m21s
2024-09-24 00:04:36 +02:00
6da5919f86 Add a shitload of caching
Some checks are pending
Build Formula10 Docker Image / build-docker (push) Waiting to run
2024-09-24 00:01:41 +02:00
852f0a04ca Add profiler (currently disabled) 2024-09-24 00:00:41 +02:00
aa38d0138f Update FastF1 frontend button description 2024-09-23 21:23:38 +02:00
a9ab892c2a Add functions to obtain specific sessions to EventDataHelper 2024-09-23 21:23:20 +02:00
e6157893ae Remove deprecated import 2024-09-23 17:13:05 +02:00
b2a01ce453 Use column name string when sorting by column 2024-09-23 17:12:55 +02:00
5234e8a06e Add helper classes for FastF1 dataframes 2024-09-23 16:52:23 +02:00
a965c0007b Prefix OpenF1 functions 2024-09-23 16:52:11 +02:00
9acced3899 Add werkzeug to requirements 2024-09-23 16:52:00 +02:00
ed0c5f51b4 Add __repr__ to Race class 2024-09-23 16:51:18 +02:00
ebb862c9d7 Add Pandas to requirements 2024-09-23 16:50:48 +02:00
4a0eb5329c Set FastF1 cache location in dockerfile 2024-09-23 16:50:35 +02:00
30bf475000 Prefix OpenF1 classes + functions with "openf1" 2024-09-22 23:55:54 +02:00
d66087142d Add fastf1 module 2024-09-22 23:34:16 +02:00
8bdb3b5207 Add fastf1 python dependency to requirements 2024-09-22 23:01:22 +02:00
2e685ed646 Add fastf1 python dependency 2024-09-22 22:00:38 +02:00
b4c459ffe7 Allow specifying drivers as "inactive" and replace Logan Sargeant with Franco Colapinto
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 1m27s
2024-08-30 20:47:06 +02:00
3340b77efe Slight reformatting 2024-08-30 18:48:40 +02:00
0cff53cf42 Fix driver standing points string for NC
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 30s
2024-05-25 14:42:49 +02:00
f15bbb4b96 Update requirements.txt
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 28s
2024-03-10 20:10:49 +01:00
d88ae9568f Add frontend button to fetch from OpenF1
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 24s
2024-03-10 20:08:41 +01:00
1ab92eff9a Implement fetching of certain data from OpenF1 2024-03-10 19:54:28 +01:00
c2838c3332 Update flake with commands 2024-03-10 18:50:29 +01:00
e6a70365f3 Add OpenF1 model classes 2024-03-10 18:50:24 +01:00
6e20275dcd Remove obsolete notes
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 13s
2024-03-10 00:48:42 +01:00
10cf690ee2 Track sprint stats + fastest laps + add to stats
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-03-10 00:32:07 +01:00
628e2cd99a Update frontend for extended stats (sprint)
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-09 23:22:18 +01:00
e57109caf4 Add quali_date + has_sprint fields to race
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-03-09 20:56:58 +01:00
8b2920f886 Add dummy values to race result
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 20s
These columns are not marked nullable, so ignoring them prevents entering of race results
2024-03-09 19:48:07 +01:00
73273bc5cd Small database migration fixes
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 16:02:20 +01:00
d3097038a5 Large database migration
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-03-03 15:38:35 +01:00
96cb8ca891 Fix sorting + standing bug for wcc/wdc
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 23s
2024-03-03 11:24:40 +01:00
1ad558171d Decrease diagram height on large screens
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 03:38:25 +01:00
873df8bd8e Only apply horizontal diagram width on mobile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-03-03 03:34:42 +01:00
b250c47cb3 Update diagrams for landscape mode
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 03:31:22 +01:00
6ed5b914e4 Use driver abbr in diagram
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 16s
2024-03-03 03:24:51 +01:00
feb6d27e24 Increase diagram point size
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 03:22:56 +01:00
0d598e75a2 Add diagrams to stats page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 16s
2024-03-03 03:19:51 +01:00
a3d234a754 Add standing diff to stats page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 02:33:21 +01:00
8fcb8c5704 Improve styling consistency
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 02:22:34 +01:00
20d177192f Update diagram
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 02:15:19 +01:00
27e0231a25 Update diagram
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-03-03 02:10:58 +01:00
325f753d31 Add points history diagram
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-03-03 02:09:06 +01:00
b4794ca42f Add chartjs 2024-03-03 01:01:07 +01:00
481492868b Fix grid layout on PC screen
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 00:25:36 +01:00
64d37acc23 Many frontend improvements
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-03 00:06:52 +01:00
c2c71a32e8 Update note
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 22:40:03 +01:00
e7d2e960ad Add points to race table
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 22:31:40 +01:00
1bd174e73f Add notes to statistics/leaderboard pages 2024-03-02 22:27:25 +01:00
0ea6568082 Add statistics page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 22:18:28 +01:00
09203d9390 Update season guess calculation
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 21:49:47 +01:00
5dcebab545 Allow enabling debug endpoints with env var 2024-03-02 21:49:39 +01:00
dfb9360125 Display current season guess state
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 20:31:34 +01:00
a0051aacc3 Implement domain version of season_guess_result 2024-03-02 20:31:14 +01:00
d3ee08df36 Add tooltip on leaderboard 2024-03-02 18:03:31 +01:00
0a94222cb2 Color first place(s) red in leaderboard
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 13s
2024-03-02 18:00:56 +01:00
162423ec84 Combine places with equal points in leaderboard
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 17:58:06 +01:00
fcc29d2372 Add place to leaderboard
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 17:49:04 +01:00
131965e61f Fix date mistakes in static race data
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 13:30:24 +01:00
d5d128379d Sort leaderboard table by total points
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 13:25:03 +01:00
49939a38b4 Revert race table styling
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-03-02 13:15:20 +01:00
f1090f205b Add initial leaderboards page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 38s
2024-03-02 13:11:25 +01:00
efcdf5412b Update race table styling 2024-03-02 13:11:16 +01:00
e0e6ec6bd5 Disable more elements when form is closed 2024-03-02 12:48:34 +01:00
abea8aa0c8 Allow disabling timing constraints for development 2024-03-02 12:38:12 +01:00
cf0dc88284 Add initial (untested) race guess points calculation 2024-02-27 21:01:09 +01:00
70fae278a8 Add pytest 2024-02-27 21:00:59 +01:00
262bcc8d5e Add seasonguessresult table 2024-02-27 20:21:37 +01:00
44549f019d Split base model from template + points model 2024-02-27 19:42:21 +01:00
dc9dc3d092 Make race page table columns smaller
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 23:56:58 +01:00
16be73c1c1 Fix some race page layout issues
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 23:52:51 +01:00
1f4489b3aa Show place number on race result
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 23:34:48 +01:00
ab9440cb4a Rename statistics to leaderboard 2024-02-26 23:09:57 +01:00
1e4c9a1ad6 Add race countdown
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-02-26 23:06:34 +01:00
457a80e58e Disable load dynamic admin endpoints
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 22:35:22 +01:00
005b852836 Fix template syntax error
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 22:29:44 +01:00
25b945cb62 Clarify DSQ in rules page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 22:23:40 +01:00
86cfa5f649 Fix result index bug introduced in previous commit
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 22:20:21 +01:00
97d67d49ce Date-lock race+season guesses + use errorpage more often
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 17s
2024-02-26 22:15:08 +01:00
2a8c17633e Add error page 2024-02-26 19:51:07 +01:00
3949ad8f04 Add rules page
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s
2024-02-26 19:02:32 +01:00
bf9c25e537 Update pxx in static race data 2024-02-26 19:02:22 +01:00
2a0b3bc3f3 Display race time
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 17:06:51 +01:00
dbbbd5ccc6 Add times to static race data 2024-02-26 17:06:35 +01:00
ba9c72f71d Make hot take textbox larger
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-26 16:35:27 +01:00
db78d93160 Fix bug where user enabled bools were parsed wrong 2024-02-26 16:35:18 +01:00
a852690f0b Fix bug where NONE_TEAM was inserted into season guess 2024-02-26 16:35:06 +01:00
594653e3bf Fix race frontend issue with no users
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 21s
2024-02-26 16:28:08 +01:00
3d27099bbb Update static race data 2024-02-26 16:27:58 +01:00
991a1a177e Split frontend model from backend model
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 26s
2024-02-25 15:09:59 +01:00
a9b1bc4403 Update .gitignore 2024-02-25 15:09:05 +01:00
7d10c8d4c5 Update .gitignore 2024-02-25 15:08:30 +01:00
e36c4f2b18 Update docker workflow
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-24 18:43:25 +01:00
45d7c50e66 Fix relative paths for csv im-/export 2024-02-24 18:42:39 +01:00
c37b3b882b Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-24 18:37:34 +01:00
06f0eba1ea Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 25s
2024-02-24 18:20:39 +01:00
d5f2766e7a Update dockerfile
Some checks failed
Build Formula10 Docker Image / build-docker (push) Failing after 48s
2024-02-24 18:19:22 +01:00
81a886965f Update dockerfile
Some checks failed
Build Formula10 Docker Image / build-docker (push) Failing after 11s
2024-02-24 18:12:37 +01:00
a7f90a0a0d Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 13s
2024-02-24 18:11:25 +01:00
10a79dfae3 Update dockerfile
Some checks failed
Build Formula10 Docker Image / build-docker (push) Failing after 16s
2024-02-24 18:10:54 +01:00
a91e702b13 Update dockerfile
Some checks failed
Build Formula10 Docker Image / build-docker (push) Failing after 16s
2024-02-24 18:04:59 +01:00
674f209f5d Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-24 18:03:00 +01:00
ecb90a6b48 Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 46s
2024-02-24 17:53:02 +01:00
a371e5b304 Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 20s
2024-02-24 17:49:34 +01:00
686c7fe945 Update dockerfile
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 23s
2024-02-24 17:42:02 +01:00
3704192730 Finish restructuring files
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 14s
2024-02-24 17:41:12 +01:00
fc8a890511 Change file structure
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 50s
2024-02-24 16:22:48 +01:00
85 changed files with 4916 additions and 2112 deletions

View File

@ -4,12 +4,10 @@ on:
push:
branches: [main]
paths:
- ".gitea/workflows/*"
- ".gitea/workflows/**"
- "Dockerfile"
- "requirements.txt"
- "*.py"
- "templates/*"
- "static/*"
- "formula10/**"
jobs:

8
.gitignore vendored
View File

@ -1,6 +1,8 @@
.direnv
.vscode
__pycache__
instance/formula10.db
.idea
dynamic_data
__pycache__
instance
data/dynamic_export
data/dynamic_export

View File

@ -1,10 +1,17 @@
# syntax=docker/dockerfile:1
FROM public.ecr.aws/docker/library/python:3.11.8-slim-bookworm
RUN apt-get update -y
WORKDIR /app
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python3", "-u", "formula10.py"]
ENV FASTF1_CACHE="/cache"
CMD ["python3", "-u", "-m", "flask", "--app", "formula10", "run", "--host", "0.0.0.0"]

View File

@ -1,221 +0,0 @@
from typing import Dict, List, cast
from urllib.parse import quote
from flask import redirect
from werkzeug import Response
from database_utils import race_has_result, user_exists
from model import PodiumDrivers, RaceResult, SeasonGuess, TeamWinners, User, db, RaceGuess
from validation_utils import any_is_none, positions_are_contiguous
def find_or_create_race_guess(user_name: str, race_name: str) -> RaceGuess:
# There can be a single RaceGuess at most, since (user_name, race_name) is the composite primary key
race_guess: RaceGuess | None = db.session.query(RaceGuess).filter_by(user_name=user_name, race_name=race_name).first()
if race_guess is not None:
return race_guess
# Insert a new RaceGuess
race_guess = RaceGuess(user_name=user_name, race_name=race_name)
db.session.add(race_guess)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
race_guess = db.session.query(RaceGuess).filter_by(user_name=user_name, race_name=race_name).first()
if race_guess is None:
raise Exception("Failed adding RaceGuess to the database")
return race_guess
def update_race_guess(race_name: str, user_name: str, pxx_select: str | None, dnf_select: str | None) -> Response:
if any_is_none(pxx_select, dnf_select):
return redirect(f"/race/{quote(user_name)}")
pxx_driver_name: str = cast(str, pxx_select)
dnf_driver_name: str = cast(str, dnf_select)
# TODO: Date-lock this. Otherwise there is a period of time after the race
# but before the result where guesses can still be entered
# We can't guess for races that are already over
if race_has_result(race_name):
return redirect(f"/race/{quote(user_name)}")
race_guess: RaceGuess = find_or_create_race_guess(user_name, race_name)
race_guess.pxx_driver_name = pxx_driver_name
race_guess.dnf_driver_name = dnf_driver_name
db.session.commit()
return redirect("/race/Everyone")
def delete_race_guess(race_name: str, user_name: str) -> Response:
# Don't change guesses that are already over
if race_has_result(race_name):
return redirect(f"/race/{quote(user_name)}")
db.session.query(RaceGuess).filter_by(race_name=race_name, user_name=user_name).delete()
db.session.commit()
return redirect("/race/Everyone")
def find_or_create_team_winners(user_name: str) -> TeamWinners:
# There can be a single TeamWinners at most, since user_name is the primary key
team_winners: TeamWinners | None = db.session.query(TeamWinners).filter_by(user_name=user_name).first()
if team_winners is not None:
return team_winners
team_winners = TeamWinners(user_name=user_name)
db.session.add(team_winners)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
team_winners = db.session.query(TeamWinners).filter_by(user_name=user_name).first()
if team_winners is None:
raise Exception("Failed adding TeamWinners to the database")
return team_winners
def find_or_create_podium_drivers(user_name: str) -> PodiumDrivers:
# There can be a single PodiumDrivers at most, since user_name is the primary key
podium_drivers: PodiumDrivers | None = db.session.query(PodiumDrivers).filter_by(user_name=user_name).first()
if podium_drivers is not None:
return podium_drivers
podium_drivers = PodiumDrivers(user_name=user_name)
db.session.add(podium_drivers)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
podium_drivers = db.session.query(PodiumDrivers).filter_by(user_name=user_name).first()
if podium_drivers is None:
raise Exception("Failed adding PodiumDrivers to the database")
return podium_drivers
def find_or_create_season_guess(user_name: str) -> SeasonGuess:
# There can be a single SeasonGuess at most, since user_name is the primary key
season_guess: SeasonGuess | None = db.session.query(SeasonGuess).filter_by(user_name=user_name).first()
if season_guess is not None:
# There can't be more than a single one, since both also use user_name as primary key
if db.session.query(TeamWinners).filter_by(user_name=user_name).first() is None:
raise Exception(f"SeasonGuess for {user_name} is missing associated TeamWinners")
if db.session.query(PodiumDrivers).filter_by(user_name=user_name).first() is None:
raise Exception(f"SeasonGuess for {user_name} is missing associated PodiumDrivers")
return season_guess
# Insert a new SeasonGuess
team_winners: TeamWinners = find_or_create_team_winners(user_name)
podium_drivers: PodiumDrivers = find_or_create_podium_drivers(user_name)
season_guess = SeasonGuess(user_name=user_name, team_winners_user_name=team_winners.user_name, podium_drivers_user_name=podium_drivers.user_name)
db.session.add(season_guess)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
season_guess = db.session.query(SeasonGuess).filter_by(user_name=user_name).first()
if season_guess is None:
raise Exception("Failed adding SeasonGuess to the database")
return season_guess
def update_season_guess(user_name: str, guesses: List[str | None], team_winner_guesses: List[str | None], podium_driver_guesses: List[str]) -> Response:
# Pylance marks type errors here, but those are intended. Columns are marked nullable.
season_guess: SeasonGuess = find_or_create_season_guess(user_name)
season_guess.hot_take = guesses[0]
season_guess.p2_team_name = guesses[1]
season_guess.overtake_driver_name = guesses[2]
season_guess.dnf_driver_name = guesses[3]
season_guess.gained_driver_name = guesses[4]
season_guess.lost_driver_name = guesses[5]
season_guess.team_winners.teamwinner_driver_names = team_winner_guesses
season_guess.podium_drivers.podium_driver_names = podium_driver_guesses
db.session.commit()
return redirect(f"/season/Everyone")
def find_or_create_race_result(race_name: str) -> RaceResult:
# There can be a single RaceResult at most, since race_name is the primary key
race_result: RaceResult | None = db.session.query(RaceResult).filter_by(race_name=race_name).first()
if race_result is not None:
return race_result
race_result = RaceResult(race_name=race_name)
db.session.add(race_result)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
race_result = db.session.query(RaceResult).filter_by(race_name=race_name).first()
if race_result is None:
raise Exception("Failed adding RaceResult to the database")
return race_result
def update_race_result(race_name: str, pxx_driver_names_list: List[str], first_dnf_driver_names_list: List[str], dnf_driver_names_list: List[str], excluded_driver_names_list: List[str]) -> Response:
# Use strings as keys, as these dicts will be serialized to json
pxx_driver_names: Dict[str, str] = {
str(position + 1): driver for position, driver in enumerate(pxx_driver_names_list)
}
# Not counted drivers have to be at the end
excluded_driver_names: Dict[str, str] = {
str(position + 1): driver for position, driver in enumerate(pxx_driver_names_list)
if driver in excluded_driver_names_list
}
if len(excluded_driver_names) > 0 and (not "20" in excluded_driver_names or not positions_are_contiguous(list(excluded_driver_names.keys()))):
return redirect(f"/result/{quote(race_name)}")
# First DNF drivers have to be contained in DNF drivers
for driver_name in first_dnf_driver_names_list:
if driver_name not in dnf_driver_names_list:
dnf_driver_names_list.append(driver_name)
race_result: RaceResult = find_or_create_race_result(race_name)
race_result.pxx_driver_names = pxx_driver_names
race_result.first_dnf_driver_names = first_dnf_driver_names_list
race_result.dnf_driver_names = dnf_driver_names_list
race_result.excluded_driver_names = excluded_driver_names_list
db.session.commit()
return redirect(f"/result/{quote(race_name)}")
def update_user(user_name: str | None, add: bool = False, delete: bool = False) -> Response:
if user_name is None or len(user_name) < 3:
return redirect("/user")
if not add and not delete:
return redirect("/user")
if add and delete:
return redirect("/user")
if add:
if user_exists(user_name):
return redirect("/user")
user: User = User(name=user_name)
db.session.add(user)
db.session.commit()
return redirect("/user")
if delete:
if not user_exists(user_name):
return redirect("/user")
db.session.query(User).filter_by(name=user_name).delete()
db.session.commit()
return redirect("/user")
raise Exception("update_user received illegal combination of arguments")

View File

@ -1,9 +0,0 @@
from model import User, db, RaceResult
def race_has_result(race_name: str) -> bool:
return db.session.query(RaceResult).filter_by(race_name=race_name).first() is not None
def user_exists(user_name: str) -> bool:
return db.session.query(User).filter_by(name=user_name).first() is not None

View File

@ -1,97 +0,0 @@
import csv
import os.path
from typing import List, Any
from model import Team, Driver, Race, User, RaceResult, RaceGuess, TeamWinners, PodiumDrivers, SeasonGuess, db
def load_csv(filename: str) -> List[List[str]]:
if not os.path.exists(filename):
print(f"Could not load data from file {filename}, as it doesn't exist!")
return []
with open(filename, "r", newline="") as file:
reader = csv.reader(file, delimiter=",")
next(reader, None) # skip header
return list(reader)
def write_csv(filename: str, objects: List[Any]):
if len(objects) == 0:
print(f"Could not write objects to file {filename}, as no objects were given!")
return
with open(filename, "w", newline="") as file:
writer = csv.writer(file, delimiter=",")
writer.writerow(objects[0].__csv_header__)
for obj in objects:
writer.writerow(obj.to_csv())
# Reload static database data, this has to be called from the app context
def reload_static_data():
print("Initializing Database with Static Values...")
# Create it (if it doesn't exist!)
db.create_all()
# Clear static data
db.session.query(Team).delete()
db.session.query(Driver).delete()
db.session.query(Race).delete()
# Reload static data
for row in load_csv("static_data/teams.csv"):
db.session.add(Team.from_csv(row))
for row in load_csv("static_data/drivers.csv"):
db.session.add(Driver.from_csv(row))
for row in load_csv("static_data/races.csv"):
db.session.add(Race.from_csv(row))
db.session.commit()
def reload_dynamic_data():
print("Initializing Database with Dynamic Values...")
# Create it (if it doesn't exist!)
db.create_all()
# Clear dynamic data
db.session.query(User).delete()
db.session.query(RaceResult).delete()
db.session.query(RaceGuess).delete()
db.session.query(TeamWinners).delete()
db.session.query(PodiumDrivers).delete()
db.session.query(SeasonGuess).delete()
# Reload dynamic data
for row in load_csv("dynamic_data/users.csv"):
db.session.add(User.from_csv(row))
for row in load_csv("dynamic_data/raceresults.csv"):
db.session.add(RaceResult.from_csv(row))
for row in load_csv("dynamic_data/raceguesses.csv"):
db.session.add(RaceGuess.from_csv(row))
for row in load_csv("dynamic_data/teamwinners.csv"):
db.session.add(TeamWinners.from_csv(row))
for row in load_csv("dynamic_data/podiumdrivers.csv"):
db.session.add(PodiumDrivers.from_csv(row))
for row in load_csv("dynamic_data/seasonguesses.csv"):
db.session.add(SeasonGuess.from_csv(row))
db.session.commit()
def export_dynamic_data():
print("Exporting Userdata...")
users: List[User] = db.session.query(User).all()
raceresults: List[RaceResult] = db.session.query(RaceResult).all()
raceguesses: List[RaceGuess] = db.session.query(RaceGuess).all()
teamwinners: List[TeamWinners] = db.session.query(TeamWinners).all()
podiumdrivers: List[PodiumDrivers] = db.session.query(PodiumDrivers).all()
seasonguesses: List[SeasonGuess] = db.session.query(SeasonGuess).all()
write_csv("dynamic_data/users.csv", users)
write_csv("dynamic_data/raceresults.csv", raceresults)
write_csv("dynamic_data/raceguesses.csv", raceguesses)
write_csv("dynamic_data/teamwinners.csv", teamwinners)
write_csv("dynamic_data/podiumdrivers.csv", podiumdrivers)
write_csv("dynamic_data/seasonguesses.csv", seasonguesses)

154
flake.nix
View File

@ -5,47 +5,143 @@
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.devshell.url = "github:numtide/devshell";
outputs = { self, nixpkgs, flake-utils, devshell }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [ devshell.overlays.default ];
outputs = {
self,
nixpkgs,
flake-utils,
devshell,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [devshell.overlays.default];
};
timple = pkgs.python311Packages.buildPythonPackage rec {
pname = "timple";
version = "0.1.8";
src = pkgs.python311Packages.fetchPypi {
inherit pname version;
hash = "sha256-u8EgMA8BA6OpPlSg0ASRxLcIcv5psRIEcBpIicagXw8=";
};
myPython = pkgs.python311.withPackages (p: with p; [
doCheck = false;
pyproject = false;
# Build time deps
nativeBuildInputs = with pkgs.python311Packages; [
setuptools
];
# Run time deps
dependencies = with pkgs.python311Packages; [
matplotlib
numpy
];
};
fastf1 = pkgs.python311Packages.buildPythonPackage rec {
pname = "fastf1";
version = "3.4.0";
src = pkgs.python311Packages.fetchPypi {
inherit pname version;
hash = "sha256-rCjJaM0W2m9Yk3dhHkMOdIqPiKtVqoXuELBasmA9ybA=";
};
doCheck = false;
pyproject = true;
# Build time deps
nativeBuildInputs = with pkgs.python311Packages; [
hatchling
hatch-vcs
];
# Run time deps
dependencies = with pkgs.python311Packages; [
matplotlib
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
];
};
myPython = pkgs.python311.withPackages (p:
with p; [
# Basic
rich
numpy
# Web
flask
flask-sqlalchemy
flask-caching
sqlalchemy
requests
# Test
pytest
fastf1
# TODO: For some reason, listing those under fastf1.dependencies doesn't work???
matplotlib
numpy
pandas
python-dateutil
requests-cache
scipy
rapidfuzz
websockets
timple
]);
in {
devShell = pkgs.devshell.mkShell {
name = "Formula10";
in {
devShell = pkgs.devshell.mkShell {
name = "Formula10";
packages = with pkgs; [
myPython
packages = with pkgs; [
myPython
nodejs_21
nodePackages.sass
nodePackages.postcss-cli
nodePackages.autoprefixer
];
sqlitebrowser
# Use $1 for positional args
commands = [
# {
# name = "";
# help = "";
# command = "";
# }
];
};
});
nodejs_21
nodePackages.sass
nodePackages.postcss-cli
nodePackages.autoprefixer
];
# Use $1 for positional args
commands = [
{
name = "vscode";
help = "Launch VSCode";
command = "code . &>/dev/null &";
}
{
name = "pycharm";
help = "Launch PyCharm Professional";
command = "pycharm-professional . &>/dev/null &";
}
{
name = "db";
help = "Launch SQLiteBrowser";
command = "sqlitebrowser ./instance/formula10.db &>/dev/null &";
}
{
name = "api";
help = "Launch Hoppscotch in Google Chrome";
command = "google-chrome-stable https://hoppscotch.io &>/dev/null &";
}
];
};
});
}

View File

@ -1,179 +0,0 @@
from typing import List
from urllib.parse import unquote
from flask import Flask, render_template, request, redirect
from werkzeug import Response
from model import Team, db
from file_utils import reload_static_data, reload_dynamic_data, export_dynamic_data
from template_model import TemplateModel
from backend_model import delete_race_guess, update_race_guess, update_race_result, update_season_guess, update_user
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///formula10.db"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.url_map.strict_slashes = False
db.init_app(app)
# TODO
# General
# - Choose "place to guess" late before the race? Make a page for this
# - Make user order changeable using drag'n'drop?
# - Show place when entering race result (would require updating the drag'n'drop code...)
# - Show cards of previous race results, like with season guesses?
# - Make the season card grid left-aligned? So e.g. 2 cards are not spread over the whole screen with large gaps?
# Statistics
# - Auto calculate points
# - Order user table by points + display points somewhere
# - Show current values for some season guesses (e.g. current most dnfs)
# - Generate static diagram using chart.js + templating the js (funny yikes)
# Rules page
@app.route("/")
def root() -> Response:
return redirect("/race/Everyone")
@app.route("/save/all")
def save() -> Response:
export_dynamic_data()
return redirect("/")
@app.route("/load/all")
def load() -> Response:
reload_static_data()
reload_dynamic_data()
return redirect("/")
@app.route("/load/static")
def load_static() -> Response:
reload_static_data()
return redirect("/")
@app.route("/load/dynamic")
def load_dynamic() -> Response:
reload_dynamic_data()
return redirect("/")
@app.route("/race")
def race_root() -> Response:
return redirect("/race/Everyone")
@app.route("/race/<user_name>")
def race_active_user(user_name: str) -> str:
user_name = unquote(user_name)
model = TemplateModel()
return render_template("race.jinja",
active_user=model.user_by(user_name=user_name, ignore=["Everyone"]),
model=model)
@app.route("/race-guess/<race_name>/<user_name>", methods=["POST"])
def race_guess_post(race_name: str, user_name: str) -> Response:
race_name = unquote(race_name)
user_name = unquote(user_name)
pxx: str | None = request.form.get("pxxselect")
dnf: str | None = request.form.get("dnfselect")
return update_race_guess(race_name, user_name, pxx, dnf)
@app.route("/race-guess-delete/<race_name>/<user_name>", methods=["POST"])
def race_guess_delete_post(race_name: str, user_name: str) -> Response:
race_name = unquote(race_name)
user_name = unquote(user_name)
return delete_race_guess(race_name, user_name)
@app.route("/season")
def season_root() -> Response:
return redirect("/season/Everyone")
@app.route("/season/<user_name>")
def season_active_user(user_name: str) -> str:
user_name = unquote(user_name)
model = TemplateModel()
return render_template("season.jinja",
active_user=model.user_by(user_name=user_name, ignore=["Everyone"]),
model=model)
@app.route("/season-guess/<user_name>", methods=["POST"])
def season_guess_post(user_name: str) -> Response:
user_name = unquote(user_name)
guesses: List[str | None] = [
request.form.get("hottakeselect"),
request.form.get("p2select"),
request.form.get("overtakeselect"),
request.form.get("dnfselect"),
request.form.get("gainedselect"),
request.form.get("lostselect")
]
team_winner_guesses: List[str | None] = [
request.form.get(f"teamwinner-{team.name}") for team in db.session.query(Team).all()
]
podium_driver_guesses: List[str] = request.form.getlist("podiumdrivers")
return update_season_guess(user_name, guesses, team_winner_guesses, podium_driver_guesses)
@app.route("/result")
def result_root() -> Response:
return redirect("/result/Current")
@app.route("/result/<race_name>")
def result_active_race(race_name: str) -> str:
race_name = unquote(race_name)
model = TemplateModel()
return render_template("enter.jinja",
active_result=model.race_result_by(race_name=race_name),
model=model)
@app.route("/result-enter/<race_name>", methods=["POST"])
def result_enter_post(race_name: str) -> Response:
race_name = unquote(race_name)
pxxs: List[str] = request.form.getlist("pxx-drivers")
first_dnfs: List[str] = request.form.getlist("first-dnf-drivers")
dnfs: List[str] = request.form.getlist("dnf-drivers")
excluded: List[str] = request.form.getlist("excluded-drivers")
return update_race_result(race_name, pxxs, first_dnfs, dnfs, excluded)
@app.route("/user")
def user_root() -> str:
model = TemplateModel()
return render_template("users.jinja",
model=model)
@app.route("/user-add", methods=["POST"])
def user_add_post() -> Response:
username: str | None = request.form.get("select-add-user")
return update_user(username, add=True)
@app.route("/user-delete", methods=["POST"])
def user_delete_post() -> Response:
username: str | None = request.form.get("select-delete-user")
return update_user(username, delete=True)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0")

59
formula10/__init__.py Normal file
View File

@ -0,0 +1,59 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_caching import Cache
from werkzeug.middleware.profiler import ProfilerMiddleware
# Load local ENV variables (can be set when calling the executable)
ENABLE_TIMING: bool = False if os.getenv("DISABLE_TIMING") == "True" else True
print("Running Formula10 with:")
if not ENABLE_TIMING:
print("- Disabled timing constraints")
app: Flask = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///formula10.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# Session cookie is used to propagate message to error page
app.config["SESSION_TYPE"] = "memcached"
app.config["SECRET_KEY"] = "ich stinke nach maggi"
app.url_map.strict_slashes = False
db: SQLAlchemy = SQLAlchemy()
db.init_app(app)
cache: Cache = Cache(config={"CACHE_TYPE": "SimpleCache"})
cache.init_app(app)
# app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=("/formula10/*",), sort_by=("cumtime",))
# NOTE: These imports are required to register the routes. They need to be imported after "app" is declared
import formula10.controller.race_controller
import formula10.controller.season_controller
import formula10.controller.leaderboard_controller
import formula10.controller.statistics_controller
import formula10.controller.rules_controller
import formula10.controller.admin_controller
import formula10.controller.error_controller
# TODO
# Large DB Update
# - Don't use names for frontend post requests, use IDs
# - For season guess calc there is missing: Fastest laps + sprint points + sprint DNFs (in race result)
# - Mask to allow changing usernames (easy if name is not used as ID)
# - Maybe even masks for races + drivers + teams?
# - DB fields for links to F1 site - NO: just hardcode them in with a dictionary
# Leaderboards/Points
# - Auto calculate season points (display season points in table + season guess card title?)
# Optimizations
# - Optimize PointsModel + TemplateModel. In case something is calculated often, cache it.
# - NEVER do manual DB queries, except in the DomainModel!
# General
# - Adapt diagram colors to team colors
# - Add links to the official F1 stats page (for quali/result), probably best to store entire link in DB (because they are not entirely regular)?
# - Unit testing (as much as possible, but especially points calculation)

View File

View File

@ -0,0 +1,83 @@
from typing import List
from urllib.parse import unquote
from flask import redirect, render_template, request
from werkzeug import Response
from formula10.controller.error_controller import error_redirect
from formula10.database.update_queries import update_race_result, update_user
from formula10.domain.cache_invalidator import cache_invalidate_user_updated, cache_invalidate_race_result_updated
from formula10.domain.domain_model import Model
from formula10.domain.template_model import TemplateModel
from formula10 import app
from formula10.openf1.model.openf1_session import OpenF1Session
from formula10.openf1.openf1_definitions import OPENF1_SESSION_NAME_RACE
from formula10.openf1.openf1_fetcher import openf1_fetch_driver, openf1_fetch_position, openf1_fetch_session
@app.route("/result")
def result_root() -> Response:
return redirect("/result/Current")
@app.route("/result/<race_name>")
def result_active_race(race_name: str) -> str:
race_name = unquote(race_name)
model = TemplateModel(active_user_name=None,
active_result_race_name=race_name)
return render_template("result.jinja", model=model)
@app.route("/result-enter/<race_name>", methods=["POST"])
def result_enter_post(race_name: str) -> Response:
race_name = unquote(race_name)
pxxs: List[str] = request.form.getlist("pxx-drivers")
first_dnfs: List[str] = request.form.getlist("first-dnf-drivers")
dnfs: List[str] = request.form.getlist("dnf-drivers")
excluded: List[str] = request.form.getlist("excluded-drivers")
# Extra stats for points calculation
fastest_lap: str | None = request.form.get("fastest-lap")
sprint_pxxs: List[str] = request.form.getlist("sprint-pxx-drivers")
sprint_dnf_drivers: List[str] = request.form.getlist("sprint-dnf-drivers")
if fastest_lap is None:
return error_redirect("Data was not saved, because fastest lap was not set.")
cache_invalidate_race_result_updated()
race_id: int = Model().race_by(race_name=race_name).id
return update_race_result(race_id, pxxs, first_dnfs, dnfs, excluded, int(fastest_lap), sprint_pxxs, sprint_dnf_drivers)
@app.route("/result-fetch/<race_name>", methods=["POST"])
def result_fetch_post(race_name: str) -> Response:
session: OpenF1Session = openf1_fetch_session(OPENF1_SESSION_NAME_RACE, "KSA")
openf1_fetch_driver(session.session_key, "VER")
openf1_fetch_position(session.session_key, 1)
# @todo Fetch stuff and build the race_result using update_race_result(...)
cache_invalidate_race_result_updated()
return redirect("/result")
@app.route("/user")
def user_root() -> str:
model = TemplateModel(active_user_name=None,
active_result_race_name=None)
return render_template("users.jinja", model=model)
@app.route("/user-add", methods=["POST"])
def user_add_post() -> Response:
cache_invalidate_user_updated()
username: str | None = request.form.get("select-add-user")
return update_user(username, add=True)
@app.route("/user-delete", methods=["POST"])
def user_delete_post() -> Response:
cache_invalidate_user_updated()
username: str | None = request.form.get("select-delete-user")
return update_user(username, delete=True)

View File

@ -0,0 +1,17 @@
from typing import cast
from flask import redirect, render_template, session
from werkzeug import Response
from formula10.domain.template_model import TemplateModel
from formula10 import app
def error_redirect(error_message: str) -> Response:
session["error_message"] = error_message
return redirect(f"/error")
@app.route("/error")
def error_root() -> str:
model = TemplateModel(active_user_name=None, active_result_race_name=None)
message: str = cast(str, session["error_message"])
return render_template("error.jinja", model=model, error_message=message)

View File

@ -0,0 +1,11 @@
from flask import render_template
from formula10 import app
from formula10.domain.points_model import PointsModel
from formula10.domain.template_model import TemplateModel
@app.route("/graphs")
def graphs_root() -> str:
model = TemplateModel(active_user_name=None, active_result_race_name=None)
points = PointsModel()
return render_template("leaderboard.jinja", model=model, points=points)

View File

@ -0,0 +1,58 @@
from typing import List
from urllib.parse import unquote
from flask import redirect, render_template, request
from werkzeug import Response
from formula10.database.update_queries import delete_race_guess, update_race_guess
from formula10.domain.cache_invalidator import cache_invalidate_race_guess_updated
from formula10.domain.domain_model import Model
from formula10.domain.points_model import PointsModel
from formula10.domain.template_model import TemplateModel
from formula10 import app
@app.route("/")
def root() -> Response:
return redirect("/race/Everyone")
@app.route("/race")
def race_root() -> Response:
return redirect("/race/Everyone")
@app.route("/race/<user_name>")
def race_active_user(user_name: str) -> str:
user_name = unquote(user_name)
model = TemplateModel(active_user_name=user_name,
active_result_race_name=None)
points = PointsModel()
return render_template("race.jinja", model=model, points=points)
@app.route("/race-guess/<race_name>/<user_name>", methods=["POST"])
def race_guess_post(race_name: str, user_name: str) -> Response:
race_name = unquote(race_name)
user_name = unquote(user_name)
pxx: str | None = request.form.get("pxxselect")
dnf: str | None = request.form.get("dnfselect")
cache_invalidate_race_guess_updated()
race_id: int = Model().race_by(race_name=race_name).id
user_id: int = Model().user_by(user_name=user_name).id
return update_race_guess(race_id, user_id,
int(pxx) if pxx is not None else None,
int(dnf) if dnf is not None else None)
@app.route("/race-guess-delete/<race_name>/<user_name>", methods=["POST"])
def race_guess_delete_post(race_name: str, user_name: str) -> Response:
race_name = unquote(race_name)
user_name = unquote(user_name)
cache_invalidate_race_guess_updated()
race_id: int = Model().race_by(race_name=race_name).id
user_id: int = Model().user_by(user_name=user_name).id
return delete_race_guess(race_id, user_id)

View File

@ -0,0 +1,10 @@
from flask import render_template
from formula10 import app
from formula10.domain.template_model import TemplateModel
@app.route("/rules")
def rules_root() -> str:
model = TemplateModel(active_user_name=None, active_result_race_name=None)
return render_template("rules.jinja", model=model)

View File

@ -0,0 +1,50 @@
from typing import List
from urllib.parse import unquote
from flask import redirect, render_template, request
from werkzeug import Response
from formula10.database.model.db_team import DbTeam
from formula10.database.update_queries import update_season_guess
from formula10.domain.cache_invalidator import cache_invalidate_season_guess_updated
from formula10.domain.domain_model import Model
from formula10.domain.model.team import NONE_TEAM
from formula10.domain.points_model import PointsModel
from formula10.domain.template_model import TemplateModel
from formula10 import app, db
@app.route("/season")
def season_root() -> Response:
return redirect("/season/Everyone")
@app.route("/season/<user_name>")
def season_active_user(user_name: str) -> str:
user_name = unquote(user_name)
model = TemplateModel(active_user_name=user_name,
active_result_race_name=None)
points = PointsModel()
return render_template("season.jinja", model=model, points=points)
@app.route("/season-guess/<user_name>", methods=["POST"])
def season_guess_post(user_name: str) -> Response:
user_name = unquote(user_name)
guesses: List[str | None] = [
request.form.get("hottakeselect"),
request.form.get("p2select"),
request.form.get("overtakeselect"),
request.form.get("dnfselect"),
request.form.get("gainedselect"),
request.form.get("lostselect")
]
# TODO: This is pretty ugly, to do queries in the controller
team_winner_guesses: List[str | None] = [
request.form.get(f"teamwinner-{team.id}") for team in db.session.query(DbTeam).all() if team.id != NONE_TEAM.id
]
podium_driver_guesses: List[str] = request.form.getlist("podiumdrivers")
cache_invalidate_season_guess_updated()
user_id: int = Model().user_by(user_name=user_name).id
return update_season_guess(user_id, guesses, team_winner_guesses, podium_driver_guesses)

View File

@ -0,0 +1,12 @@
from flask import render_template
from formula10 import app
from formula10.domain.points_model import PointsModel
from formula10.domain.template_model import TemplateModel
@app.route("/stats")
def stats_root() -> str:
model = TemplateModel(active_user_name=None, active_result_race_name=None)
points = PointsModel()
return render_template("statistics.jinja", model=model, points=points)

View File

View File

@ -0,0 +1,23 @@
from formula10.database.model.db_driver import DbDriver
from formula10.database.model.db_race_result import DbRaceResult
from formula10.database.model.db_user import DbUser
from formula10 import db
def race_has_result(race_id: int) -> bool:
return db.session.query(DbRaceResult).filter_by(race_id=race_id).first() is not None
def user_exists_and_enabled(user_name: str) -> bool:
return db.session.query(DbUser).filter_by(name=user_name, enabled=True).first() is not None
def user_exists_and_disabled(user_name: str) -> bool:
return db.session.query(DbUser).filter_by(name=user_name, enabled=False).first() is not None
def find_single_driver_strict(driver_id: int) -> DbDriver:
db_driver: DbDriver | None = db.session.query(DbDriver).filter_by(id=driver_id).first()
if db_driver is None:
raise Exception(f"Could not find driver with id {driver_id} in database")
return db_driver

View File

View File

@ -0,0 +1,26 @@
from sqlalchemy import Integer, String, ForeignKey, Boolean
from sqlalchemy.orm import mapped_column, Mapped, relationship
from formula10.database.model.db_team import DbTeam
from formula10 import db
class DbDriver(db.Model):
"""
A F1 driver.
It stores the corresponding team + name abbreviation.
"""
__tablename__ = "driver"
def __init__(self, *, id: int):
self.id = id # Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
name: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
abbr: Mapped[str] = mapped_column(String(4), nullable=False, unique=True)
team_id: Mapped[str] = mapped_column(ForeignKey("team.id"), nullable=False)
country_code: Mapped[str] = mapped_column(String(2), nullable=False) # alpha-2 code
active: Mapped[bool] = mapped_column(Boolean, nullable=False)
# Relationships
team: Mapped[DbTeam] = relationship("DbTeam", foreign_keys=[team_id])

View File

@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from formula10 import db
class DbRace(db.Model):
"""
A single race at a certain date and GrandPrix in the calendar.
It stores the place to guess for this race.
"""
__tablename__ = "race"
def __init__(self, *, id: int):
self.id = id # Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
number: Mapped[int] = mapped_column(Integer, nullable=False, unique=True)
date: Mapped[datetime] = mapped_column(DateTime, nullable=False, unique=True)
pxx: Mapped[int] = mapped_column(Integer, nullable=False) # This is the place to guess
quali_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, unique=True)
has_sprint: Mapped[bool] = mapped_column(Boolean, nullable=False)

View File

@ -0,0 +1,30 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from formula10.database.model.db_user import DbUser
from formula10.database.model.db_race import DbRace
from formula10.database.model.db_driver import DbDriver
from formula10 import db
class DbRaceGuess(db.Model):
"""
A guess a user made for a race.
It stores the corresponding race and the guessed drivers for PXX and DNF.
"""
__tablename__ = "raceguess"
def __init__(self, *, user_id: int, race_id: int):
self.user_id = user_id # Primary key
self.race_id = race_id # Primary key
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True)
race_id: Mapped[int] = mapped_column(ForeignKey("race.id"), primary_key=True)
pxx_driver_id: Mapped[int] = mapped_column(ForeignKey("driver.id"), nullable=False)
dnf_driver_id: Mapped[int] = mapped_column(ForeignKey("driver.id"), nullable=False)
# Relationships
user: Mapped[DbUser] = relationship("DbUser", foreign_keys=[user_id])
race: Mapped[DbRace] = relationship("DbRace", foreign_keys=[race_id])
pxx: Mapped[DbDriver] = relationship("DbDriver", foreign_keys=[pxx_driver_id])
dnf: Mapped[DbDriver] = relationship("DbDriver", foreign_keys=[dnf_driver_id])

View File

@ -0,0 +1,30 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from formula10.database.model.db_driver import DbDriver
from formula10.database.model.db_race import DbRace
from formula10 import db
class DbRaceResult(db.Model):
"""
The result of a past race.
It stores the corresponding race and dictionaries of place-/dnf-order and a list of drivers that are excluded from the standings for this race.
"""
__tablename__ = "raceresult"
def __init__(self, *, race_id: int):
self.race_id = race_id # Primary key
race_id: Mapped[int] = mapped_column(ForeignKey("race.id"), primary_key=True)
pxx_driver_ids_json: Mapped[str] = mapped_column(String(1024), nullable=False)
first_dnf_driver_ids_json: Mapped[str] = mapped_column(String(1024), nullable=False)
dnf_driver_ids_json: Mapped[str] = mapped_column(String(1024), nullable=False)
excluded_driver_ids_json: Mapped[str] = mapped_column(String(1024), nullable=False)
fastest_lap_id: Mapped[int] = mapped_column(ForeignKey("driver.id"), nullable=False)
sprint_dnf_driver_ids_json: Mapped[str] = mapped_column(String(1024), nullable=False)
sprint_points_json: Mapped[str] = mapped_column(String(1024), nullable=False)
# Relationships
race: Mapped[DbRace] = relationship("DbRace", foreign_keys=[race_id])
fastest_lap_driver: Mapped[DbDriver] = relationship("DbDriver", foreign_keys=[fastest_lap_id])

View File

@ -0,0 +1,34 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from formula10.database.model.db_driver import DbDriver
from formula10.database.model.db_team import DbTeam
from formula10.database.model.db_user import DbUser
from formula10 import db
class DbSeasonGuess(db.Model):
"""
A collection of bonus guesses for the entire season.
"""
__tablename__ = "seasonguess"
def __init__(self, *, user_id: int):
self.user_id = user_id # Primary key
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True)
hot_take: Mapped[str | None] = mapped_column(String(512), nullable=True)
p2_team_id: Mapped[int | None] = mapped_column(ForeignKey("team.id"), nullable=True)
overtake_driver_id: Mapped[int | None] = mapped_column(ForeignKey("driver.id"), nullable=True)
dnf_driver_id: Mapped[int | None] = mapped_column(ForeignKey("driver.id"), nullable=True)
gained_driver_id: Mapped[int | None] = mapped_column(ForeignKey("driver.id"), nullable=True)
lost_driver_id: Mapped[int | None] = mapped_column(ForeignKey("driver.id"), nullable=True)
team_winners_driver_ids_json: Mapped[str] = mapped_column(String(1024))
podium_drivers_driver_ids_json: Mapped[str] = mapped_column(String(1024))
# Relationships
user: Mapped[DbUser] = relationship("DbUser", foreign_keys=[user_id])
p2_team: Mapped[DbTeam | None] = relationship("DbTeam", foreign_keys=[p2_team_id])
overtake_driver: Mapped[DbDriver | None] = relationship("DbDriver", foreign_keys=[overtake_driver_id])
dnf_driver: Mapped[DbDriver | None] = relationship("DbDriver", foreign_keys=[dnf_driver_id])
gained_driver: Mapped[DbDriver | None] = relationship("DbDriver", foreign_keys=[gained_driver_id])
lost_driver: Mapped[DbDriver | None] = relationship("DbDriver", foreign_keys=[lost_driver_id])

View File

@ -0,0 +1,22 @@
from sqlalchemy import Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from formula10 import db
from formula10.database.model.db_user import DbUser
class DbSeasonGuessResult(db.Model):
"""
Manually entered results for the season bonus guesses.
"""
__tablename__ = "seasonguessresult"
def __init__(self, *, user_id: int):
self.user_id = user_id # Primary key
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True)
hot_take_correct: Mapped[bool] = mapped_column(Boolean, nullable=False)
overtakes_correct: Mapped[bool] = mapped_column(Boolean, nullable=False)
# Relationships
user: Mapped[DbUser] = relationship("DbUser", foreign_keys=[user_id])

View File

@ -0,0 +1,17 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from formula10 import db
class DbTeam(db.Model):
"""
A constructor/team (name only).
"""
__tablename__ = "team"
def __init__(self, *, id: int):
self.id = id # Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
name: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)

View File

@ -0,0 +1,19 @@
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from formula10 import db
class DbUser(db.Model):
"""
A user that can guess races (name only).
"""
__tablename__ = "user"
def __init__(self, *, id: int | None):
if id is not None:
self.id = id # Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)

View File

@ -0,0 +1,247 @@
import json
from typing import Dict, List, cast
from urllib.parse import quote
from flask import redirect
from werkzeug import Response
from formula10.controller.error_controller import error_redirect
from formula10.database.common_queries import race_has_result, user_exists_and_disabled, user_exists_and_enabled
from formula10.database.model.db_race import DbRace
from formula10.database.model.db_race_guess import DbRaceGuess
from formula10.database.model.db_race_result import DbRaceResult
from formula10.database.model.db_season_guess import DbSeasonGuess
from formula10.database.model.db_user import DbUser
from formula10.database.validation import any_is_none, positions_are_contiguous, race_has_started
from formula10 import ENABLE_TIMING, db
def find_or_create_race_guess(user_id: int, race_id: int) -> DbRaceGuess:
# There can be a single RaceGuess at most, since (user_name, race_name) is the composite primary key
race_guess: DbRaceGuess | None = db.session.query(DbRaceGuess).filter_by(user_id=user_id, race_id=race_id).first()
if race_guess is not None:
return race_guess
# Insert a new RaceGuess
race_guess = DbRaceGuess(user_id=user_id, race_id=race_id)
race_guess.pxx_driver_id = 9999
race_guess.dnf_driver_id = 9999
db.session.add(race_guess)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
race_guess = db.session.query(DbRaceGuess).filter_by(user_id=user_id, race_id=race_id).first()
if race_guess is None:
raise Exception("Failed adding RaceGuess to the database")
return race_guess
def update_race_guess(race_id: int, user_id: int, pxx_select_id: int | None, dnf_select_id: int | None) -> Response:
if any_is_none(pxx_select_id, dnf_select_id):
return error_redirect(f"Picks for race \"{race_id}\" were not saved, because you did not fill all the fields.")
if ENABLE_TIMING and race_has_started(race_id=race_id):
return error_redirect(f"No picks for race \"{race_id}\" can be entered, as this race has already started.")
if race_has_result(race_id):
return error_redirect(f"No picks for race \"{race_id}\" can be entered, as this race has already finished.")
pxx_driver_id: int = cast(int, pxx_select_id)
dnf_driver_id: int = cast(int, dnf_select_id)
race_guess: DbRaceGuess = find_or_create_race_guess(user_id, race_id)
race_guess.pxx_driver_id = pxx_driver_id
race_guess.dnf_driver_id = dnf_driver_id
db.session.commit()
return redirect("/race/Everyone")
def delete_race_guess(race_id: int, user_id: int) -> Response:
# Don't change guesses that are already over
if ENABLE_TIMING and race_has_started(race_id=race_id):
return error_redirect(f"No picks for race with id \"{race_id}\" can be deleted, as this race has already started.")
if race_has_result(race_id):
return error_redirect(f"No picks for race \"{race_id}\" can be deleted, as this race has already finished.")
# Does not throw if row doesn't exist
db.session.query(DbRaceGuess).filter_by(race_id=race_id, user_id=user_id).delete()
db.session.commit()
return redirect("/race/Everyone")
def find_or_create_season_guess(user_id: int) -> DbSeasonGuess:
# There can be a single SeasonGuess at most, since user_name is the primary key
season_guess: DbSeasonGuess | None = db.session.query(DbSeasonGuess).filter_by(user_id=user_id).first()
if season_guess is not None:
return season_guess
# Insert a new SeasonGuess
season_guess = DbSeasonGuess(user_id=user_id)
season_guess.team_winners_driver_ids_json=json.dumps(["9999"])
season_guess.podium_drivers_driver_ids_json=json.dumps(["9999"])
db.session.add(season_guess)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
season_guess = db.session.query(DbSeasonGuess).filter_by(user_id=user_id).first()
if season_guess is None:
raise Exception("Failed adding SeasonGuess to the database")
return season_guess
def update_season_guess(user_id: int, guesses: List[str | None], team_winner_guesses: List[str | None], podium_driver_guesses: List[str]) -> Response:
# Pylance marks type errors here, but those are intended. Columns are marked nullable.
if ENABLE_TIMING and race_has_started(race_id=1):
return error_redirect("No season picks can be entered, as the season has already begun!")
season_guess: DbSeasonGuess = find_or_create_season_guess(user_id)
season_guess.hot_take = guesses[0] # type: ignore
season_guess.p2_team_id = guesses[1] # type: ignore
season_guess.overtake_driver_id = guesses[2] # type: ignore
season_guess.dnf_driver_id = guesses[3] # type: ignore
season_guess.gained_driver_id = guesses[4] # type: ignore
season_guess.lost_driver_id = guesses[5] # type: ignore
season_guess.team_winners_driver_ids_json = json.dumps(team_winner_guesses)
season_guess.podium_drivers_driver_ids_json = json.dumps(podium_driver_guesses)
db.session.commit()
return redirect(f"/season/Everyone")
def find_or_create_race_result(race_id: int) -> DbRaceResult:
# There can be a single RaceResult at most, since race_name is the primary key
race_result: DbRaceResult | None = db.session.query(DbRaceResult).filter_by(race_id=race_id).first()
if race_result is not None:
return race_result
race_result = DbRaceResult(race_id=race_id)
race_result.pxx_driver_ids_json = json.dumps(["9999"])
race_result.first_dnf_driver_ids_json = json.dumps(["9999"])
race_result.dnf_driver_ids_json = json.dumps(["9999"])
race_result.excluded_driver_ids_json = json.dumps(["9999"])
race_result.fastest_lap_id = 9999
race_result.sprint_dnf_driver_ids_json = json.dumps([])
race_result.sprint_points_json = json.dumps({})
db.session.add(race_result)
db.session.commit()
# Double check if database insertion worked and obtain any values set by the database
race_result = db.session.query(DbRaceResult).filter_by(race_id=race_id).first()
if race_result is None:
raise Exception("Failed adding RaceResult to the database")
return race_result
def update_race_result(race_id: int, pxx_driver_ids_list: List[str], first_dnf_driver_ids_list: List[str], dnf_driver_ids_list: List[str], excluded_driver_ids_list: List[str],
fastest_lap_driver_id: int, sprint_pxx_driver_ids_list: List[str], sprint_dnf_driver_ids_list: List[str]) -> Response:
if ENABLE_TIMING and not race_has_started(race_id=race_id):
return error_redirect("No race result can be entered, as the race has not begun!")
# Use strings as keys, as these dicts will be serialized to json
pxx_driver_ids: Dict[str, str] = {
str(position + 1): driver_id for position, driver_id in enumerate(pxx_driver_ids_list)
}
# Not counted drivers have to be at the end
excluded_driver_ids: Dict[str, str] = {
str(position + 1): driver_id for position, driver_id in enumerate(pxx_driver_ids_list)
if driver_id in excluded_driver_ids_list
}
if len(excluded_driver_ids) > 0 and (not "20" in excluded_driver_ids or not positions_are_contiguous(list(excluded_driver_ids.keys()))):
return error_redirect("Race result was not saved, as excluded drivers must be contiguous and at the end of the field!")
# First DNF drivers have to be contained in DNF drivers
for driver_id in first_dnf_driver_ids_list:
if driver_id not in dnf_driver_ids_list:
dnf_driver_ids_list.append(driver_id)
# There can't be dnfs but no initial dnfs
if len(dnf_driver_ids_list) > 0 and len(first_dnf_driver_ids_list) == 0:
return error_redirect("Race result was not saved, as there cannot be DNFs without (an) initial DNF(s)!")
race_result: DbRaceResult = find_or_create_race_result(race_id)
race_result.pxx_driver_ids_json = json.dumps(pxx_driver_ids)
race_result.first_dnf_driver_ids_json = json.dumps(first_dnf_driver_ids_list)
race_result.dnf_driver_ids_json = json.dumps(dnf_driver_ids_list)
race_result.excluded_driver_ids_json = json.dumps(excluded_driver_ids_list)
# Extra stats for points calculation
sprint_pxx_driver_ids: Dict[str, str] = {
str(position + 1): driver_id for position, driver_id in enumerate(sprint_pxx_driver_ids_list)
}
race_result.fastest_lap_id = fastest_lap_driver_id
race_result.sprint_dnf_driver_ids_json = json.dumps(sprint_dnf_driver_ids_list)
race_result.sprint_points_json = json.dumps(sprint_pxx_driver_ids)
db.session.commit()
race: DbRace | None = db.session.query(DbRace).filter_by(id=race_id).first()
if race is None:
raise Exception(f"Could not find DbRace with id {race_id}")
return redirect(f"/result/{quote(race.name)}")
def update_user(user_name: str | None, add: bool = False, delete: bool = False) -> Response:
if user_name is None:
return error_redirect("Invalid request: Cannot add/delete user because it is \"None\"!")
if not add and not delete:
return error_redirect("Invalid request: Can either add or delete user!")
if add and delete:
return error_redirect("Invalid request: Can either add or delete user!")
if add:
if len(user_name) < 3:
return error_redirect(f"User \"{user_name}\" was not added, because the username must contain at least 3 characters!")
if user_exists_and_enabled(user_name):
return error_redirect(f"User \"{user_name}\" was not added, because it already exists!")
elif user_exists_and_disabled(user_name):
disabled_user: DbUser | None = db.session.query(DbUser).filter_by(name=user_name, enabled=False).first()
if disabled_user is None:
raise Exception("update_user couldn't reenable user")
disabled_user.enabled = True
else:
user: DbUser = DbUser(id=None)
user.name = user_name
user.enabled = True
db.session.add(user)
db.session.commit()
return redirect("/user")
if delete:
if user_exists_and_disabled(user_name):
return error_redirect(f"User \"{user_name}\" was not deleted, because it does not exist!")
elif user_exists_and_enabled(user_name):
enabled_user: DbUser | None = db.session.query(DbUser).filter_by(name=user_name, enabled=True).first()
if enabled_user is None:
raise Exception("update_user couldn't disable user")
enabled_user.enabled = False
db.session.commit()
else:
return error_redirect(f"User \"{user_name}\" was not deleted, because it does not exist!")
return redirect("/user")
raise Exception("update_user received illegal combination of arguments")

View File

@ -0,0 +1,115 @@
from datetime import datetime
from typing import Any, Callable, Iterable, List, TypeVar, overload
from formula10.database.model.db_race import DbRace
from formula10 import db
from formula10.domain.model.race import Race
_T = TypeVar("_T")
def any_is_none(*args: Any) -> bool:
for arg in args:
if arg is None:
return True
return False
def positions_are_contiguous(positions: List[str]) -> bool:
if len(positions) == 0:
return True
positions_unique = set(positions) # Remove duplicates
positions_sorted: List[int] = sorted([int(position) for position in positions_unique])
# [2, 3, 4, 5]: 2 + 3 == 5
return positions_sorted[0] + len(positions_sorted) - 1 == positions_sorted[-1]
@overload
def race_has_started(*, race: Race) -> bool:
return race_has_started(race=race)
@overload
def race_has_started(*, race_id: int) -> bool:
return race_has_started(race_id=race_id)
def race_has_started(*, race: Race | None = None, race_id: int | None = None) -> bool:
if race is None and race_id is not None:
_race: DbRace | None = db.session.query(DbRace).filter_by(id=race_id).first()
if _race is None:
raise Exception(f"Couldn't obtain race with id {race_id} to check date")
return datetime.now() > _race.date
if race is not None and race_id is None:
return datetime.now() > race.date
raise Exception("race_has_started received illegal arguments")
def find_first_else_none(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> _T | None:
"""
Finds the first element in a sequence matching a predicate.
Returns None if no element is found.
"""
return next(filter(predicate, iterable), None)
def find_multiple(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> List[_T]:
filtered = list(filter(predicate, iterable))
return filtered
def find_multiple_strict(predicate: Callable[[_T], bool], iterable: Iterable[_T], count: int = 0) -> List[_T]:
"""
Finds <count> elements in a sequence matching a predicate (finds all if <count> is 0).
Throws exception if more/fewer elements were found than specified.
"""
filtered = list(filter(predicate, iterable))
if count != 0 and len(filtered) != count:
raise Exception(f"find_multiple found {len(filtered)} matching elements but expected {count}")
return filtered
def find_atleast_strict(predicate: Callable[[_T], bool], iterable: Iterable[_T], count: int = 0) -> List[_T]:
"""
Finds at least <count> elements in a sequence matching a predicate.
Throws exception if fewer elements were found than specified.
"""
filtered = list(filter(predicate, iterable))
if len(filtered) < count:
raise Exception(f"find_atleast found {len(filtered)} matching elements but expected at least {count}")
return filtered
def find_single_strict(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> _T:
"""
Find a single element in a sequence matching a predicate.
Throws exception if more/less than a single element is found.
"""
filtered = list(filter(predicate, iterable))
if len(filtered) != 1:
raise Exception(f"find_single found {len(filtered)} matching elements but expected 1")
return filtered[0]
def find_single_or_none_strict(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> _T | None:
"""
Find a single element in a sequence matching a predicate if it exists.
Only throws exception if more than a single element is found.
"""
filtered = list(filter(predicate, iterable))
if len(filtered) > 1:
raise Exception(f"find_single_or_none found {len(list(filtered))} matching elements but expected 0 or 1")
return filtered[0] if len(filtered) == 1 else None

View File

View File

@ -0,0 +1,98 @@
from typing import Callable, List
from formula10 import cache
from formula10.domain.domain_model import Model
from formula10.domain.points_model import PointsModel
def cache_invalidate_user_updated() -> None:
caches: List[str] = [
"domain_all_users",
"domain_all_race_guesses",
"domain_all_season_guesses",
"points_points_per_step",
"points_user_standing",
]
memoized_caches: List[Callable] = [
PointsModel.points_by,
PointsModel.race_guesses_by,
PointsModel.season_guesses_by,
]
for c in caches:
cache.delete(c)
for c in memoized_caches:
cache.delete_memoized(c)
def cache_invalidate_race_result_updated() -> None:
caches: List[str] = [
"domain_all_race_results",
"points_points_per_step",
"points_team_points_per_step",
"points_dnfs",
"points_driver_points_per_step_cumulative",
"points_wdc_standing_by_position",
"points_wdc_standing_by_driver",
"points_most_dnf_names",
"points_most_gained_names",
"points_most_lost_names",
"points_team_points_per_step_cumulative",
"points_teams_sorted_by_points",
"points_wcc_standing_by_position",
"points_wcc_standing_by_team",
"points_user_standing",
"template_first_race_without_result",
]
memoized_caches: List[Callable] = [
PointsModel.driver_points_per_step,
PointsModel.driver_points_by,
PointsModel.total_driver_points_by,
PointsModel.drivers_sorted_by_points,
PointsModel.total_team_points_by,
PointsModel.points_by,
PointsModel.is_team_winner,
PointsModel.has_podium,
PointsModel.picks_with_points_count,
]
for c in caches:
cache.delete(c)
for c in memoized_caches:
cache.delete_memoized(c)
def cache_invalidate_race_guess_updated() -> None:
caches: List[str] = [
"domain_all_race_guesses",
]
memoized_caches: List[Callable] = [
Model.race_guesses_by,
]
for c in caches:
cache.delete(c)
for c in memoized_caches:
cache.delete_memoized(c)
def cache_invalidate_season_guess_updated() -> None:
caches: List[str] = [
"domain_all_season_guesses",
]
memoized_caches: List[Callable] = [
Model.season_guesses_by,
]
for c in caches:
cache.delete(c)
for c in memoized_caches:
cache.delete_memoized(c)

View File

@ -0,0 +1,322 @@
from typing import Callable, Dict, List, overload
from sqlalchemy import desc
from formula10.database.model.db_driver import DbDriver
from formula10.database.model.db_race import DbRace
from formula10.database.model.db_race_guess import DbRaceGuess
from formula10.database.model.db_race_result import DbRaceResult
from formula10.database.model.db_season_guess import DbSeasonGuess
from formula10.database.model.db_season_guess_result import DbSeasonGuessResult
from formula10.database.model.db_team import DbTeam
from formula10.database.model.db_user import DbUser
from formula10.database.validation import find_multiple_strict, find_single_or_none_strict, find_single_strict, find_atleast_strict
from formula10.domain.model.driver import NONE_DRIVER, Driver
from formula10.domain.model.race import Race
from formula10.domain.model.race_guess import RaceGuess
from formula10.domain.model.race_result import RaceResult
from formula10.domain.model.season_guess import SeasonGuess
from formula10.domain.model.season_guess_result import SeasonGuessResult
from formula10.domain.model.team import NONE_TEAM, Team
from formula10.domain.model.user import User
from formula10 import db, cache
class Model:
@staticmethod
@cache.cached(timeout=None, key_prefix="domain_all_users") # Clear when adding/deleting users
def all_users() -> List[User]:
"""
Returns a list of all enabled users.
"""
db_users = db.session.query(DbUser).filter_by(enabled=True).all()
return [User.from_db_user(db_user) for db_user in db_users]
@staticmethod
@cache.cached(timeout=None, key_prefix="domain_all_race_results") # Clear when adding/updating results
def all_race_results() -> List[RaceResult]:
"""
Returns a list of all race results, in descending order (most recent first).
"""
db_race_results = db.session.query(DbRaceResult).join(DbRaceResult.race).order_by(desc("number")).all()
return [RaceResult.from_db_race_result(db_race_result) for db_race_result in db_race_results]
@staticmethod
@cache.cached(timeout=None, key_prefix="domain_all_race_guesses") # Clear when adding/updating race guesses or users
def all_race_guesses() -> List[RaceGuess]:
"""
Returns a list of all race guesses (of enabled users).
"""
db_race_guesses = db.session.query(DbRaceGuess).join(DbRaceGuess.user).filter_by(enabled=True).all()
return [RaceGuess.from_db_race_guess(db_race_guess) for db_race_guess in db_race_guesses]
@staticmethod
@cache.cached(timeout=None, key_prefix="domain_all_season_guesses") # Clear when adding/updating season guesses or users
def all_season_guesses() -> List[SeasonGuess]:
"""
Returns a list of all season guesses (of enabled users).
"""
db_season_guesses = db.session.query(DbSeasonGuess).join(DbSeasonGuess.user).filter_by(enabled=True).all()
return [SeasonGuess.from_db_season_guess(db_season_guess) for db_season_guess in db_season_guesses]
@staticmethod
@cache.cached(timeout=None, key_prefix="domain_all_season_guess_results") # No cleanup, bc entered manually
def all_season_guess_results() -> List[SeasonGuessResult]:
"""
Returns a list of all season guess results (of enabled users).
"""
db_season_guess_results = db.session.query(DbSeasonGuessResult).join(DbSeasonGuessResult.user).filter_by(enabled=True).all()
return [SeasonGuessResult.from_db_season_guess_result(db_season_guess_result) for db_season_guess_result in db_season_guess_results]
@staticmethod
@cache.cached(timeout=None, key_prefix="domain_all_races") # No cleanup, bc entered manually
def all_races() -> List[Race]:
"""
Returns a list of all races, in descending order (last race first).
"""
db_races = db.session.query(DbRace).order_by(desc("number")).all()
return [Race.from_db_race(db_race) for db_race in db_races]
@staticmethod
@cache.memoize(timeout=None) # No cleanup, bc entered manually
def all_drivers(*, include_none: bool, include_inactive: bool) -> List[Driver]:
"""
Returns a list of all active drivers.
"""
db_drivers = db.session.query(DbDriver).all()
drivers = [Driver.from_db_driver(db_driver) for db_driver in db_drivers]
if not include_inactive:
predicate: Callable[[Driver], bool] = lambda driver: driver.active
drivers = find_multiple_strict(predicate, drivers)
if not include_none:
predicate: Callable[[Driver], bool] = lambda driver: driver != NONE_DRIVER
drivers = find_multiple_strict(predicate, drivers)
return drivers
@staticmethod
@cache.memoize(timeout=None) # No cleanup, bc entered manually
def all_teams(*, include_none: bool) -> List[Team]:
"""
Returns a list of all teams.
"""
db_teams = db.session.query(DbTeam).all()
teams = [Team.from_db_team(db_team) for db_team in db_teams]
if not include_none:
predicate: Callable[[Team], bool] = lambda team: team != NONE_TEAM
return find_multiple_strict(predicate, teams)
return teams
#
# User queries
#
@overload
def user_by(self, *, user_name: str) -> User:
"""
Tries to obtain the user object for a specific username.
"""
return self.user_by(user_name=user_name)
@overload
def user_by(self, *, user_name: str, ignore: List[str]) -> User | None:
"""
Tries to obtain the user object for a specific username, but ignores certain usernames.
"""
return self.user_by(user_name=user_name, ignore=ignore)
def user_by(self, *, user_name: str, ignore: List[str] | None = None) -> User | None:
if ignore is None:
ignore = []
if len(ignore) > 0 and user_name in ignore:
return None
predicate: Callable[[User], bool] = lambda user: user.name == user_name
return find_single_strict(predicate, self.all_users())
#
# Race result queries
#
def race_result_by(self, *, race_name: str) -> RaceResult | None:
"""
Tries to obtain the race result corresponding to a race name.
"""
predicate: Callable[[RaceResult], bool] = lambda result: result.race.name == race_name
return find_single_or_none_strict(predicate, self.all_race_results())
#
# Race guess queries
#
@overload
def race_guesses_by(self, *, user_name: str) -> List[RaceGuess]:
"""
Returns a list of all race guesses made by a specific user.
"""
return self.race_guesses_by(user_name=user_name)
@overload
def race_guesses_by(self, *, race_name: str) -> List[RaceGuess]:
"""
Returns a list of all race guesses made for a specific race.
"""
return self.race_guesses_by(race_name=race_name)
@overload
def race_guesses_by(self, *, user_name: str, race_name: str) -> RaceGuess | None:
"""
Returns a single race guess by a specific user for a specific race, or None, if this guess doesn't exist.
"""
return self.race_guesses_by(user_name=user_name, race_name=race_name)
@overload
def race_guesses_by(self) -> Dict[str, Dict[str, RaceGuess]]:
"""
Returns a dictionary that maps race-ids to user-id - guess dictionaries.
"""
return self.race_guesses_by()
@cache.memoize(timeout=None, args_to_ignore=["self"]) # Cleanup when adding/updating race guesses or users
def race_guesses_by(self, *, user_name: str | None = None, race_name: str | None = None) -> RaceGuess | List[RaceGuess] | Dict[str, Dict[str, RaceGuess]] | None:
# List of all guesses by a single user
if user_name is not None and race_name is None:
predicate: Callable[[RaceGuess], bool] = lambda guess: guess.user.name == user_name
return find_multiple_strict(predicate, self.all_race_guesses())
# List of all guesses for a single race
if user_name is None and race_name is not None:
predicate: Callable[[RaceGuess], bool] = lambda guess: guess.race.name == race_name
return find_multiple_strict(predicate, self.all_race_guesses())
# Guess for a single race by a single user
if user_name is not None and race_name is not None:
predicate: Callable[[RaceGuess], bool] = lambda guess: guess.user.name == user_name and guess.race.name == race_name
return find_single_or_none_strict(predicate, self.all_race_guesses())
# Dict with all guesses
if user_name is None and race_name is None:
guesses_by: Dict[str, Dict[str, RaceGuess]] = dict()
guess: RaceGuess
for guess in self.all_race_guesses():
if guess.race.name not in guesses_by:
guesses_by[guess.race.name] = dict()
guesses_by[guess.race.name][guess.user.name] = guess
return guesses_by
raise Exception("race_guesses_by encountered illegal combination of arguments")
#
# Season guess queries
#
@overload
def season_guesses_by(self, *, user_name: str) -> SeasonGuess | None:
"""
Returns the season guess made by a specific user.
"""
return self.season_guesses_by(user_name=user_name)
@overload
def season_guesses_by(self) -> Dict[str, SeasonGuess]:
"""
Returns a dictionary of season guesses mapped to usernames.
"""
return self.season_guesses_by()
@cache.memoize(timeout=None, args_to_ignore=["self"]) # Cleanup when adding/updating season guesses or users
def season_guesses_by(self, *, user_name: str | None = None) -> SeasonGuess | Dict[str, SeasonGuess] | None:
if user_name is not None:
predicate: Callable[[SeasonGuess], bool] = lambda guess: guess.user.name == user_name
return find_single_or_none_strict(predicate, self.all_season_guesses())
if user_name is None:
guesses_by: Dict[str, SeasonGuess] = dict()
guess: SeasonGuess
for guess in self.all_season_guesses():
guesses_by[guess.user.name] = guess
return guesses_by
raise Exception("season_guesses_by encountered illegal combination of arguments")
#
# Season guess result queries
#
def season_guess_result_by(self, *, user_name: str) -> SeasonGuessResult | None:
predicate: Callable[[SeasonGuessResult], bool] = lambda guess: guess.user.name == user_name
return find_single_or_none_strict(predicate, self.all_season_guess_results())
#
# Team queries
#
@staticmethod
def none_team() -> Team:
return NONE_TEAM
#
# Driver queries
#
@staticmethod
def none_driver() -> Driver:
return NONE_DRIVER
@overload
def drivers_by(self, *, team_name: str, include_inactive: bool) -> List[Driver]:
"""
Returns a list of all drivers driving for a certain team.
"""
return self.drivers_by(team_name=team_name, include_inactive=include_inactive)
@overload
def drivers_by(self, *, include_inactive: bool) -> Dict[str, List[Driver]]:
"""
Returns a dictionary of drivers mapped to team names.
"""
return self.drivers_by(include_inactive=include_inactive)
@cache.memoize(timeout=None, args_to_ignore=["self"]) # No Cleanup, data added manually
def drivers_by(self, *, team_name: str | None = None, include_inactive: bool) -> List[Driver] | Dict[str, List[Driver]]:
if team_name is not None:
predicate: Callable[[Driver], bool] = lambda driver: driver.team.name == team_name
if include_inactive:
return find_atleast_strict(predicate, self.all_drivers(include_none=False, include_inactive=True), 2)
else:
return find_multiple_strict(predicate, self.all_drivers(include_none=False, include_inactive=False), 2)
if team_name is None:
drivers_by: Dict[str, List[Driver]] = dict()
driver: Driver
team: Team
for team in self.all_teams(include_none=False):
drivers_by[team.name] = []
for driver in self.all_drivers(include_none=False, include_inactive=include_inactive):
drivers_by[driver.team.name] += [driver]
return drivers_by
raise Exception("drivers_by encountered illegal combination of arguments")
#
# Race queries
#
def race_by(self, *, race_name: str) -> Race:
for race in self.all_races():
if race.name == race_name:
return race
raise Exception(f"Couldn't find race {race_name}")

View File

View File

@ -0,0 +1,60 @@
from urllib.parse import quote
from formula10.database.model.db_driver import DbDriver
from formula10.domain.model.team import NONE_TEAM, Team
class Driver:
@classmethod
def from_db_driver(cls, db_driver: DbDriver):
driver: Driver = cls()
driver.id = db_driver.id
driver.name = db_driver.name
driver.abbr = db_driver.abbr
driver.country = db_driver.country_code
driver.team = Team.from_db_team(db_driver.team)
driver.active = db_driver.active
return driver
def to_db_driver(self) -> DbDriver:
db_driver: DbDriver = DbDriver(id=self.id)
db_driver.name = self.name
db_driver.abbr = self.abbr
db_driver.country_code = self.country
db_driver.team_id = self.team.name
db_driver.active = self.active
return db_driver
def __eq__(self, __value: object) -> bool:
if isinstance(__value, Driver):
return self.id == __value.id
return NotImplemented
def __hash__(self) -> int:
return hash(self.id)
# This is important to memoize functions getting a Driver as input.
# The repr() will be appended to the cache key.
def __repr__(self) -> str:
return f"Driver(id={self.id}, name={self.name})"
id: int
name: str
abbr: str
country: str
team: Team
active: bool
@property
def name_sanitized(self) -> str:
return quote(self.name)
NONE_DRIVER: Driver = Driver()
NONE_DRIVER.id = 0
NONE_DRIVER.name = "None"
NONE_DRIVER.abbr = "None"
NONE_DRIVER.country = "NO"
NONE_DRIVER.team = NONE_TEAM
NONE_DRIVER.active = True

View File

@ -0,0 +1,52 @@
from datetime import datetime
from urllib.parse import quote
from formula10.database.model.db_race import DbRace
class Race:
@classmethod
def from_db_race(cls, db_race: DbRace):
race: Race = cls()
race.id = db_race.id
race.name = db_race.name
race.number = db_race.number
race.date = db_race.date
race.place_to_guess = db_race.pxx
race.quali_date = db_race.quali_date
race.has_sprint = db_race.has_sprint
return race
def to_db_race(self) -> DbRace:
db_race: DbRace = DbRace(id=self.id)
db_race.name = self.name
db_race.number = self.number
db_race.date = self.date
db_race.pxx = self.place_to_guess
db_race.quali_date = self.date
db_race.has_sprint = self.has_sprint
return db_race
def __eq__(self, __value: object) -> bool:
if isinstance(__value, Race):
return self.id == __value.id
return NotImplemented
def __hash__(self) -> int:
return hash(self.id)
def __repr__(self) -> str:
return f"race(\n\tid={self.id}, name={self.name}, number={self.number},\n\tdate={self.date}, quali_date={self.quali_date},\n\thas_sprint={self.has_sprint}, place_to_guess={self.place_to_guess}\n)"
id: int
name: str
number: int
date: datetime
place_to_guess: int
quali_date: datetime
has_sprint: bool
@property
def name_sanitized(self) -> str:
return quote(self.name)

View File

@ -0,0 +1,35 @@
from formula10.database.model.db_race_guess import DbRaceGuess
from formula10.domain.model.driver import Driver
from formula10.domain.model.race import Race
from formula10.domain.model.user import User
class RaceGuess:
@classmethod
def from_db_race_guess(cls, db_race_guess: DbRaceGuess):
race_guess: RaceGuess = cls()
race_guess.user = User.from_db_user(db_race_guess.user)
race_guess.race = Race.from_db_race(db_race_guess.race)
race_guess.pxx_guess = Driver.from_db_driver(db_race_guess.pxx)
race_guess.dnf_guess = Driver.from_db_driver(db_race_guess.dnf)
return race_guess
def to_db_race_guess(self) -> DbRaceGuess:
db_race_guess: DbRaceGuess = DbRaceGuess(user_id=self.user.id, race_id=self.race.id)
db_race_guess.pxx_driver_id = self.pxx_guess.id
db_race_guess.dnf_driver_id = self.dnf_guess.id
return db_race_guess
def __eq__(self, __value: object) -> bool:
if isinstance(__value, RaceGuess):
return self.user == __value.user and self.race == __value.race
return NotImplemented
def __hash__(self) -> int:
return hash((self.user, self.race))
user: User
race: Race
pxx_guess: Driver
dnf_guess: Driver

View File

@ -0,0 +1,192 @@
import json
from typing import Dict, List
from formula10.database.common_queries import find_single_driver_strict
from formula10.database.model.db_race_result import DbRaceResult
from formula10.domain.model.driver import NONE_DRIVER, Driver
from formula10.domain.model.race import Race
class RaceResult:
@classmethod
def from_db_race_result(cls, db_race_result: DbRaceResult):
race_result: RaceResult = cls()
race_result.race = Race.from_db_race(db_race_result.race)
race_result.fastest_lap_driver = Driver.from_db_driver(db_race_result.fastest_lap_driver)
# Deserialize from json
standing: Dict[str, str] = json.loads(db_race_result.pxx_driver_ids_json)
initial_dnf: List[str] = json.loads(db_race_result.first_dnf_driver_ids_json)
all_dnfs: List[str] = json.loads(db_race_result.dnf_driver_ids_json)
standing_exclusions: List[str] = json.loads(db_race_result.excluded_driver_ids_json)
sprint_dnfs: List[str] = json.loads(db_race_result.sprint_dnf_driver_ids_json)
sprint_standing: Dict[str, str] = json.loads(db_race_result.sprint_points_json)
# Populate relationships
race_result.standing = {
position: Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for position, driver_id in standing.items()
}
race_result.initial_dnf = [
Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for driver_id in initial_dnf
]
race_result.all_dnfs = [
Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for driver_id in all_dnfs
]
race_result.standing_exclusions = [
Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for driver_id in standing_exclusions
]
race_result.sprint_dnfs = [
Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for driver_id in sprint_dnfs
]
race_result.sprint_standing = {
position: Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for position, driver_id in sprint_standing.items()
}
return race_result
def to_db_race_result(self) -> DbRaceResult:
# "Unpopulate" relationships, remove none driver
standing: Dict[str, str] = {
position: driver.name for position, driver in self.standing.items()
}
initial_dnf: List[str] = [
str(driver.id) for driver in self.initial_dnf if driver
]
all_dnfs: List[str] = [
str(driver.id) for driver in self.all_dnfs if driver
]
standing_exclusions: List[str] = [
str(driver.id) for driver in self.standing_exclusions if driver
]
sprint_dnfs: List[str] = [
str(driver.id) for driver in self.sprint_dnfs if driver
]
sprint_standing: Dict[str, str] = {
position: driver.name for position, driver in self.sprint_standing.items()
}
# Serialize to json
db_race_result: DbRaceResult = DbRaceResult(race_id=self.race.id)
db_race_result.pxx_driver_ids_json = json.dumps(standing)
db_race_result.first_dnf_driver_ids_json = json.dumps(initial_dnf)
db_race_result.dnf_driver_ids_json = json.dumps(all_dnfs)
db_race_result.excluded_driver_ids_json = json.dumps(standing_exclusions)
db_race_result.fastest_lap_id = self.fastest_lap_driver.id
db_race_result.sprint_dnf_driver_ids_json = json.dumps(sprint_dnfs)
db_race_result.sprint_points_json = json.dumps(sprint_standing)
return db_race_result
def __eq__(self, __value: object) -> bool:
if isinstance(__value, RaceResult):
return self.race == __value.race
return NotImplemented
def __hash__(self) -> int:
return hash(self.race)
race: Race
standing: Dict[str, Driver] # Always contains all 20 drivers, even if DNF'ed or excluded
initial_dnf: List[Driver] # initial_dnf is empty if no-one DNF'ed
all_dnfs: List[Driver]
standing_exclusions: List[Driver]
fastest_lap_driver: Driver
sprint_dnfs: List[Driver]
sprint_standing: Dict[str, Driver]
def offset_from_place_to_guess(self, offset: int, respect_nc:bool = True) -> Driver:
position: str = str(self.race.place_to_guess + offset)
if position not in self.standing:
raise Exception(f"Position {position} not found in RaceResult.standing")
if self.standing[position] in self.standing_exclusions and respect_nc:
return NONE_DRIVER
return self.standing[position]
def driver_standing_position(self, driver: Driver) -> int | None:
if driver == NONE_DRIVER:
return None
for position, _driver in self.standing.items():
if driver == _driver and driver not in self.standing_exclusions:
return int(position)
return None
def driver_standing_position_string(self, driver: Driver) -> str:
if driver == NONE_DRIVER:
return ""
for position, _driver in self.standing.items():
if driver == _driver and driver not in self.standing_exclusions:
return f" (P{position})"
return " (NC)"
def driver_standing_points_string(self, driver: Driver) -> str:
points_strings: Dict[int, str] = {
0: "10 Points",
1: "6 Points",
2: "3 Points",
3: "1 Points"
}
if driver == NONE_DRIVER:
if self.standing[str(self.race.place_to_guess)] in self.standing_exclusions:
return "10 Points"
else:
return "0 Points"
for position, _driver in self.standing.items():
if driver == _driver and driver not in self.standing_exclusions:
position_offset: int = abs(self.race.place_to_guess - int(position))
if position_offset in points_strings:
return points_strings[position_offset]
else:
return "0 Points"
elif driver == _driver and driver in self.standing_exclusions:
return "0 Points"
raise Exception(f"Could not get points string for driver {driver.name}")
def driver_dnf_points_string(self, driver: Driver) -> str:
if driver == NONE_DRIVER:
if len(self.initial_dnf) == 0:
return "10 Points"
else:
return "0 Points"
if driver in self.initial_dnf:
return "10 Points"
else:
return "0 Points"
def ordered_standing_list(self) -> List[Driver]:
return [
self.standing[str(position)] for position in range(1, 21)
]
def ordered_sprint_standing_list(self) -> List[Driver]:
return [
self.sprint_standing[str(position)] for position in range(1, 21)
]
def initial_dnf_string(self) -> str:
if len(self.initial_dnf) == 0:
return NONE_DRIVER.name
dnf_string: str = ""
for driver in self.initial_dnf:
dnf_string += f"{driver.abbr} "
return dnf_string[0:len(dnf_string)-1] # Remove last space

View File

@ -0,0 +1,81 @@
import json
from typing import List
from formula10.database.common_queries import find_single_driver_strict
from formula10.database.model.db_season_guess import DbSeasonGuess
from formula10.domain.model.driver import Driver
from formula10.domain.model.team import Team
from formula10.domain.model.user import User
class SeasonGuess:
@classmethod
def from_db_season_guess(cls, db_season_guess: DbSeasonGuess):
season_guess: SeasonGuess = cls()
season_guess.user = User.from_db_user(db_season_guess.user)
season_guess.hot_take = db_season_guess.hot_take if db_season_guess.hot_take is not None else None
season_guess.p2_wcc = Team.from_db_team(db_season_guess.p2_team) if db_season_guess.p2_team is not None else None
season_guess.most_overtakes = Driver.from_db_driver(db_season_guess.overtake_driver) if db_season_guess.overtake_driver is not None else None
season_guess.most_dnfs = Driver.from_db_driver(db_season_guess.dnf_driver) if db_season_guess.dnf_driver is not None else None
season_guess.most_wdc_gained = Driver.from_db_driver(db_season_guess.gained_driver) if db_season_guess.gained_driver is not None else None
season_guess.most_wdc_lost = Driver.from_db_driver(db_season_guess.lost_driver) if db_season_guess.lost_driver is not None else None
# Deserialize from json
team_winners: List[str | None] = json.loads(db_season_guess.team_winners_driver_ids_json)
podiums: List[str] = json.loads(db_season_guess.podium_drivers_driver_ids_json)
# Populate relationships
season_guess.team_winners = [
Driver.from_db_driver(find_single_driver_strict(int(driver_id))) if driver_id is not None else None
for driver_id in team_winners
]
season_guess.podiums = [
Driver.from_db_driver(find_single_driver_strict(int(driver_id)))
for driver_id in podiums
]
return season_guess
def to_db_season_guess(self):
# "Unpopulate" relationships
team_winners: List[str | None] = [
driver.name if driver is not None else None
for driver in self.team_winners
]
podiums: List[str] = [
driver.name for driver in self.podiums
]
# Serialize to json
db_season_guess: DbSeasonGuess = DbSeasonGuess(user_id=self.user.id)
db_season_guess.hot_take = self.hot_take
db_season_guess.p2_team_id = self.p2_wcc.id if self.p2_wcc is not None else None
db_season_guess.overtake_driver_id = self.most_overtakes.id if self.most_overtakes is not None else None
db_season_guess.dnf_driver_id = self.most_dnfs.id if self.most_dnfs is not None else None
db_season_guess.gained_driver_id = self.most_wdc_gained.id if self.most_wdc_gained is not None else None
db_season_guess.lost_driver_id = self.most_wdc_lost.id if self.most_wdc_lost is not None else None
db_season_guess.team_winners_driver_ids_json=json.dumps(team_winners)
db_season_guess.podium_drivers_driver_ids_json=json.dumps(podiums)
return db_season_guess
def __eq__(self, __value: object) -> bool:
if isinstance(__value, SeasonGuess):
return self.user == __value.user
return NotImplemented
def __hash__(self) -> int:
return hash(self.user)
user: User
hot_take: str | None
p2_wcc: Team | None
most_overtakes: Driver | None
most_dnfs: Driver | None
most_wdc_gained: Driver | None
most_wdc_lost: Driver | None
team_winners: List[Driver | None]
podiums: List[Driver]
def hot_take_string(self) -> str:
return self.hot_take if self.hot_take is not None else ""

View File

@ -0,0 +1,32 @@
from formula10.database.model.db_season_guess_result import DbSeasonGuessResult
from formula10.domain.model.user import User
class SeasonGuessResult:
@classmethod
def from_db_season_guess_result(cls, db_season_guess_result: DbSeasonGuessResult):
season_guess_result: SeasonGuessResult = cls()
season_guess_result.user = User.from_db_user(db_season_guess_result.user)
season_guess_result.hot_take_correct = db_season_guess_result.hot_take_correct
season_guess_result.overtakes_correct = db_season_guess_result.overtakes_correct
return season_guess_result
def to_db_season_guess_result(self) -> DbSeasonGuessResult:
db_season_guess_result: DbSeasonGuessResult = DbSeasonGuessResult(user_id=self.user.id)
db_season_guess_result.hot_take_correct = self.hot_take_correct
db_season_guess_result.overtakes_correct = self.overtakes_correct
return db_season_guess_result
def __eq__(self, __value: object) -> bool:
if isinstance(__value, SeasonGuessResult):
return self.user == __value.user
return NotImplemented
def __hash__(self) -> int:
return hash(self.user)
user: User
hot_take_correct: bool
overtakes_correct: bool

View File

@ -0,0 +1,37 @@
from urllib.parse import quote
from formula10.database.model.db_team import DbTeam
class Team:
@classmethod
def from_db_team(cls, db_team: DbTeam):
team: Team = cls()
team.id = db_team.id
team.name = db_team.name
return team
def to_db_team(self) -> DbTeam:
db_team: DbTeam = DbTeam(id=self.id)
db_team.name = self.name
return db_team
def __eq__(self, __value: object) -> bool:
if isinstance(__value, Team):
return self.id == __value.id
return NotImplemented
def __hash__(self) -> int:
return hash(self.id)
id: int
name: str
@property
def name_sanitized(self) -> str:
return quote(self.name)
NONE_TEAM: Team = Team()
NONE_TEAM.id = 0
NONE_TEAM.name = "None"

View File

@ -0,0 +1,36 @@
from urllib.parse import quote
from formula10.database.model.db_user import DbUser
class User:
@classmethod
def from_db_user(cls, db_user: DbUser):
user: User = cls()
user.id = db_user.id
user.name = db_user.name
user.enabled = db_user.enabled
return user
def to_db_user(self) -> DbUser:
db_user: DbUser = DbUser(id=self.id)
db_user.name = self.name
db_user.enabled = self.enabled
return db_user
def __eq__(self, __value: object) -> bool:
if isinstance(__value, User):
return self.id == __value.id
return NotImplemented
def __hash__(self) -> int:
return hash(self.id)
id: int
name: str
enabled: bool
@property
def name_sanitized(self) -> str:
return quote(self.name)

View File

@ -0,0 +1,907 @@
import json
from typing import Any, Callable, Dict, List, overload, Tuple
import numpy as np
from formula10 import cache
from formula10.domain.domain_model import Model
from formula10.domain.model.driver import NONE_DRIVER, Driver
from formula10.domain.model.race_guess import RaceGuess
from formula10.domain.model.race_result import RaceResult
from formula10.domain.model.season_guess import SeasonGuess
from formula10.domain.model.season_guess_result import SeasonGuessResult
from formula10.domain.model.team import Team
from formula10.domain.model.user import User
from formula10.database.validation import find_single_or_none_strict
# Guess points
RACE_GUESS_OFFSET_POINTS: Dict[int, int] = {3: 1, 2: 3, 1: 6, 0: 10}
RACE_GUESS_DNF_POINTS: int = 10
SEASON_GUESS_HOT_TAKE_POINTS: int = 10
SEASON_GUESS_P2_POINTS: int = 10
SEASON_GUESS_OVERTAKES_POINTS: int = 10
SEASON_GUESS_DNF_POINTS: int = 10
SEASON_GUESS_GAINED_POINTS: int = 10
SEASON_GUESS_LOST_POINTS: int = 10
SEASON_GUESS_TEAMWINNER_CORRECT_POINTS: int = 3
SEASON_GUESS_TEAMWINNER_FALSE_POINTS: int = -3
SEASON_GUESS_PODIUMS_CORRECT_POINTS: int = 3
SEASON_GUESS_PODIUMS_FALSE_POINTS: int = -2
# Driver points
DRIVER_RACE_POINTS: Dict[int, int] = {
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
}
DRIVER_SPRINT_POINTS: Dict[int, int] = {1: 8, 2: 7, 3: 6, 4: 5, 5: 4, 6: 3, 7: 2, 8: 1}
DRIVER_FASTEST_LAP_POINTS: int = 1
# Last season results
WDC_STANDING_2023: Dict[str, int] = {
"Max Verstappen": 1,
"Sergio Perez": 2,
"Lewis Hamilton": 3,
"Fernando Alonso": 4,
"Charles Leclerc": 5,
"Lando Norris": 6,
"Carlos Sainz": 7,
"George Russell": 8,
"Oscar Piastri": 9,
"Lance Stroll": 10,
"Pierre Gasly": 11,
"Esteban Ocon": 12,
"Alexander Albon": 13,
"Yuki Tsunoda": 14,
"Valtteri Bottas": 15,
"Nico Hulkenberg": 16,
"Daniel Ricciardo": 17,
"Zhou Guanyu": 18,
"Kevin Magnussen": 19,
"Logan Sargeant": 21,
}
WCC_STANDING_2023: Dict[str, int] = {
"Red Bull": 1,
"Mercedes": 2,
"Ferrari": 3,
"McLaren": 4,
"Aston Martin": 5,
"Alpine": 6,
"Williams": 7,
"VCARB": 8,
"Sauber": 9,
"Haas": 10,
}
# In case a substitute driver is driving, those points have to be subtracted from the actual driver
# (Driver_ID, Race_ID, Points)
WDC_SUBSTITUTE_POINTS: List[Tuple[int, int, int]] = [
(15, 2, 6), # Bearman raced for Sainz in Saudi Arabia
(8, 17, 1), # Bearman raced for Magnussen in Azerbaijan
]
WDC_STANDING_2024: Dict[str, int] = {
"Max Verstappen": 1,
"Lando Norris": 2,
"Charles Leclerc": 3,
"Oscar Piastri": 4,
"Carlos Sainz": 5,
"George Russell": 6,
"Lewis Hamilton": 7,
"Sergio Perez": 8,
"Fernando Alonso": 9,
"Pierre Gasly": 10,
"Nico Hulkenberg": 11,
"Yuki Tsunoda": 12,
"Lance Stroll": 13,
"Esteban Ocon": 14,
"Kevin Magnussen": 15,
"Alexander Albon": 16,
"Daniel Ricciardo": 17,
"Oliver Bearman": 18,
"Franco Colapinto": 19,
"Zhou Guanyu": 20,
"Liam Lawson": 21,
"Valtteri Bottas": 22,
"Logan Sargeant": 23,
"Jack Doohan": 24
}
def standing_points(race_guess: RaceGuess, race_result: RaceResult) -> int:
guessed_driver_position: int | None = race_result.driver_standing_position(
driver=race_guess.pxx_guess
)
if guessed_driver_position is None:
return 0
position_offset: int = abs(guessed_driver_position - race_guess.race.place_to_guess)
if position_offset not in RACE_GUESS_OFFSET_POINTS:
return 0
return RACE_GUESS_OFFSET_POINTS[position_offset]
def dnf_points(race_guess: RaceGuess, race_result: RaceResult) -> int:
if race_guess.dnf_guess in race_result.initial_dnf:
return RACE_GUESS_DNF_POINTS
if race_guess.dnf_guess == NONE_DRIVER and len(race_result.initial_dnf) == 0:
return RACE_GUESS_DNF_POINTS
return 0
def substitute_points(driver: Driver, race_number: int) -> int:
predicate: Callable[[Tuple[int, int, int]], bool] = lambda substitution: driver.id == substitution[0] and race_number == substitution[1]
substitution: Tuple[int, int, int] = find_single_or_none_strict(predicate, WDC_SUBSTITUTE_POINTS)
if substitution is not None:
return substitution[2]
else:
return 0
class PointsModel(Model):
"""
This class bundles all data + functionality required to do points calculations.
"""
def __init__(self):
Model.__init__(self)
@cache.cached(
timeout=None, key_prefix="points_points_per_step"
) # Clear when adding/updating race results or users
def points_per_step(self) -> Dict[str, List[int]]:
"""
Returns a dictionary of lists, containing points per race for each user.
"""
points_per_step = dict()
for user in self.all_users():
points_per_step[user.name] = [0] * (
len(self.all_races()) + 1
) # Start at index 1, like the race numbers
for race_guess in self.all_race_guesses():
user_name: str = race_guess.user.name
race_number: int = race_guess.race.number
race_result: RaceResult | None = self.race_result_by(
race_name=race_guess.race.name
)
if race_result is None:
continue
points_per_step[user_name][race_number] = standing_points(
race_guess, race_result
) + dnf_points(race_guess, race_result)
return points_per_step
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Clear when adding/updating race results
def driver_points_per_step(self, *, include_inactive: bool) -> Dict[str, List[int]]:
"""
Returns a dictionary of lists, containing points per race for each driver.
"""
driver_points_per_step = dict()
for driver in self.all_drivers(
include_none=False, include_inactive=include_inactive
):
driver_points_per_step[driver.name] = [0] * (
len(self.all_races()) + 1
) # Start at index 1, like the race numbers
for race_result in self.all_race_results():
race_number: int = race_result.race.number
for position, driver in race_result.standing.items():
driver_points_per_step[driver.name][race_number] = (
DRIVER_RACE_POINTS[int(position)]
if int(position) in DRIVER_RACE_POINTS
else 0
)
driver_points_per_step[driver.name][race_number] += (
DRIVER_FASTEST_LAP_POINTS
if race_result.fastest_lap_driver == driver
and int(position) <= 10
else 0
)
driver_points_per_step[driver.name][race_number] -= substitute_points(driver, race_number)
for position, driver in race_result.sprint_standing.items():
driver_name: str = driver.name
driver_points_per_step[driver_name][race_number] += (
DRIVER_SPRINT_POINTS[int(position)]
if int(position) in DRIVER_SPRINT_POINTS
else 0
)
return driver_points_per_step
@cache.cached(timeout=None, key_prefix="points_team_points_per_step")
def team_points_per_step(self) -> Dict[str, List[int]]:
"""
Returns a dictionary of lists, containing points per race for each team.
"""
team_points_per_step = dict()
for team in self.all_teams(include_none=False):
team_points_per_step[team.name] = [0] * (
len(self.all_races()) + 1
) # Start at index 1, like the race numbers
for race_result in self.all_race_results():
for driver in race_result.standing.values():
team_name: str = driver.team.name
race_number: int = race_result.race.number
team_points_per_step[team_name][
race_number
] += self.driver_points_per_step(include_inactive=True)[driver.name][
race_number
]
return team_points_per_step
@cache.cached(timeout=None, key_prefix="points_dnfs")
def dnfs(self) -> Dict[str, int]:
dnfs = dict()
for driver in self.all_drivers(include_none=False, include_inactive=True):
dnfs[driver.name] = 0
for race_result in self.all_race_results():
for driver in race_result.all_dnfs:
dnfs[driver.name] += 1
for driver in race_result.sprint_dnfs:
dnfs[driver.name] += 1
return dnfs
#
# Driver stats
#
@cache.cached(
timeout=None, key_prefix="points_driver_points_per_step_cumulative"
) # Cleanup when adding/updating race results
def driver_points_per_step_cumulative(self) -> Dict[str, List[int]]:
"""
Returns a dictionary of lists, containing cumulative points per race for each driver.
"""
points_per_step_cumulative: Dict[str, List[int]] = dict()
for driver_name, points in self.driver_points_per_step(
include_inactive=True
).items():
points_per_step_cumulative[driver_name] = np.cumsum(points).tolist()
return points_per_step_cumulative
@overload
def driver_points_by(
self, *, driver_name: str, include_inactive: bool
) -> List[int]:
"""
Returns a list of points per race for a specific driver.
"""
return self.driver_points_by(
driver_name=driver_name, include_inactive=include_inactive
)
@overload
def driver_points_by(
self, *, race_name: str, include_inactive: bool
) -> Dict[str, int]:
"""
Returns a dictionary of points per driver for a specific race.
"""
return self.driver_points_by(
race_name=race_name, include_inactive=include_inactive
)
@overload
def driver_points_by(
self, *, driver_name: str, race_name: str, include_inactive: bool
) -> int:
"""
Returns the points for a specific race for a specific driver.
"""
return self.driver_points_by(
driver_name=driver_name,
race_name=race_name,
include_inactive=include_inactive,
)
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def driver_points_by(
self,
*,
driver_name: str | None = None,
race_name: str | None = None,
include_inactive: bool
) -> List[int] | Dict[str, int] | int:
if driver_name is not None and race_name is None:
return self.driver_points_per_step(include_inactive=include_inactive)[
driver_name
]
if driver_name is None and race_name is not None:
race_number: int = self.race_by(race_name=race_name).number
points_by_race: Dict[str, int] = dict()
for _driver_name, points in self.driver_points_per_step(
include_inactive=include_inactive
).items():
points_by_race[_driver_name] = points[race_number]
return points_by_race
if driver_name is not None and race_name is not None:
race_number: int = self.race_by(race_name=race_name).number
return self.driver_points_per_step(include_inactive=include_inactive)[
driver_name
][race_number]
raise Exception("driver_points_by received an illegal combination of arguments")
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def total_driver_points_by(self, driver_name: str) -> int:
return sum(
self.driver_points_by(driver_name=driver_name, include_inactive=True)
)
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def drivers_sorted_by_points(self, *, include_inactive: bool) -> List[Driver]:
comparator: Callable[[Driver], int] = (
lambda driver: self.total_driver_points_by(driver.name)
)
return sorted(
self.all_drivers(include_none=False, include_inactive=include_inactive),
key=comparator,
reverse=True,
)
@cache.cached(
timeout=None, key_prefix="points_wdc_standing_by_position"
) # Cleanup when adding/updating race results
def wdc_standing_by_position(self) -> Dict[int, List[str]]:
standing: Dict[int, List[str]] = dict()
if WDC_STANDING_2024 is None:
for position in range(
1, len(self.all_drivers(include_none=False, include_inactive=True)) + 1
):
standing[position] = list()
position: int = 1
last_points: int = 0
for driver in self.drivers_sorted_by_points(include_inactive=True):
points: int = self.total_driver_points_by(driver.name)
if points < last_points:
# If multiple drivers have equal points, a place is shared.
# In this case, the next driver does not occupy the immediate next position.
position += len(standing[position])
standing[position].append(driver.name)
last_points = points
if WDC_STANDING_2024 is not None:
for position in range(1, len(WDC_STANDING_2024) + 1):
standing[position] = list()
for driver, position in WDC_STANDING_2024.items():
standing[position] += [driver]
return standing
@cache.cached(
timeout=None, key_prefix="points_wdc_standing_by_driver"
) # Cleanup when adding/updating race results
def wdc_standing_by_driver(self) -> Dict[str, int]:
standing: Dict[str, int] = dict()
if WDC_STANDING_2024 is None:
position: int = 1
last_points: int = 0
for driver in self.drivers_sorted_by_points(include_inactive=True):
points: int = self.total_driver_points_by(driver.name)
if points < last_points:
drivers_with_this_position = 0
for _driver, _position in standing.items():
if _position == position:
drivers_with_this_position += 1
# If multiple drivers have equal points, a place is shared.
# In this case, the next driver does not occupy the immediate next position.
position += drivers_with_this_position
standing[driver.name] = position
last_points = points
return standing
if WDC_STANDING_2024 is not None:
return WDC_STANDING_2024
def wdc_diff_2023_by(self, driver_name: str) -> int:
if not driver_name in WDC_STANDING_2023:
return 0
return (
WDC_STANDING_2023[driver_name] - self.wdc_standing_by_driver()[driver_name]
)
@cache.cached(
timeout=None, key_prefix="points_most_dnf_names"
) # Cleanup when adding/updating race results
def most_dnf_names(self) -> List[str]:
dnf_names: List[str] = list()
most_dnfs: int = 0
for dnfs in self.dnfs().values():
if dnfs > most_dnfs:
most_dnfs = dnfs
for driver_name, dnfs in self.dnfs().items():
if dnfs == most_dnfs:
dnf_names.append(driver_name)
return dnf_names
@cache.cached(
timeout=None, key_prefix="points_most_gained_names"
) # Cleanup when adding/updating race results
def most_gained_names(self) -> List[str]:
most_gained_names: List[str] = list()
most_gained: int = 0
for driver in self.all_drivers(include_none=False, include_inactive=True):
gained: int = self.wdc_diff_2023_by(driver.name)
if gained > most_gained:
most_gained = gained
for driver in self.all_drivers(include_none=False, include_inactive=True):
gained: int = self.wdc_diff_2023_by(driver.name)
if gained == most_gained:
most_gained_names.append(driver.name)
return most_gained_names
@cache.cached(
timeout=None, key_prefix="points_most_lost_names"
) # Cleanup when adding/updating race results
def most_lost_names(self) -> List[str]:
most_lost_names: List[str] = list()
most_lost: int = 100
for driver in self.all_drivers(include_none=False, include_inactive=True):
lost: int = self.wdc_diff_2023_by(driver.name)
if lost < most_lost:
most_lost = lost
for driver in self.all_drivers(include_none=False, include_inactive=True):
lost: int = self.wdc_diff_2023_by(driver.name)
if lost == most_lost:
most_lost_names.append(driver.name)
return most_lost_names
#
# Team points
#
@cache.cached(
timeout=None, key_prefix="points_team_points_per_step_cumulative"
) # Cleanup when adding/updating race results
def team_points_per_step_cumulative(self) -> Dict[str, List[int]]:
"""
Returns a dictionary of lists, containing cumulative points per race for each team.
"""
points_per_step_cumulative: Dict[str, List[int]] = dict()
for team_name, points in self.team_points_per_step().items():
points_per_step_cumulative[team_name] = np.cumsum(points).tolist()
return points_per_step_cumulative
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def total_team_points_by(self, team_name: str) -> int:
teammates: List[Driver] = self.drivers_by(
team_name=team_name, include_inactive=True
)
return sum(
sum(self.driver_points_by(driver_name=teammate.name, include_inactive=True))
for teammate in teammates
)
@cache.cached(
timeout=None, key_prefix="points_teams_sorted_by_points"
) # Cleanup when adding/updating race results
def teams_sorted_by_points(self) -> List[Team]:
comparator: Callable[[Team], int] = lambda team: self.total_team_points_by(
team.name
)
return sorted(self.all_teams(include_none=False), key=comparator, reverse=True)
@cache.cached(
timeout=None, key_prefix="points_wcc_standing_by_position"
) # Cleanup when adding/updating race results
def wcc_standing_by_position(self) -> Dict[int, List[str]]:
standing: Dict[int, List[str]] = dict()
for position in range(1, len(self.all_teams(include_none=False)) + 1):
standing[position] = list()
position: int = 1
last_points: int = 0
for team in self.teams_sorted_by_points():
points: int = self.total_team_points_by(team.name)
if points < last_points:
# If multiple teams have equal points, a place is shared.
# In this case, the next team does not occupy the immediate next position.
position += len(standing[position])
standing[position].append(team.name)
last_points = points
return standing
@cache.cached(
timeout=None, key_prefix="points_wcc_standing_by_team"
) # Cleanup when adding/updating race results
def wcc_standing_by_team(self) -> Dict[str, int]:
standing: Dict[str, int] = dict()
position: int = 1
last_points: int = 0
for team in self.teams_sorted_by_points():
points: int = self.total_team_points_by(team.name)
if points < last_points:
teams_with_this_position = 0
for _team, _position in standing.items():
if _position == position:
teams_with_this_position += 1
# If multiple teams have equal points, a place is shared.
# In this case, the next team does not occupy the immediate next position.
position += teams_with_this_position
standing[team.name] = position
last_points = points
return standing
def wcc_diff_2023_by(self, team_name: str) -> int:
return WCC_STANDING_2023[team_name] - self.wcc_standing_by_team()[team_name]
#
# User stats
#
def points_per_step_cumulative(self) -> Dict[str, List[int]]:
"""
Returns a dictionary of lists, containing cumulative points per race for each user.
"""
points_per_step_cumulative: Dict[str, List[int]] = dict()
for user_name, points in self.points_per_step().items():
points_per_step_cumulative[user_name] = np.cumsum(points).tolist()
return points_per_step_cumulative
@overload
def points_by(self, *, user_name: str) -> List[int]:
"""
Returns a list of points per race for a specific user.
"""
return self.points_by(user_name=user_name)
@overload
def points_by(self, *, race_name: str) -> Dict[str, int]:
"""
Returns a dictionary of points per user for a specific race.
"""
return self.points_by(race_name=race_name)
@overload
def points_by(self, *, user_name: str, race_name: str) -> int:
"""
Returns the points for a specific race for a specific user.
"""
return self.points_by(user_name=user_name, race_name=race_name)
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results or users
def points_by(
self, *, user_name: str | None = None, race_name: str | None = None
) -> List[int] | Dict[str, int] | int:
if user_name is not None and race_name is None:
return self.points_per_step()[user_name]
if user_name is None and race_name is not None:
race_number: int = self.race_by(race_name=race_name).number
points_by_race: Dict[str, int] = dict()
for _user_name, points in self.points_per_step().items():
points_by_race[_user_name] = points[race_number]
return points_by_race
if user_name is not None and race_name is not None:
race_number: int = self.race_by(race_name=race_name).number
return self.points_per_step()[user_name][race_number]
raise Exception("points_by received an illegal combination of arguments")
def season_points_by(self, *, user_name: str) -> int:
"""
Returns the number of points from seasonguesses for a specific user.
"""
big_picks = (int(self.hot_take_correct(user_name=user_name)) * 10
+ int(self.p2_constructor_correct(user_name=user_name)) * 10
+ int(self.overtakes_correct(user_name=user_name)) * 10
+ int(self.dnfs_correct(user_name=user_name)) * 10
+ int(self.most_gained_correct(user_name=user_name)) * 10
+ int(self.most_lost_correct(user_name=user_name)) * 10)
small_picks = 0
guess: SeasonGuess = self.season_guesses_by(user_name=user_name)
for driver in guess.team_winners:
if self.is_team_winner(driver):
small_picks += 3
else:
small_picks -= 3
# NOTE: Not picked drivers that had a podium are also wrong
for driver in self.all_drivers(include_none=False, include_inactive=True):
if driver in guess.podiums and self.has_podium(driver):
small_picks += 3
elif driver in guess.podiums and not self.has_podium(driver):
small_picks -=2
elif driver not in guess.podiums and self.has_podium(driver):
small_picks -=2
return big_picks + small_picks
def total_points_by(self, *, user_name: str, include_season: bool) -> int:
"""
Returns the total number of points for a specific user.
"""
if include_season:
return sum(self.points_by(user_name=user_name)) + self.season_points_by(user_name=user_name)
else:
return sum(self.points_by(user_name=user_name))
def users_sorted_by_points(self, *, include_season: bool) -> List[User]:
"""
Returns the list of users, sorted by their points from race guesses (in descending order).
"""
comparator: Callable[[User], int] = lambda user: self.total_points_by(user_name=user.name, include_season=include_season)
return sorted(self.all_users(), key=comparator, reverse=True)
@cache.cached(
timeout=None, key_prefix="points_user_standing"
) # Cleanup when adding/updating race results or users
def user_standing(self, *, include_season: bool) -> Dict[str, int]:
standing: Dict[str, int] = dict()
position: int = 1
last_points: int = 0
for user in self.users_sorted_by_points(include_season=include_season):
if self.total_points_by(user_name=user.name, include_season=include_season) < last_points:
users_with_this_position = 0
for _user, _position in standing.items():
if _position == position:
users_with_this_position += 1
# If multiple users have equal points, a place is shared.
# In this case, the next user does not occupy the immediate next position.
position += users_with_this_position
standing[user.name] = position
last_points = self.total_points_by(user_name=user.name, include_season=include_season)
return standing
def picks_count(self, user_name: str) -> int:
# Treat standing + dnf picks separately
return len(self.race_guesses_by(user_name=user_name)) * 2
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def picks_with_points_count(self, user_name: str) -> int:
count: int = 0
for race_guess in self.race_guesses_by(user_name=user_name):
race_result: RaceResult | None = self.race_result_by(
race_name=race_guess.race.name
)
if race_result is None:
continue
if standing_points(race_guess, race_result) > 0:
count += 1
if dnf_points(race_guess, race_result) > 0:
count += 1
return count
def points_per_pick(self, user_name: str) -> float:
if self.picks_count(user_name) == 0:
return 0.0
return self.total_points_by(user_name=user_name, include_season=False) / self.picks_count(user_name)
#
# Season guess evaluation
#
def hot_take_correct(self, user_name: str) -> bool:
season_guess_result: SeasonGuessResult | None = self.season_guess_result_by(
user_name=user_name
)
return (
season_guess_result.hot_take_correct
if season_guess_result is not None
else False
)
def p2_constructor_correct(self, user_name: str) -> bool:
season_guess: SeasonGuess | None = self.season_guesses_by(user_name=user_name)
if season_guess is None or season_guess.p2_wcc is None:
return False
return season_guess.p2_wcc.name in self.wcc_standing_by_position()[2]
def overtakes_correct(self, user_name: str) -> bool:
season_guess_result: SeasonGuessResult | None = self.season_guess_result_by(
user_name=user_name
)
return (
season_guess_result.overtakes_correct
if season_guess_result is not None
else False
)
def dnfs_correct(self, user_name: str) -> bool:
season_guess: SeasonGuess | None = self.season_guesses_by(user_name=user_name)
if season_guess is None or season_guess.most_dnfs is None:
return False
return season_guess.most_dnfs.name in self.most_dnf_names()
def most_gained_correct(self, user_name: str) -> bool:
season_guess: SeasonGuess | None = self.season_guesses_by(user_name=user_name)
if season_guess is None or season_guess.most_wdc_gained is None:
return False
return season_guess.most_wdc_gained.name in self.most_gained_names()
def most_lost_correct(self, user_name: str) -> bool:
season_guess: SeasonGuess | None = self.season_guesses_by(user_name=user_name)
if season_guess is None or season_guess.most_wdc_lost is None:
return False
return season_guess.most_wdc_lost.name in self.most_lost_names()
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def is_team_winner(self, driver: Driver) -> bool:
teammates: List[Driver] = self.drivers_by(
team_name=driver.team.name, include_inactive=True
)
# Min - Highest position is the lowest place number
winner: Driver = min(teammates, key=lambda driver: self.wdc_standing_by_driver()[driver.name])
return driver == winner
@cache.memoize(
timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results
def has_podium(self, driver: Driver) -> bool:
for race_result in self.all_race_results():
position: int | None = race_result.driver_standing_position(driver)
if position is not None and position <= 3:
return True
return False
#
# Diagram queries
#
def cumulative_points_data(self) -> str:
data: Dict[Any, Any] = dict()
data["labels"] = [0] + [
race.name for race in sorted(self.all_races(), key=lambda race: race.number)
] # + ["Season"]
data["datasets"] = [
{
"data": self.points_per_step_cumulative()[user.name], # + [self.total_points_by(user_name=user.name, include_season=True)],
"label": user.name,
"fill": False,
}
for user in self.all_users()
]
return json.dumps(data)
def cumulative_driver_points_data(self) -> str:
data: Dict[Any, Any] = dict()
data["labels"] = [0] + [
race.name for race in sorted(self.all_races(), key=lambda race: race.number)
]
data["datasets"] = [
{
"data": self.driver_points_per_step_cumulative()[driver.name],
"label": driver.abbr,
"fill": False,
}
for driver in self.all_drivers(include_none=False, include_inactive=True)
]
return json.dumps(data)
def cumulative_team_points_data(self) -> str:
data: Dict[Any, Any] = dict()
data["labels"] = [0] + [
race.name for race in sorted(self.all_races(), key=lambda race: race.number)
]
data["datasets"] = [
{
"data": self.team_points_per_step_cumulative()[team.name],
"label": team.name,
"fill": False,
}
for team in self.all_teams(include_none=False)
]
return json.dumps(data)

View File

@ -0,0 +1,107 @@
from typing import List, Callable
from formula10 import ENABLE_TIMING, cache
from formula10.domain.domain_model import Model
from formula10.domain.model.driver import Driver
from formula10.domain.model.race import Race
from formula10.domain.model.race_result import RaceResult
from formula10.domain.model.user import User
from formula10.database.validation import find_first_else_none, find_multiple_strict, find_single_strict, race_has_started
class TemplateModel(Model):
"""
This class bundles all data + functionality required from inside a template.
"""
active_user: User | None = None
active_result: RaceResult | None = None
# RIC is excluded, since he didn't drive as many races 2023 as the others
_wdc_gained_excluded_abbrs: List[str] = ["RIC"]
def __init__(self, *, active_user_name: str | None, active_result_race_name: str | None):
Model.__init__(self)
if active_user_name is not None:
self.active_user = self.user_by(user_name=active_user_name, ignore=["Everyone"])
if active_result_race_name is not None:
self.active_result = self.race_result_by(race_name=active_result_race_name)
@staticmethod
def race_guess_open(race: Race) -> bool:
return not race_has_started(race=race) if ENABLE_TIMING else True
@staticmethod
def season_guess_open() -> bool:
return not race_has_started(race_id=1) if ENABLE_TIMING else True
def race_result_open(self, race_name: str) -> bool:
predicate: Callable[[Race], bool] = lambda race: race.name == race_name
race: Race = find_single_strict(predicate, self.all_races())
return race_has_started(race_id=race.id) if ENABLE_TIMING else True
def active_user_name_or_everyone(self) -> str:
return self.active_user.name if self.active_user is not None else "Everyone"
def active_user_name_sanitized_or_everyone(self) -> str:
return self.active_user.name_sanitized if self.active_user is not None else "Everyone"
def all_users_or_active_user(self) -> List[User]:
if self.active_user is not None:
return [self.active_user]
return self.all_users()
@cache.cached(timeout=None, key_prefix="template_first_race_without_result") # Cleanup when adding/updating race results
def first_race_without_result(self) -> Race | None:
"""
Returns the first race-object with no associated race result.
"""
results: List[RaceResult] = self.all_race_results()
if len(results) == 0:
return self.all_races()[-1] # all_races is sorted descending by number
most_recent_result: RaceResult = results[0]
predicate: Callable[[Race], bool] = lambda race: race.number == most_recent_result.race.number + 1
return find_first_else_none(predicate, self.all_races())
@property
def current_race(self) -> Race | None:
return self.first_race_without_result()
def active_result_race_or_current_race(self) -> Race:
if self.active_result is not None:
return self.active_result.race
elif self.current_race is not None:
return self.current_race
else:
return self.all_races()[0]
def active_result_race_name_or_current_race_name(self) -> str:
if self.active_result is not None:
return self.active_result.race.name
elif self.current_race is not None:
return self.current_race.name
else:
return self.all_races()[0].name
def active_result_race_name_or_current_race_name_sanitized(self) -> str:
if self.active_result is not None:
return self.active_result.race.name_sanitized
elif self.current_race is not None:
return self.current_race.name_sanitized
else:
return self.all_races()[0].name_sanitized
def all_active_drivers_or_active_result_standing_drivers(self) -> List[Driver]:
return self.active_result.ordered_standing_list() if self.active_result is not None else self.all_drivers(include_none=False, include_inactive=False)
def all_active_drivers_or_active_result_sprint_standing_drivers(self) -> List[Driver]:
return self.active_result.ordered_sprint_standing_list() if self.active_result is not None else self.all_drivers(include_none=False, include_inactive=False)
def active_drivers_for_wdc_gained(self) -> List[Driver]:
predicate: Callable[[Driver], bool] = lambda driver: driver.abbr not in self._wdc_gained_excluded_abbrs
return find_multiple_strict(predicate, self.all_drivers(include_none=False, include_inactive=True))

View File

View File

@ -0,0 +1,364 @@
# https://docs.fastf1.dev/events.html#event-formats
from typing import Optional
from fastf1.core import Lap, DriverResult, Session
from fastf1.events import Event
from pandas import Timestamp, Timedelta
FASTF1_SESSIONTYPE_NORMAL: str = "conventional"
FASTF1_SESSIONTYPE_SPRINT: str = "sprint_qualifying"
FASTF1_SPRINT_QUALIFYING_SESSION: int = 2
FASTF1_SPRINT_SESSION: int = 3
FASTF1_QUALIFYING_SESSION: int = 4
FASTF1_RACE_SESSION: int = 5
class EventDataHelper:
"""
Helper class that provides easy access to EventDataFrame columns.
"""
def __init__(self, event: Event):
self.__event = event
__event: Event
@property
def round_number(self) -> int:
return self.__event["RoundNumber"]
@property
def country(self) -> str:
return self.__event["Country"]
@property
def location(self) -> str:
return self.__event["Location"]
@property
def official_event_name(self) -> str:
"""
Get the event name including sponsor names etc.
"""
return self.__event["OfficialEventName"]
@property
def event_name(self) -> str:
return self.__event["EventName"]
@property
def event_date(self) -> Timestamp:
return self.__event["EventDate"]
@property
def event_format(self) -> str:
"""
Get the event format.
@return: Either conventional, sprint, sprint_shootout, sprint_qualifying or testing (sprint_qualifying is the 2024 sprint format).
"""
return self.__event["EventFormat"]
def session(self, session_number: int) -> str:
"""
Get the format of a session belonging to this event.
@param session_number: Either 1, 2, 3, 4 or 5
@return: Either Pactice 1, Practice 2, Practice 3, Qualifying, Sprint, Sprint Shootout or Race
"""
return self.__event[f"Session{session_number}"]
def session_date(self, session_number: int) -> Timestamp:
"""
Get the date of a session belonging to this event.
@param session_number: Either 1, 2, 3, 4 or 5
"""
return self.__event[f"Session{session_number}Date"]
def session_date_utc(self, session_number: int) -> Timestamp:
"""
Get the date a session belonging to this event in coordinated universal time.
@param session_number: Either 1, 2, 3, 4 or 5
"""
return self.__event[f"Session{session_number}DateUtc"]
@property
def f1_api_support(self) -> bool:
return self.__event["F1ApiSupport"]
# The following doesn't correspond to DataFrame columns
@property
def sprint_qualifying(self):
return self.__event.get_sprint_qualifying()
@property
def sprint(self):
return self.__event.get_sprint()
@property
def qualifying(self) -> Session:
return self.__event.get_qualifying()
@property
def race(self) -> Session:
return self.__event.get_race()
class LapDataHelper:
"""
Helper class that provides easy access to Lap DataFrame columns.
"""
def __init__(self, lap: Lap):
self.__lap = lap
__lap: Lap
@property
def time(self) -> Timedelta:
return self.__lap["Time"]
@property
def driver(self) -> str:
return self.__lap["Driver"]
@property
def driver_number(self) -> str:
return self.__lap["DriverNumber"]
@property
def lap_time(self) -> Timedelta:
return self.__lap["LapTime"]
@property
def lap_number(self) -> int:
return self.__lap["LapNumber"]
@property
def stint(self) -> int:
return self.__lap["Stint"]
@property
def pit_out_time(self) -> Timedelta:
"""
Get the session time when the car left the pit.
"""
return self.__lap["PitOutTime"]
@property
def pit_in_time(self) -> Timedelta:
"""
Get the session time when the car entered the pit.
"""
return self.__lap["PitInTime"]
def sector_time(self, sector_number: int) -> Timedelta:
"""
Get the sector times set in this lap.
@param sector_number: Either 1, 2 or 3.
"""
return self.__lap[f"Sector{sector_number}Time"]
def sector_session_time(self, sector_number: int) -> Timedelta:
"""
Get the session time when the sector time was set.
@param sector_number: Either 1, 2 or 3.
"""
return self.__lap[f"Sector{sector_number}SessionTime"]
def speed(self, speedtrap: str) -> float:
"""
Get car speed at measure point in this lap.
@param speedtrap: Either I1, I2, FL or ST (sector 1, sector 2, finish line or longest straight)
"""
return self.__lap[f"Speed{speedtrap}"]
@property
def is_personal_best(self) -> bool:
return self.__lap["IsPersonalBest"]
@property
def compound(self) -> str:
"""
Get compound used in this lap.
@return: Either SOFT, MEDIUM, HARD, INTERMEDIATE or WET
"""
return self.__lap["Compound"]
@property
def tyre_life(self) -> int:
"""
Get laps driven on the current tyre (includes laps from other sessions).
"""
return self.__lap["TyreLife"]
@property
def fresh_tyre(self) -> bool:
return self.__lap["FreshTyre"]
@property
def team(self) -> str:
return self.__lap["Team"]
@property
def lap_start_time(self) -> Timedelta:
return self.__lap["LapStartTime"]
@property
def lap_start_date(self) -> Timestamp:
return self.__lap["LapStartDate"]
@property
def track_status(self) -> str:
"""
Get the track status in this lap.
@return: Either 1, 2, 3, 4, 5, 6 or 7 (clear, yellow flag, ?, SC, red flag, VSC and VSC ending).
"""
return self.__lap["TrackStatus"]
@property
def position(self) -> int:
return self.__lap["Position"]
@property
def deleted(self) -> Optional[bool]:
"""
Determine if the lap was deleted (only available if race control messages are loaded).
"""
return self.__lap["Deleted"]
@property
def deleted_reason(self) -> str:
return self.__lap["DeletedReason"]
@property
def fast_f1_generated(self) -> bool:
"""
Determine if the lap was generated by FastF1 (information is interpolated).
"""
return self.__lap["FastF1Generated"]
@property
def is_accurate(self) -> bool:
"""
Determine if lap start and end match with other laps before and after.
"""
return self.__lap["IsAccurate"]
class DriverResultDataHelper:
"""
Helper class that provides easy access to DriverResult DataFrame columns.
"""
def __init__(self, driver_result: DriverResult):
self.__driver_result = driver_result
__driver_result: DriverResult
@property
def driver_number(self) -> str:
return self.__driver_result["DriverNumber"]
@property
def broadcast_name(self) -> str:
"""
Get this driver's broadcast name.
@return: For example P GASLY
"""
return self.__driver_result["BroadcastName"]
@property
def full_name(self) -> str:
return self.__driver_result["FullName"]
@property
def abbreviation(self) -> str:
return self.__driver_result["Abbreviation"]
# @property
# def driver_id(self) -> str:
# """
# Get the driverId used by the Ergast API.
# """
# return self.__driver_result["DriverID"]
@property
def team_name(self) -> str:
return self.__driver_result["TeamName"]
@property
def team_color(self) -> str:
return self.__driver_result["TeamColor"]
# @property
# def team_id(self) -> str:
# """
# Get the constructorId used by the Ergast API.
# @return:
# """
# return self.__driver_result["TeamID"]
@property
def first_name(self) -> str:
return self.__driver_result["FirstName"]
@property
def last_name(self) -> str:
return self.__driver_result["LastName"]
@property
def headshot_url(self) -> str:
return self.__driver_result["HeadshotUrl"]
@property
def country_code(self) -> str:
"""
Get a driver's three-letter country code.
@return: For example FRA
"""
return self.__driver_result["CountryCode"]
@property
def position(self) -> int:
return self.__driver_result["Position"]
@property
def classified_position(self) -> str:
"""
Get the classification result for this driver.
@return: Either R, D, E, W, F, N (retired, disqualified, excluded, withdrawn, failed to qualify and not classified)
"""
return self.__driver_result["ClassifiedPosition"]
@property
def grid_position(self) -> int:
"""
Get the driver's starting position.
"""
return self.__driver_result["GridPosition"]
def qualifying_time(self, qualifying_number: int) -> Timedelta:
"""
Get the driver's best qualifying time.
@param qualifying_number: Either 1, 2 or 3 (for Q1, Q2 and Q3)
"""
return self.__driver_result[f"Q{qualifying_number}"]
@property
def time(self) -> Timedelta:
"""
Get the driver's total race time.
"""
return self.__driver_result["Time"]
@property
def status(self) -> str:
"""
Get the driver's finishing status.
@return: For example Finished, +1 Lap, Crash, Gearbox...
"""
return self.__driver_result["Status"]
@property
def points(self) -> int:
return self.__driver_result["Points"]

View File

View File

View File

@ -0,0 +1,55 @@
from typing import Any, Callable, Dict
class OpenF1Driver:
__type_conversion_map__: Dict[str, Callable[[Any], Any]] = {
"session_key": int,
"meeting_key": int,
"full_name": str,
"first_name": str,
"last_name": str,
"name_acronym": str,
"broadcast_name": str,
"country_code": str,
"headshot_url": str,
"driver_number": int,
"team_colour": str,
"team_name": str
}
def __init__(self, response: dict[str, str] | None):
if response is None:
return
for key in response:
if not hasattr(self, key):
raise Exception(f"Mismatch between response data and {type(self).__name__} (key={key})")
if not key in self.__type_conversion_map__:
raise Exception(f"Mismatch between response data and {type(self).__name__}.__type_map__ (key={key})")
setattr(self, key, self.__type_conversion_map__[key](response[key]))
print("OpenF1Driver:", self.__dict__)
def to_params(self) -> Dict[str, str]:
params: Dict[str, str] = dict()
for key in self.__dict__:
params[str(key)] = str(self.__dict__[key])
return params
# Set all members to None so hasattr works above
session_key: int = None # type: ignore
meeting_key: int = None # type: ignore
full_name: str = None # type: ignore
first_name: str = None # type: ignore
last_name: str = None # type: ignore
name_acronym: str = None # type: ignore
broadcast_name: str = None # type: ignore
country_code: str = None # type: ignore
headshot_url: str = None # type: ignore
driver_number: int = None # type: ignore
team_colour: str = None # type: ignore
team_name: str = None # type: ignore

View File

@ -0,0 +1,40 @@
from datetime import datetime
from typing import Any, Callable, Dict
class OpenF1Position:
__type_conversion_map__: Dict[str, Callable[[Any], Any]] = {
"session_key": int,
"meeting_key": int,
"driver_number": int,
"date": lambda date: datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%f"),
"position": int
}
def __init__(self, response: dict[str, str] | None):
if response is None:
return
for key in response:
if not hasattr(self, key):
raise Exception(f"Mismatch between response data and {type(self).__name__} (key={key})")
if not key in self.__type_conversion_map__:
raise Exception(f"Mismatch between response data and {type(self).__name__}.__type_map__ (key={key})")
setattr(self, key, self.__type_conversion_map__[key](response[key]))
print("OpenF1Position:", self.__dict__)
def to_params(self) -> Dict[str, str]:
params: Dict[str, str] = dict()
for key in self.__dict__:
params[str(key)] = str(self.__dict__[key])
return params
session_key: int = None # type: ignore
meeting_key: int = None # type: ignore
driver_number: int = None # type: ignore
date: datetime = None # type: ignore
position: int = None # type: ignore

View File

@ -0,0 +1,60 @@
from datetime import datetime, time
from typing import Any, Callable, Dict
class OpenF1Session:
__type_conversion_map__: Dict[str, Callable[[Any], Any]] = {
"location": str,
"country_key": int,
"country_code": str,
"country_name": str,
"circuit_key": int,
"circuit_short_name": str,
"session_type": str,
"session_name": str,
"date_start": lambda date: datetime.strptime(date, "%Y-%m-%dT%H:%M:%S"),
"date_end": lambda date: datetime.strptime(date, "%Y-%m-%dT%H:%M:%S"),
"gmt_offset": lambda time: datetime.strptime(time, "%H:%M:%S").time(),
"session_key": int,
"meeting_key": int,
"year": int
}
def __init__(self, response: dict[str, str] | None):
if response is None:
return
for key in response:
if not hasattr(self, key):
raise Exception(f"Mismatch between response data and {type(self).__name__} (key={key})")
if not key in self.__type_conversion_map__:
raise Exception(f"Mismatch between response data and {type(self).__name__}.__type_map__ (key={key})")
setattr(self, key, self.__type_conversion_map__[key](response[key]))
print("OpenF1Session:", self.__dict__)
def to_params(self) -> Dict[str, str]:
params: Dict[str, str] = dict()
for key in self.__dict__:
params[str(key)] = str(self.__dict__[key])
return params
location: str = None # type: ignore
country_key: int = None # type: ignore
country_code: str = None # type: ignore
country_name: str = None # type: ignore
circuit_key: int = None # type: ignore
circuit_short_name: str = None # type: ignore
session_type: str = None # type: ignore
session_name: str = None # type: ignore
date_start: datetime = None # type: ignore
date_end: datetime = None # type: ignore
gmt_offset: time = None # type: ignore
session_key: int = None # type: ignore
meeting_key: int = None # type: ignore
year: int = None # type: ignore

View File

@ -0,0 +1,9 @@
OPENF1_URL: str = "https://api.openf1.org/v1"
OPENF1_SESSION_ENDPOINT: str = f"{OPENF1_URL}/sessions"
OPENF1_POSITION_ENDPOINT: str = f"{OPENF1_URL}/position"
OPENF1_DRIVER_ENDPOINT: str = f"{OPENF1_URL}/drivers"
OPENF1_SESSION_TYPE_RACE: str = "Race"
OPENF1_SESSION_NAME_RACE: str = "Race"
OPENF1_SESSION_NAME_SPRINT: str = "Sprint"

View File

@ -0,0 +1,76 @@
from datetime import datetime
import json
from typing import Any, Callable, Dict, List, cast
from requests import Response, get
from formula10.openf1.model.openf1_driver import OpenF1Driver
from formula10.openf1.model.openf1_position import OpenF1Position
from formula10.openf1.model.openf1_session import OpenF1Session
from formula10.openf1.openf1_definitions import OPENF1_DRIVER_ENDPOINT, OPENF1_POSITION_ENDPOINT, OPENF1_SESSION_ENDPOINT, OPENF1_SESSION_NAME_RACE, OPENF1_SESSION_NAME_SPRINT, OPENF1_SESSION_TYPE_RACE
def openf1_request_helper(endpoint: str, params: Dict[str, str]) -> List[Dict[str, str]]:
response: Response = get(endpoint, params=params)
if not response.ok:
raise Exception(f"OpenF1 request to {response.request.url} failed")
obj: Any = json.loads(response.text)
if isinstance(obj, List):
return cast(List[Dict[str, str]], obj)
elif isinstance(obj, Dict):
return [cast(Dict[str, str], obj)]
else:
# @todo Fail gracefully
raise Exception(f"Unexpected OpenF1 response from {response.request.url}: {obj}")
def openf1_fetch_latest_session(session_name: str) -> OpenF1Session:
# ApiSession object only supports integer session_keys
response: List[Dict[str, str]] = openf1_request_helper(OPENF1_SESSION_ENDPOINT, {
"session_key": "latest",
"session_type": OPENF1_SESSION_TYPE_RACE,
"session_name": session_name
})
return OpenF1Session(response[0])
def openf1_fetch_latest_race_session_key() -> int:
return openf1_fetch_latest_session(OPENF1_SESSION_NAME_RACE).session_key
def openf1_fetch_latest_sprint_session_key() -> int:
return openf1_fetch_latest_session(OPENF1_SESSION_NAME_SPRINT).session_key
def openf1_fetch_session(session_name: str, country_code: str) -> OpenF1Session:
_session: OpenF1Session = OpenF1Session(None)
_session.session_type = OPENF1_SESSION_TYPE_RACE # includes races + sprints
_session.year = 2024
_session.country_code = country_code
_session.session_name = session_name
response: List[Dict[str, str]] = openf1_request_helper(OPENF1_SESSION_ENDPOINT, _session.to_params())
return OpenF1Session(response[0])
def openf1_fetch_driver(session_key: int, name_acronym: str) -> OpenF1Driver:
_driver: OpenF1Driver = OpenF1Driver(None)
_driver.name_acronym = name_acronym
_driver.session_key = session_key
response: List[Dict[str, str]] = openf1_request_helper(OPENF1_DRIVER_ENDPOINT, _driver.to_params())
return OpenF1Driver(response[0])
def openf1_fetch_position(session_key: int, position: int):
_position: OpenF1Position = OpenF1Position(None)
_position.session_key = session_key
_position.position = position
response: List[Dict[str, str]] = openf1_request_helper(OPENF1_POSITION_ENDPOINT, _position.to_params())
# Find the last driver that was on this position at last
predicate: Callable[[Dict[str, str]], datetime] = lambda position: datetime.strptime(position["date"], "%Y-%m-%dT%H:%M:%S.%f")
return OpenF1Position(max(response, key=predicate))

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,29 @@
// Set the date we're counting down to
var raceTime = document.getElementById("race_date").innerHTML
var countDownDate = new Date(raceTime).getTime();
// Update the countdown every 1 second
var x = setInterval(function() {
// Get today's date and time
var now = new Date().getTime();
// Find the distance between now and the countdown date
var distance = countDownDate - now;
// Time calculations for days, hours, minutes and seconds
var days = Math.floor(distance / (1000 * 60 * 60 * 24));
var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
var seconds = Math.floor((distance % (1000 * 60)) / 1000);
// Display the result in the element with id="demo"
document.getElementById("race_date_countdown").innerHTML = days + "d " + hours + "h " + minutes + "m " + seconds + "s ";
// If the countdown is finished, write some text
if (distance < 0) {
clearInterval(x);
document.getElementById("race_date_countdown").innerHTML = "GO GO GO";
}
}, 1000);

View File

@ -41,13 +41,15 @@ function handleDrop(e) {
// Don't do anything if dropping the same column we're dragging.
if (dragSrcEl != this) {
// Set the source column's HTML to the HTML of the column we dropped on.
//alert(this.outerHTML);
//dragSrcEl.innerHTML = this.innerHTML;
//this.innerHTML = e.dataTransfer.getData('text/html');
// alert(this.outerHTML);
// dragSrcEl.innerHTML = this.innerHTML;
// this.innerHTML = e.dataTransfer.getData('text/html');
this.parentNode.removeChild(dragSrcEl);
let dropHTML = e.dataTransfer.getData('text/html');
this.insertAdjacentHTML('beforebegin', dropHTML);
let dropElem = this.previousSibling;
addDnDHandlers(dropElem);
}

View File

@ -0,0 +1,18 @@
.chart-container {
width: 100%;
height: 40vh;
}
@media only screen and (max-width: 900px) {
.chart-container {
width: 100%;
height: 50vh;
}
}
@media only screen and (orientation: landscape) and (max-width: 900px) {
.chart-container {
width: 100%;
height: 100vh;
}
}

View File

@ -0,0 +1,27 @@
.card-grid {
grid-template-columns: repeat(auto-fit, minmax(325px, 1fr));
grid-row-gap: 0;
grid-column-gap: 8px;
}
@media only screen and (min-width: 470px) {
.card-grid {
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
grid-row-gap: 0;
grid-column-gap: 8px;
}
}
.card-grid-2 {
grid-template-columns: repeat(1, minmax(325px, 1fr));
grid-row-gap: 0;
grid-column-gap: 8px;
}
@media only screen and (min-width: 950px) {
.card-grid-2 {
grid-template-columns: repeat(2, minmax(450px, 1fr));
grid-row-gap: 0;
grid-column-gap: 8px;
}
}

View File

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
{# Active user navbar dropdown #}
{% macro active_user_dropdown(page) %}
{% if model.all_users() | length > 1 %}
<div class="dropdown">
<button class="btn btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
{{ model.active_user_name_or_everyone() }}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/{{ page }}/Everyone">Everyone</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for user in model.all_users() %}
<li><a class="dropdown-item" href="/{{ page }}/{{ user.name_sanitized }}">{{ user.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endmacro %}
{# Simple driver select for forms #}
{% macro driver_select(name, label, include_none, include_inactive=false, drivers=none, disabled=false, border="") %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
<option value="" selected disabled hidden></option>
{% if drivers == none %}
{% set drivers = model.all_drivers(include_none=include_none, include_inactive=include_inactive) %}
{% endif %}
{% for driver in drivers %}
<option value="{{ driver.id }}">{{ driver.abbr }}</option>
{% endfor %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Driver select for forms where a value might be preselected #}
{% macro driver_select_with_preselect(driver_match, name, label, include_none, include_inactive=false, drivers=none, disabled=false, border="") %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
{# Use namespace wrapper to persist scope between loop iterations #}
{% set user_has_chosen = namespace(driverpre=false) %}
{% if drivers == none %}
{% set drivers = model.all_drivers(include_none=include_none, include_inactive=include_inactive) %}
{% endif %}
{% for driver in drivers %}
{% if driver_match == driver %}
{% set user_has_chosen.driverpre = true %}
<option selected="selected" value="{{ driver.id }}">{{ driver.abbr }}</option>
{% else %}
<option value="{{ driver.id }}">{{ driver.abbr }}</option>
{% endif %}
{% if (include_none == true) and (driver == model.none_driver()) %}
<option disabled="disabled">──────────</option>
{% endif %}
{% endfor %}
{# Add an empty default if nothing has been chosen #}
{% if user_has_chosen.driverpre == false %}
<option value="" selected="selected" disabled="disabled" hidden="hidden"></option>
{% endif %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Simple team select for forms #}
{% macro team_select(name, label, include_none, teams=none, disabled=false, border="") %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
<option value="" selected disabled hidden></option>
{% if teams == none %}
{% set teams = model.all_teams(include_none=include_none) %}
{% endif %}
{% for team in teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Team select for forms where a value might be preselected #}
{% macro team_select_with_preselect(team_match, name, label, include_none, teams=none, disabled=false, border="") %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
{# Use namespace wrapper to persist scope between loop iterations #}
{% set user_has_chosen = namespace(teampre=false) %}
{% if teams == none %}
{% set teams = model.all_teams(include_none=include_none) %}
{% endif %}
{% for team in teams %}
{% if team_match == team %}
{% set user_has_chosen.teampre = true %}
<option selected="selected" value="{{ team.id }}">{{ team.name }}</option>
{% else %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endif %}
{% if (include_none == true) and (team == model.none_team()) %}
<option disabled="disabled">──────────</option>
{% endif %}
{% endfor %}
{# Add an empty default if nothing has been chosen #}
{% if user_has_chosen.teampre == false %}
<option value="" selected="selected" disabled="disabled" hidden="hidden"></option>
{% endif %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Easy nav-bar entries. When a page sets the active_page variable, the current entry will be underlined #}
{% macro nav_selector(page, text) %}
<a class="nav-link text-nowrap" href="{{ page }}">{% if active_page == page %}<u>{% endif %} {{ text }}
{# NOTE: active_page should be set at the top of each template #}
{% if active_page == page %}</u>{% endif %}</a>
{% endmacro %}
{% macro pxx_guess_colorization(guessed_driver, result) -%}
{% if (guessed_driver == result.offset_from_place_to_guess(-3)) and (guessed_driver != model.none_driver()) %}
fw-bold
{% elif (guessed_driver == result.offset_from_place_to_guess(-2)) and (guessed_driver != model.none_driver()) %}
text-danger fw-bold
{% elif (guessed_driver == result.offset_from_place_to_guess(-1)) and (guessed_driver != model.none_driver()) %}
text-warning fw-bold
{% elif (guessed_driver == result.offset_from_place_to_guess( 0)) %}text-success fw-bold
{% elif (guessed_driver == result.offset_from_place_to_guess( 1)) and (guessed_driver != model.none_driver()) %}
text-warning fw-bold
{% elif (guessed_driver == result.offset_from_place_to_guess( 2)) and (guessed_driver != model.none_driver()) %}
text-danger fw-bold
{% elif (guessed_driver == result.offset_from_place_to_guess( 3)) and (guessed_driver != model.none_driver()) %}
fw-bold
{% endif %}
{%- endmacro %}
{% macro dnf_guess_colorization(guessed_driver, result) -%}
{% if guessed_driver in result.initial_dnf %}text-success fw-bold
{% elif (guessed_driver == model.none_driver()) and (result.initial_dnf | length == 0) %}text-success fw-bold
{% endif %}
{%- endmacro %}
{# @formatter:off #}
{% macro pxx_standing_tooltip_text(result) %}
{%- for position in range(-3, 4) %}
{%- set driver = result.offset_from_place_to_guess(position, respect_nc=false) %}
{{- driver.abbr ~ result.driver_standing_position_string(driver) }}
{% endfor %}
{%- endmacro %}
{# @formatter:on #}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Title -->
<title>{% block title %}{% endblock title %}</title>
<link rel="icon" href="../static/image/favicon.svg" sizes="any" type="image/svg+xml">
<!-- Bootstrap -->
<link href="../static/style/bootstrap.css" rel="stylesheet">
<script src="../static/script/bootstrap.bundle.js"></script>
<!-- ChartJS -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom -->
<link href="../static/style/grid.css" rel="stylesheet">
<link href="../static/style/diagram.css" rel="stylesheet">
<script defer>
{# Initialize Bootstrap Tooltips #}
let tooltipTriggerList = document.querySelectorAll("[data-bs-toggle='tooltip']")
let tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
</script>
{% block head_extra %}{% endblock head_extra %}
</head>
<body>
<nav class="navbar fixed-top navbar-expand-lg bg-body-tertiary shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="/race/Everyone">
<img src="../static/image/f1_logo.svg" alt="Logo" width="120" height="30"
class="d-inline-block align-text-top">
Formula 10
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<div class="navbar-nav me-2">
{{ nav_selector(page="/race/" ~ model.active_user_name_sanitized_or_everyone(), text="Race Picks") }}
{{ nav_selector(page="/season/" ~ model.active_user_name_sanitized_or_everyone(), text="Season Picks") }}
{{ nav_selector(page="/graphs", text="Leaderboard") }}
{{ nav_selector(page="/stats", text="Statistics") }}
{{ nav_selector(page="/rules", text="Rules") }}
</div>
{% block navbar_center %}{% endblock navbar_center %}
<div class="flex-grow-1"></div>
<div class="navbar-nav">
{{ nav_selector(page="/result", text="Enter Race Result") }}
{{ nav_selector(page="/user", text="Manage Users") }}
</div>
</div>
</div>
</nav>
<div class="px-2 pt-2" style="margin-top: 55px !important;">
{% block body %}{% endblock body %}
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Error{% endblock title %}
{% set active_page = "/error" %}
{% block body %}
<div class="card shadow-sm mb-2">
<div class="card-header">
<span class="text-danger fw-bold">Error</span>
</div>
<div class="card-body">
{{ error_message }}
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,110 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Leaderboard{% endblock title %}
{% set active_page = "/graphs" %}
{% block body %}
{# <div class="card shadow-sm mb-2">#}
{# <div class="card-header">#}
{# Note#}
{# </div>#}
{##}
{# <div class="card-body">#}
{# Points only include race picks.#}
{# </div>#}
{# </div>#}
<div class="card shadow-sm mb-2">
<div class="card-header">
Leaderboard
</div>
<div class="card-body">
<div class="d-inline-block overflow-x-scroll w-100">
<table class="table table-bordered table-sm table-responsive">
<thead>
<tr>
<th scope="col" class="text-center" style="min-width: 50px;">Place</th>
<th scope="col" class="text-center" style="min-width: 50px;">User</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points (Race)</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points (Season)</th>
<th scope="col" class="text-center" style="min-width: 100px;">Total picks</th>
<th scope="col" class="text-center" style="min-width: 100px;" data-bs-toggle="tooltip"
title="Any points count as correct">Correct picks
</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points per pick</th>
</tr>
</thead>
<tbody>
{% for user in points.users_sorted_by_points(include_season=True) %}
{% set user_standing = points.user_standing(include_season=True)[user.name] %}
<tr class="{% if user_standing == 1 %}table-danger{% endif %}">
<td class="text-center text-nowrap">{{ user_standing }}</td>
<td class="text-center text-nowrap">{{ user.name }}</td>
<td class="text-center text-nowrap">{{ points.total_points_by(user_name=user.name, include_season=True) }}</td>
<td class="text-center text-nowrap">{{ points.total_points_by(user_name=user.name, include_season=False) }}</td>
<td class="text-center text-nowrap">{{ points.season_points_by(user_name=user.name) }}</td>
<td class="text-center text-nowrap">{{ points.picks_count(user.name) }}</td>
<td class="text-center text-nowrap">{{ points.picks_with_points_count(user.name) }}</td>
<td class="text-center text-nowrap">{{ "%0.2f" % points.points_per_pick(user.name) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
History (Race)
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="line-chart"></canvas>
</div>
<script>
function cumulative_points(data) {
return new Chart(document.getElementById("line-chart"), {
type: 'line',
data: data,
options: {
title: {
display: true,
text: 'History'
},
{#tension: 0,#}
responsive: true,
maintainAspectRatio: false,
pointRadius: 5,
pointHoverRadius: 10,
scales: {
y: {
min: 0,
{#max: 100#}
}
}
}
});
}
cumulative_points({{ points.cumulative_points_data() | safe }})
</script>
</div>
</div>
{# <div class="card mt-2">#}
{# <div class="card-body">#}
{# <h5 class="card-title">Statistics</h5>#}
{# Various statistics: Driver voted most for DNF #}
{# </div>#}
{# </div>#}
{% endblock body %}

View File

@ -0,0 +1,204 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Race{% endblock title %}
{% set active_page = "/race/" ~ model.active_user_name_sanitized_or_everyone() %}
{% block head_extra %}
<script src="../static/script/countdown.js" defer></script>
{% endblock head_extra %}
{% block navbar_center %}
{{ active_user_dropdown(page='race') }}
{% endblock navbar_center %}
{% block body %}
{# Put table in this div to make right padding work #}
<div class="d-inline-block overflow-x-scroll w-100 mb-2">
<table class="table table-bordered table-sm table-responsive shadow-sm">
<thead>
<tr>
<th scope="col" rowspan="2" class="text-center" style="width: 125px;">Race</th>
<th scope="col" {% if model.active_user is none %}colspan="{{ model.all_users() | length }}"{% endif %}
class="text-center">Call
</th>
<th scope="col" rowspan="2" class="text-center" style="width: 125px;">Result</th>
</tr>
</thead>
<tbody>
{# Users List #}
<tr>
<td>&nbsp;</td>
{# Link should only be visible if all users are visible #}
{% if model.active_user is not none %}
<td class="text-center text-nowrap" style="min-width: 100px;">{{ model.active_user.name }}
({{ points.total_points_by(user_name=model.active_user.name, include_season=False) }})
</td>
{% else %}
{% for user in model.all_users() %}
<td class="text-center text-nowrap" style="min-width: 100px;">
<a href="/race/{{ user.name_sanitized }}" class="link-dark">{{ user.name }}
({{ points.total_points_by(user_name=user.name, include_season=False) }})</a>
</td>
{% endfor %}
{% endif %}
<td>&nbsp;</td>
</tr>
{# Current Result, only displayed for all users overview and if guess is remaining #}
{% if (model.active_user is none) and (model.current_race is not none) %}
<tr class="table-danger">
<td class="text-nowrap">
<span class="fw-bold">{{ model.current_race.number }}:</span> {{ model.current_race.name }}<br>
<small><span class="fw-bold">Guess:</span> P{{ model.current_race.place_to_guess }}</small><br>
<small><span class="fw-bold">Date:</span> {{ model.current_race.date.strftime("%d.%m %H:%M") }}
</small>
</td>
{% if model.all_users() | length > 0 %}
{% for user in model.all_users() %}
{% set user_guess = model.race_guesses_by(user_name=user.name, race_name=model.current_race.name) %}
<td class="text-center text-nowrap">
{% if user_guess is not none %}
<ul class="list-group list-group-flush">
<li class="list-group-item" style="background-color: inherit;">
{{ user_guess.pxx_guess.abbr }}
</li>
<li class="list-group-item" style="background-color: inherit;">
{{ user_guess.dnf_guess.abbr }}
</li>
</ul>
{% else %}
&nbsp;
{% endif %}
</td>
{% endfor %}
{% else %}
<td>&nbsp;</td>
{% endif %}
{# Race countdown #}
<span id="race_date" hidden="hidden">{{ model.current_race.date.strftime("%Y-%m-%dT%H:%M") }}</span>
<td class="text-center text-nowrap align-middle">
<span class="fw-bold">Race starts in:</span><br>
<span id="race_date_countdown">00d 00h 00m 00s</span>
</td>
</tr>
{% endif %}
{# Enter Guess, only displayed for single user focused view and if guess is remaining #}
{% if (model.active_user is not none) and (model.current_race is not none) %}
<tr class="table-danger">
<td class="text-nowrap">
<span class="fw-bold">{{ model.current_race.number }}:</span> {{ model.current_race.name }}<br>
<small><span class="fw-bold">Guess:</span> P{{ model.current_race.place_to_guess }}</small><br>
<small><span class="fw-bold">Date:</span> {{ model.current_race.date.strftime("%d.%m %H:%M") }}
</small>
</td>
<td>
{% set race_guess_open = model.race_guess_open(model.current_race) %}
{% if race_guess_open == true %}
{% set action_save_href = "/race-guess/" ~ model.current_race.name_sanitized ~ "/" ~ model.active_user.name_sanitized %}
{% set action_delete_href = "/race-guess-delete/" ~ model.current_race.name_sanitized ~ "/" ~ model.active_user.name_sanitized %}
{% else %}
{% set action_save_href = "" %}
{% set action_delete_href = "" %}
{% endif %}
{# Enter + Save guess #}
<form action="{{ action_save_href }}" method="post">
{% set user_guess = model.race_guesses_by(user_name=model.active_user.name, race_name=model.current_race.name) %}
{# Driver PXX Select #}
{{ driver_select_with_preselect(driver_match=user_guess.pxx_guess, name="pxxselect", label="P" ~ model.current_race.place_to_guess ~ ":", include_none=true, disabled=not race_guess_open) }}
<div class="mt-2"></div>
{# Driver DNF Select #}
{{ driver_select_with_preselect(driver_match=user_guess.dnf_guess, name="dnfselect", label="DNF:", include_none=true, disabled=not race_guess_open) }}
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save"
{% if race_guess_open == false %}disabled="disabled"{% endif %}>
</form>
{# Delete guess #}
<form action="{{ action_delete_href }}" method="post">
<input type="submit" class="btn btn-dark mt-2 w-100" value="Delete"
{% if race_guess_open == false %}disabled="disabled"{% endif %}>
</form>
</td>
<td>&nbsp;</td>
</tr>
{% endif %}
{# Past Race Results #}
{% for past_result in model.all_race_results() %}
<tr>
<td class="text-nowrap">
<span class="fw-bold">{{ past_result.race.number }}:</span> {{ past_result.race.name }}<br>
<small><span class="fw-bold">Guessed:</span> P{{ past_result.race.place_to_guess }}</small><br>
<small><span class="fw-bold">Date:</span> {{ past_result.race.date.strftime("%d.%m %H:%M") }}
</small>
</td>
{% if model.all_users_or_active_user() | length > 0 %}
{% for user in model.all_users_or_active_user() %}
<td class="text-center text-nowrap">
{% set user_guess = model.race_guesses_by(user_name=user.name, race_name=past_result.race.name) %}
{% if user_guess is not none %}
<ul class="list-group list-group-flush">
<li class="list-group-item {{ pxx_guess_colorization(guessed_driver=user_guess.pxx_guess, result=past_result) }}">
<span data-bs-toggle="tooltip"
title="{{ past_result.driver_standing_points_string(user_guess.pxx_guess) }}">
{{ user_guess.pxx_guess.abbr ~ past_result.driver_standing_position_string(user_guess.pxx_guess) }}
</span>
</li>
<li class="list-group-item {{ dnf_guess_colorization(guessed_driver=user_guess.dnf_guess, result=past_result) }}">
<span data-bs-toggle="tooltip"
title="{{ past_result.driver_dnf_points_string(user_guess.dnf_guess) }}">
{{ user_guess.dnf_guess.abbr }}
</span>
</li>
</ul>
{% else %}
&nbsp;
{% endif %}
</td>
{% endfor %}
{% else %}
<td>&nbsp;</td>
{% endif %}
{# Actual result #}
<td class="text-center text-nowrap">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<span data-bs-toggle="tooltip" title="{{ pxx_standing_tooltip_text(result=past_result) }}">
P{{ past_result.race.place_to_guess }}: {{ past_result.offset_from_place_to_guess(0).abbr }}
</span>
</li>
<li class="list-group-item">
DNF: {{ past_result.initial_dnf_string() }}</li>
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock body %}

View File

@ -0,0 +1,217 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Race Result{% endblock title %}
{% set active_page = "/result" %}
{% block head_extra %}
<link href="../static/style/draggable.css" rel="stylesheet">
<script src="../static/script/draggable.js" defer></script>
{% endblock head_extra %}
{% block navbar_center %}
{% if model.all_race_results() | length > 0 %}
<div class="dropdown">
<button class="btn btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
{{ model.active_result_race_name_or_current_race_name() }}
</button>
<ul class="dropdown-menu">
{% if model.current_race is not none %}
<li>
<a class="dropdown-item"
href="/result/{{ model.current_race.name_sanitized }}">{{ model.current_race.name }}</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
{% endif %}
{% for result in model.all_race_results() %}
<li>
<a class="dropdown-item"
href="/result/{{ result.race.name_sanitized }}">{{ result.race.name }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock navbar_center %}
{% block body %}
{% set race_result_open=model.race_result_open(model.active_result_race_name_or_current_race_name()) %}
{% if race_result_open == true %}
{% set action_save_href = "/result-enter/" ~ model.active_result_race_name_or_current_race_name_sanitized() %}
{% set action_fetch_href = "/result-fetch/" ~ model.active_result_race_name_or_current_race_name_sanitized() %}
{% else %}
{% set action_save_href = "" %}
{% set action_fetch_href = "" %}
{% endif %}
<form action="{{ action_fetch_href }}" method="post">
<div class="card shadow-sm mb-2 w-100">
<div class="card-header">
Autofill
</div>
<div class="card-body">
<input type="submit" class="btn btn-danger mt-2 w-100" value="Fetch using FastF1"
{% if race_result_open == false %}disabled="disabled"{% endif %}>
</div>
</div>
</form>
<form class="grid card-grid" action="{{ action_save_href }}" method="post">
{# Race result #}
<div class="card shadow-sm mb-2 w-100">
<div class="card-header">
{{ model.active_result_race_name_or_current_race_name() }}
</div>
<div class="card-body">
<div class="d-inline-block overflow-x-scroll w-100">
<div style="width: 460px;">
{# Place numbers #}
<ul class="list-group list-group-flush d-inline-block">
{% for driver in model.all_active_drivers_or_active_result_standing_drivers() %}
<li class="list-group-item p-1"><span id="place_number"
class="fw-bold">P{{ "%02d" % loop.index }}</span>:
</li>
{% endfor %}
</ul>
{# Drag and drop, "#columns .column" is the selector for the JS #}
<ul id="columns" class="list-group list-group-flush d-inline-block float-end">
{% for driver in model.all_active_drivers_or_active_result_standing_drivers() %}
<li class="list-group-item {% if race_result_open == true %}column{% endif %} p-1"
{% if race_result_open == true %}draggable="true"{% endif %}>
{{ driver.name }}
<div class="d-inline-block float-end" style="margin-left: 30px;">
{# Fastest lap #}
<div class="form-check form-check-reverse d-inline-block">
<input type="radio" class="form-check-input"
value="{{ driver.id }}"
id="fastest-lap-{{ driver.id }}" name="fastest-lap"
{% if (model.active_result is not none) and (driver.id == model.active_result.fastest_lap_driver.id) %}checked{% endif %}
{% if race_result_open == false %}disabled="disabled"{% endif %}>
<label for="fastest-lap-{{ driver.id }}"
class="form-check-label text-muted" data-bs-toggle="tooltip"
title="Fastest lap">Lap</label>
</div>
{# Driver DNFed at first #}
<div class="form-check form-check-reverse d-inline-block"
style="margin-left: 2px;">
<input type="checkbox" class="form-check-input"
value="{{ driver.id }}"
id="first-dnf-{{ driver.id }}" name="first-dnf-drivers"
{% if (model.active_result is not none) and (driver in model.active_result.initial_dnf) %}checked{% endif %}
{% if race_result_open == false %}disabled="disabled"{% endif %}>
<label for="first-dnf-{{ driver.id }}"
class="form-check-label text-muted">1. DNF</label>
</div>
{# Driver DNFed #}
<div class="form-check form-check-reverse d-inline-block"
style="margin-left: 2px;">
<input type="checkbox" class="form-check-input"
value="{{ driver.id }}"
id="dnf-{{ driver.id }}" name="dnf-drivers"
{% if (model.active_result is not none) and (driver in model.active_result.all_dnfs) %}checked{% endif %}
{% if race_result_open == false %}disabled="disabled"{% endif %}>
<label for="dnf-{{ driver.id }}"
class="form-check-label text-muted">DNF</label>
</div>
{# Driver Excluded #}
<div class="form-check form-check-reverse d-inline-block"
style="margin-left: 2px;">
<input type="checkbox" class="form-check-input"
value="{{ driver.id }}"
id="exclude-{{ driver.id }}" name="excluded-drivers"
{% if (model.active_result is not none) and (driver in model.active_result.standing_exclusions) %}checked{% endif %}
{% if race_result_open == false %}disabled="disabled"{% endif %}>
<label for="exclude-{{ driver.id }}"
class="form-check-label text-muted" data-bs-toggle="tooltip"
title="Driver is not counted for standing">NC</label>
</div>
</div>
{# Standing order #}
<input type="hidden" name="pxx-drivers" value="{{ driver.id }}">
</li>
{% endfor %}
</ul>
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save"
{% if race_result_open == false %}disabled="disabled"{% endif %}>
</div>
</div>
</div>
</div>
{# Sprint result #}
{% if model.active_result_race_or_current_race().has_sprint == true %}
<div class="card shadow-sm mb-2 w-100">
<div class="card-header">
Sprint
</div>
<div class="card-body">
<div class="d-inline-block overflow-x-scroll w-100">
<div style="width: 275px;">
{# Place numbers #}
<ul class="list-group list-group-flush d-inline-block">
{% for driver in model.all_active_drivers_or_active_result_sprint_standing_drivers() %}
<li class="list-group-item p-1"><span id="place_number"
class="fw-bold">P{{ "%02d" % loop.index }}</span>:
</li>
{% endfor %}
</ul>
{# Drag and drop, "#columns .column" is the selector for the JS #}
<ul id="columns" class="list-group list-group-flush d-inline-block float-end">
{% for driver in model.all_active_drivers_or_active_result_sprint_standing_drivers() %}
<li class="list-group-item {% if race_result_open == true %}column{% endif %} p-1"
{% if race_result_open == true %}draggable="true"{% endif %}>
{{ driver.name }}
<div class="d-inline-block float-end" style="margin-left: 30px;">
{# Driver DNFed #}
<div class="form-check form-check-reverse d-inline-block"
style="margin-left: 2px;">
<input type="checkbox" class="form-check-input"
value="{{ driver.id }}"
id="sprint-dnf-{{ driver.id }}" name="sprint-dnf-drivers"
{% if (model.active_result is not none) and (driver in model.active_result.sprint_dnfs) %}checked{% endif %}
{% if race_result_open == false %}disabled="disabled"{% endif %}>
<label for="sprint-dnf-{{ driver.id }}"
class="form-check-label text-muted">DNF</label>
</div>
</div>
{# Standing order #}
<input type="hidden" name="sprint-pxx-drivers" value="{{ driver.id }}">
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endif %}
</form>
{% endblock body %}

View File

@ -0,0 +1,96 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Rules{% endblock title %}
{% set active_page = "/rules" %}
{% block body %}
<div class="card shadow-sm mb-2">
<div class="card-header">
Format
</div>
<div class="card-body">
<p>
Das Tippspiel wird dieses Jahr in zwei Teile aufgespalten: Renntips (für jedes Rennen) und Bonustipps
(für die ganze Saison).
Renntipps müssen bis Rennstart, Bonustipps bis 1. März abgegeben werden.
Tipps können entweder selbst eingetragen werden, oder in die Gruppe geschrieben werden, um gültig zu
sein.
</p>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
Renntipps
</div>
<div class="card-body">
<p>
Jedes Rennen besteht aus zwei Tipps.
Einem Platzierungstipp (PX-Tipp) und einem Tipp welcher Fahrer als Erstes das Rennen abbricht
(DNF-Tipp).
Anders als beim P10-Tipp letztes Jahr wird dieses Mal jedes Rennen eine andere Platzierung getippt.
Die zu tippende Platzierung wird auf der Tabellen-Übersichtsseite und im Fahrer-Auswahlmenü angezeigt.
</p>
<p>
DNS/DSQ zählt hierbei nicht als DNF, außerdem besteht die Möglichkeit zu tippen, dass kein Fahrer DNF't oder
das Rennen auf PX beendet.
</p>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
Punkte
</div>
<div class="card-body">
<p>
Für einen korrekten Tipp werden 10 Punkte vergeben.
Beim PX-Tipp werden 6 Punkte für einen Platz Abweichung, 3 Punkte für zwei plätze Abweichung und 1 Punkt
für 3 Plätze Abweichung vergeben.
</p>
<p>
Beim DNF-Tipp werden keine Abweichungspunkte vergeben, ebensowenig wie bei der Auswahl "Keiner" für den
PX-Tipp.
</p>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
Bonustipps
</div>
<div class="card-body">
<p>
Zusätzlich gibt es dieses Jahr auch Tipps, die sich auf die ganze Saison beziehen und verschieden
bepunktet werden.
</p>
<ul class="list-group list-group-numbered">
<li class="list-group-item">Welches Team wird P2 in der constructors championship? (10 Punkte für die
richtige Antwort / 0 Punkte für eine falsche Antwort)
</li>
<li class="list-group-item">Welcher Fahrer verbessert am meisten seine Platzierung in der drivers
championship im Vergleich zum Vorjahr? (10/0)
</li>
<li class="list-group-item">Welcher Fahrer verschlechtert am meisten seine Platzierung in der drivers
championship im Vergleich zum Vorjahr? (10/0)
</li>
<li class="list-group-item">Welcher Fahrer hat die meisten DNFs? (10/0)</li>
<li class="list-group-item">Welcher Fahrer hat die meisten Überholungen? (10/0)</li>
<li class="list-group-item">Nenne alle Fahrer, die mindestens einmal auf dem Podium stehen werden.
(3/-2, nicht-gegebene Antworten zählen als falsch)
</li>
<li class="list-group-item">Nenne für alle Teams den Sieger des teaminternen Duells. (3/-3)</li>
<li class="list-group-item">Nenne einen hot-take für die Saison (10/0, "hotness" wird vorher abgesegnet)</li>
</ul>
</div>
</div>
{% endblock body %}

View File

@ -0,0 +1,185 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Season{% endblock title %}
{% set active_page = "/season/" ~ model.active_user_name_sanitized_or_everyone() %}
{% block navbar_center %}
{{ active_user_dropdown(page='season') }}
{% endblock navbar_center %}
{% block body %}
{# <div class="card shadow-sm mb-2">#}
{# <div class="card-header">#}
{# Note#}
{# </div>#}
{##}
{# <div class="card-body">#}
{# Picks that match the current standings are marked in green, except for the hot-take and overtake picks, as#}
{# those are not evaluated automatically.<br>#}
{# </div>#}
{# </div>#}
<div class="grid card-grid">
{% for user in model.all_users_or_active_user() %}
<div class="card shadow-sm mb-2">
<div class="card-header">
{# Link should only be visible if all users are visible #}
{% if model.active_user is not none %}
{{ user.name }}
{% else %}
<a href="/season/{{ user.name }}" class="link-dark">{{ user.name }}</a>
{% endif %}
</div>
<div class="card-body">
{% set user_guess = model.season_guesses_by(user_name=user.name) %}
{% set season_guess_open = model.season_guess_open() %}
{% if season_guess_open == true %}
{% set action_save_href = "/season-guess/" ~ user.name %}
{% else %}
{% set action_save_href = "" %}
{% endif %}
<form action="{{ action_save_href }}" method="post">
{# Hot Take #}
<div class="form-floating">
<textarea
class="form-control {% if points.hot_take_correct(user.name) %}border-success{% else %}border-danger{% endif %}"
id="hot-take-input-{{ user.name }}" name="hottakeselect"
style="height: 150px"
{% if season_guess_open == false %}disabled="disabled"{% endif %}>
{%- if user_guess is not none -%}{{ user_guess.hot_take_string() }}{%- endif -%}
</textarea>
<label for="hot-take-input-{{ user.name }}" class="text-primary">Hot Take:</label>
</div>
{# P2 Constructor #}
<div class="mt-2">
{{ team_select_with_preselect(team_match=user_guess.p2_wcc, name="p2select", label="P2 in WCC:",
include_none=false, disabled=not season_guess_open,
border=("border-success" if points.p2_constructor_correct(user.name) else "border-danger")) }}
</div>
{# Most Overtakes + DNFs #}
<div class="input-group mt-2">
{{ driver_select_with_preselect(driver_match=user_guess.most_overtakes, name="overtakeselect",
label="Most overtakes:", include_none=false, include_inactive=true, disabled=not season_guess_open,
border=("border-success" if points.overtakes_correct(user.name) else "border-danger")) }}
{{ driver_select_with_preselect(driver_match=user_guess.most_dnfs, name="dnfselect", label="Most DNFs:",
include_none=false, include_inactive=true, disabled=not season_guess_open,
border=("border-success" if points.dnfs_correct(user.name) else "border-danger")) }}
</div>
{# Most Gained + Lost #}
<div class="input-group mt-2" data-bs-toggle="tooltip"
title="Which driver will gain/lose the most places in comparison to last season's results?">
{{ driver_select_with_preselect(driver_match=user_guess.most_wdc_gained, name="gainedselect",
label="Most WDC pl. gained:", include_none=false, include_inactive=true,
drivers=model.active_drivers_for_wdc_gained(), disabled=not season_guess_open,
border=("border-success" if points.most_gained_correct(user.name) else "border-danger")) }}
{{ driver_select_with_preselect(driver_match=user_guess.most_wdc_lost, name="lostselect",
label="Most WDC pl. lost:", include_none=false, include_inactive=true, disabled=not season_guess_open,
border=("border-success" if points.most_lost_correct(user.name) else "border-danger")) }}
</div>
{# Team-internal Winners #}
<h6 class="card-subtitle mt-2" data-bs-toggle="tooltip"
title="Which driver will finish the season higher than his teammate?">Teammate battle
winners:</h6>
<div class="grid mt-2 container" style="row-gap: 0;">
{% for team in model.all_teams(include_none=false) %}
{# HACK: Choosing 0 and 1 will chose the drivers with the lowest IDs (although there could be others). #}
{# This means the drivers chosen from at the start of the season will be visible. #}
{% set driver_a = model.drivers_by(team_name=team.name, include_inactive=True)[0] %}
{% set driver_b = model.drivers_by(team_name=team.name, include_inactive=True)[1] %}
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio"
name="teamwinner-{{ team.id }}"
id="teamwinner-{{ team.id }}-1-{{ user.id }}"
value="{{ driver_a.id }}"
{% if (user_guess is not none) and (driver_a in user_guess.team_winners) %}checked="checked"{% endif %}
{% if season_guess_open == false %}disabled="disabled"{% endif %}>
<label class="form-check-label {% if (user_guess is not none) and (driver_a in user_guess.team_winners) and points.is_team_winner(driver_a) %}text-success
{% elif (user_guess is not none) and (driver_a in user_guess.team_winners) and (not points.is_team_winner(driver_a)) %}text-danger{% endif %}"
for="teamwinner-{{ team.id }}-1-{{ user.id }}">{{ driver_a.name }}</label>
</div>
</div>
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio"
name="teamwinner-{{ team.id }}"
id="teamwinner-{{ team.id }}-2-{{ user.id }}"
value="{{ driver_b.id }}"
{% if (user_guess is not none) and (driver_b in user_guess.team_winners) %}checked="checked"{% endif %}
{% if season_guess_open == false %}disabled="disabled"{% endif %}>
<label class="form-check-label {% if (user_guess is not none) and (driver_b in user_guess.team_winners) and points.is_team_winner(driver_b) %}text-success
{% elif (user_guess is not none) and (driver_b in user_guess.team_winners) and (not points.is_team_winner(driver_b)) %}text-danger{% endif %}"
for="teamwinner-{{ team.id }}-2-{{ user.id }}">{{ driver_b.name }}</label>
</div>
</div>
{% endfor %}
</div>
{# Drivers with Podiums #}
<h6 class="card-subtitle mt-2" data-bs-toggle="tooltip"
title="Which driver will reach at least a single podium?">Drivers with podium(s):</h6>
<div class="grid mt-2 container" style="row-gap: 0;">
{% for team in model.all_teams(include_none=false) %}
{# HACK: Choosing 0 and 1 will chose the drivers with the lowest IDs (although there could be others). #}
{# This means the drivers chosen from at the start of the season will be visible. #}
{% set driver_a = model.drivers_by(team_name=team.name, include_inactive=True)[0] %}
{% set driver_b = model.drivers_by(team_name=team.name, include_inactive=True)[1] %}
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox"
name="podiumdrivers"
id="podium-{{ driver_a.id }}-{{ user.id }}"
value="{{ driver_a.id }}"
{% if (user_guess is not none) and (driver_a in user_guess.podiums) %}checked="checked"{% endif %}
{% if season_guess_open == false %}disabled="disabled"{% endif %}>
<label class="form-check-label {% if (user_guess is not none) and (driver_a in user_guess.podiums) and points.has_podium(driver_a) %}text-success
{% elif (user_guess is not none) and (driver_a in user_guess.podiums) and (not points.has_podium(driver_a)) %}text-danger
{% elif (user_guess is not none) and (driver_a not in user_guess.podiums) and (points.has_podium(driver_a)) %}text-danger{% endif %}"
for="podium-{{ driver_a.id }}-{{ user.id }}">{{ driver_a.name }}</label>
</div>
</div>
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox"
name="podiumdrivers"
id="podium-{{ driver_b.id }}-{{ user.id }}"
value="{{ driver_b.id }}"
{% if (user_guess is not none) and (driver_b in user_guess.podiums) %}checked="checked"{% endif %}
{% if season_guess_open == false %}disabled="disabled"{% endif %}>
<label class="form-check-label {% if (user_guess is not none) and (driver_b in user_guess.podiums) and points.has_podium(driver_b) %}text-success
{% elif (user_guess is not none) and (driver_b in user_guess.podiums) and (not points.has_podium(driver_b)) %}text-danger
{% elif (user_guess is not none) and (driver_b not in user_guess.podiums) and (points.has_podium(driver_b)) %}text-danger{% endif %}"
for="podium-{{ driver_b.id }}-{{ user.id }}">{{ driver_b.name }}</label>
</div>
</div>
{% endfor %}
</div>
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save"
{% if season_guess_open == false %}disabled="disabled"{% endif %}>
</form>
</div>
</div>
{% endfor %}
</div>
{% endblock body %}

View File

@ -0,0 +1,161 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Statistics{% endblock title %}
{% set active_page = "/stats" %}
{% block body %}
<div class="grid card-grid-2">
<div class="card shadow-sm mb-2">
<div class="card-header">
Drivers
</div>
<div class="card-body">
<div class="d-inline-block overflow-x-scroll w-100">
<table class="table table-bordered table-sm table-responsive">
<thead>
<tr>
<th scope="col" class="text-center" style="min-width: 50px;">Place</th>
<th scope="col" class="text-center" style="min-width: 50px;">Driver</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points</th>
<th scope="col" class="text-center" style="min-width: 100px;">DNFs</th>
<th scope="col" class="text-center" style="min-width: 100px;">Place Delta</th>
</tr>
</thead>
<tbody>
{% for driver in points.drivers_sorted_by_points(include_inactive=True) %}
{% set driver_standing = points.wdc_standing_by_driver()[driver.name] %}
<tr class="{% if driver_standing == 1 %}table-danger{% endif %}">
<td class="text-center text-nowrap">{{ driver_standing }}</td>
<td class="text-center text-nowrap">{{ driver.name }}</td>
<td class="text-center text-nowrap">{{ points.total_driver_points_by(driver.name) }}</td>
<td class="text-center text-nowrap">{{ points.dnfs()[driver.name] }}</td>
<td class="text-center text-nowrap">{{ "%+d" % points.wdc_diff_2023_by(driver.name) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
Constructors
</div>
<div class="card-body">
<div class="d-inline-block overflow-x-scroll w-100">
<table class="table table-bordered table-sm table-responsive">
<thead>
<tr>
<th scope="col" class="text-center" style="min-width: 50px;">Place</th>
<th scope="col" class="text-center" style="min-width: 50px;">Team</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points</th>
<th scope="col" class="text-center" style="min-width: 100px;">Place Delta</th>
</tr>
</thead>
<tbody>
{% for team in points.teams_sorted_by_points() %}
{% set team_standing = points.wcc_standing_by_team()[team.name] %}
<tr class="{% if team_standing == 1 %}table-danger{% endif %}">
<td class="text-center text-nowrap">{{ team_standing }}</td>
<td class="text-center text-nowrap">{{ team.name }}</td>
<td class="text-center text-nowrap">{{ points.total_team_points_by(team.name) }}</td>
<td class="text-center text-nowrap">{{ points.wcc_diff_2023_by(team.name) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
Driver history
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="driver-line-chart"></canvas>
</div>
<script>
function cumulative_driver_points(data) {
return new Chart(document.getElementById("driver-line-chart"), {
type: 'line',
data: data,
options: {
title: {
display: true,
text: 'History'
},
{#tension: 0,#}
responsive: true,
maintainAspectRatio: false,
pointRadius: 5,
pointHoverRadius: 10,
scales: {
y: {
min: 0,
{#max: 100#}
}
}
}
});
}
cumulative_driver_points({{ points.cumulative_driver_points_data() | safe }})
</script>
</div>
</div>
<div class="card shadow-sm mb-2">
<div class="card-header">
Team history
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="team-line-chart"></canvas>
</div>
<script>
function cumulative_team_points(data) {
return new Chart(document.getElementById("team-line-chart"), {
type: 'line',
data: data,
options: {
title: {
display: true,
text: 'History'
},
{#tension: 0,#}
responsive: true,
maintainAspectRatio: false,
pointRadius: 5,
pointHoverRadius: 10,
scales: {
y: {
min: 0,
{#max: 100#}
}
}
}
});
}
cumulative_team_points({{ points.cumulative_team_points_data() | safe }})
</script>
</div>
</div>
</div>
{% endblock body %}

View File

@ -3,14 +3,15 @@
{% block title %}Formula 10 - Users{% endblock title %}
{% set active_page = "/users" %}
{% set active_user = none %}
{% block body %}
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">Add User</h5>
<div class="card shadow-sm mb-2">
<div class="card-header">
Add user
</div>
<div class="card-body">
<form action="/user-add" method="post">
<div class="input-group">
<div class="form-floating">
@ -26,10 +27,12 @@
</div>
{% if model.all_users() | length > 0 %}
<div class="card mt-2 shadow-sm">
<div class="card-body">
<h5 class="card-title">Registered Users</h5>
<div class="card shadow-sm mb-2">
<div class="card-header">
Registered users
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for user in model.all_users() %}
<li class="list-group-item">{{ user.name }}</li>
@ -38,10 +41,12 @@
</div>
</div>
<div class="card mt-2 shadow-sm">
<div class="card-body">
<h5 class="card-title">Delete user</h5>
<div class="card shadow-sm mb-2">
<div class="card-header">
Delete user
</div>
<div class="card-body">
<form action="/user-delete" method="post">
<div class="input-group">
<select class="form-control form-select" aria-label="select-delete-user"
@ -58,8 +63,7 @@
<div class="form-text">
"Deleting" a user just hides it from the user interface without deleting any inputs, your
"pERsoNaL
DaTa" belongs to ME now.<br>
"pERsoNaL DaTa" is not yours anyway.<br>
Re-adding a user with the same name will "restore" it. That doesn't mean you're allowed to
remove everyone though.
</div>
@ -68,16 +72,4 @@
</div>
{% endif %}
<div class="card mt-2 border-danger shadow-sm">
<div class="card-body">
<h5 class="card-title">Functions that should not be public</h5>
<h6 class="card-subtitle mb-2">(F you if you click this without knowing what it does)</h6>
<a class="btn btn-outline-danger" href="/save/all">Save all data</a>
<a class="btn btn-outline-danger" href="/load/all">Load all data</a>
<a class="btn btn-outline-danger" href="/load/static">Load static data</a>
<a class="btn btn-outline-danger" href="/load/dynamic">Load dynamic data</a>
</div>
</div>
{% endblock body %}

490
model.py
View File

@ -1,490 +0,0 @@
import json
from datetime import datetime
from typing import Any, List, Dict
from urllib.parse import quote
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
db: SQLAlchemy = SQLAlchemy()
####################################
# Static Data (Defined in Backend) #
####################################
class Race(db.Model):
"""
A single race at a certain date and GrandPrix in the calendar.
It stores the place to guess for this race.
"""
__tablename__ = "race"
@staticmethod
def from_csv(row: List[str]):
race: Race = Race()
race.name = str(row[0])
race.number = int(row[1])
race.date = datetime.strptime(row[2], "%Y-%m-%d")
race.pxx = int(row[3])
return race
@property
def name_sanitized(self) -> str:
return quote(self.name)
name: Mapped[str] = mapped_column(String(64), primary_key=True)
number: Mapped[int] = mapped_column(Integer)
date: Mapped[datetime] = mapped_column(DateTime)
pxx: Mapped[int] = mapped_column(Integer) # This is the place to guess
class Team(db.Model):
"""
A constructor/team (name only).
"""
__tablename__ = "team"
@staticmethod
def from_csv(row: List[str]):
team: Team = Team()
team.name = str(row[0])
return team
name: Mapped[str] = mapped_column(String(32), primary_key=True)
class Driver(db.Model):
"""
A F1 driver.
It stores the corresponding team + name abbreviation.
"""
__tablename__ = "driver"
@staticmethod
def from_csv(row: List[str]):
driver: Driver = Driver()
driver.name = str(row[0])
driver.abbr = str(row[1])
driver.team_name = str(row[2])
driver.country_code = str(row[3])
return driver
name: Mapped[str] = mapped_column(String(32), primary_key=True)
abbr: Mapped[str] = mapped_column(String(4))
team_name: Mapped[str] = mapped_column(ForeignKey("team.name"))
country_code: Mapped[str] = mapped_column(String(2)) # alpha-2 code
# Relationships
team: Mapped["Team"] = relationship("Team", foreign_keys=[team_name])
######################################
# Dynamic Data (Defined in Frontend) #
######################################
class User(db.Model):
"""
A user that can guess races (name only).
"""
__tablename__ = "user"
__csv_header__ = ["name"]
def __init__(self, name: str):
self.name = name # Primary key
@staticmethod
def from_csv(row: List[str]):
user: User = User(str(row[0]))
return user
def to_csv(self) -> List[Any]:
return [
self.name
]
@property
def name_sanitized(self) -> str:
return quote(self.name)
name: Mapped[str] = mapped_column(String(32), primary_key=True)
class RaceResult(db.Model):
"""
The result of a past race.
It stores the corresponding race and dictionaries of place-/dnf-order and a list of drivers that are excluded from the standings for this race.
"""
__tablename__ = "raceresult"
__allow_unmapped__ = True # TODO: Used for json conversion, move this to some other class instead
__csv_header__ = ["race_name", "pxx_driver_names_json", "first_dnf_driver_names_json", "dnf_driver_names_json", "excluded_driver_names_json"]
def __init__(self, race_name: str):
self.race_name = race_name # Primary key
@staticmethod
def from_csv(row: List[str]):
race_result: RaceResult = RaceResult(str(row[0]))
race_result.pxx_driver_names_json = str(row[1])
race_result.first_dnf_driver_names_json = str(row[2])
race_result.dnf_driver_names_json = str(row[3])
race_result.excluded_driver_names_json = str(row[4])
return race_result
def to_csv(self) -> List[Any]:
return [
self.race_name,
self.pxx_driver_names_json,
self.first_dnf_driver_names_json,
self.dnf_driver_names_json,
self.excluded_driver_names_json
]
race_name: Mapped[str] = mapped_column(ForeignKey("race.name"), primary_key=True)
pxx_driver_names_json: Mapped[str] = mapped_column(String(1024), nullable=True)
first_dnf_driver_names_json: Mapped[str] = mapped_column(String(1024), nullable=True)
dnf_driver_names_json: Mapped[str] = mapped_column(String(1024), nullable=True)
excluded_driver_names_json: Mapped[str] = mapped_column(String(1024), nullable=True)
@property
def pxx_driver_names(self) -> Dict[str, str]:
return json.loads(self.pxx_driver_names_json)
@pxx_driver_names.setter
def pxx_driver_names(self, new_pxx_driver_names: Dict[str, str]):
self.pxx_driver_names_json = json.dumps(new_pxx_driver_names)
@property
def first_dnf_driver_names(self) -> List[str]:
return json.loads(self.first_dnf_driver_names_json)
@first_dnf_driver_names.setter
def first_dnf_driver_names(self, new_first_dnf_driver_names: List[str]):
self.first_dnf_driver_names_json = json.dumps(new_first_dnf_driver_names)
@property
def dnf_driver_names(self) -> List[str]:
return json.loads(self.dnf_driver_names_json)
@dnf_driver_names.setter
def dnf_driver_names(self, new_dnf_driver_names: List[str]):
self.dnf_driver_names_json = json.dumps(new_dnf_driver_names)
@property
def excluded_driver_names(self) -> List[str]:
return json.loads(self.excluded_driver_names_json)
@excluded_driver_names.setter
def excluded_driver_names(self, new_excluded_driver_names: List[str]):
self.excluded_driver_names_json = json.dumps(new_excluded_driver_names)
# Relationships
race: Mapped["Race"] = relationship("Race", foreign_keys=[race_name])
_pxx_drivers: Dict[str, Driver] | None = None
_first_dnf_drivers: List[Driver] | None = None
_dnf_drivers: List[Driver] | None = None
_excluded_drivers: List[Driver] | None = None
@property
def pxx_drivers(self) -> Dict[str, Driver]:
if self._pxx_drivers is None:
self._pxx_drivers = dict()
for position, driver_name in self.pxx_driver_names.items():
driver: Driver | None = db.session.query(Driver).filter_by(name=driver_name).first()
if driver is None:
raise Exception(f"Error: Couldn't find driver with id {driver_name}")
self._pxx_drivers[position] = driver
return self._pxx_drivers
def pxx_driver(self, offset: int = 0) -> Driver | None:
pxx_num: str = str(self.race.pxx + offset)
if pxx_num not in self.pxx_drivers:
raise Exception(f"Position {pxx_num} not found in RaceResult.pxx_drivers")
if self.pxx_drivers[pxx_num].name in self.excluded_driver_names:
none_driver: Driver | None = db.session.query(Driver).filter_by(name="None").first()
if none_driver is None:
raise Exception(f"NONE-driver not found in database")
return none_driver
return self.pxx_drivers[pxx_num]
def pxx_driver_position_string(self, driver_name: str) -> str:
for position, driver in self.pxx_driver_names.items():
if driver == driver_name and driver not in self.excluded_driver_names:
return f"P{position}"
return "NC"
@property
def all_positions(self) -> List[Driver]:
return [
self.pxx_drivers[str(position)] for position in range(1, 21)
]
@property
def first_dnf_drivers(self) -> List[Driver]:
if self._first_dnf_drivers is None:
self._first_dnf_drivers = list()
for driver_name in self.first_dnf_driver_names:
driver: Driver | None = db.session.query(Driver).filter_by(name=driver_name).first()
if driver is None:
raise Exception(f"Error: Couldn't find driver with id {driver_name}")
self._first_dnf_drivers.append(driver)
if len(self._first_dnf_drivers) == 0:
none_driver: Driver | None = db.session.query(Driver).filter_by(name="None").first()
if none_driver is None:
raise Exception("NONE-driver not found in database")
self._first_dnf_drivers.append(none_driver)
return self._first_dnf_drivers
@property
def dnf_drivers(self) -> List[Driver]:
if self._dnf_drivers is None:
self._dnf_drivers = list()
for driver_name in self.dnf_driver_names:
driver: Driver | None = db.session.query(Driver).filter_by(name=driver_name).first()
if driver is None:
raise Exception(f"Error: Couldn't find driver with id {driver_name}")
self._dnf_drivers.append(driver)
return self._dnf_drivers
@property
def excluded_drivers(self) -> List[Driver]:
if self._excluded_drivers is None:
self._excluded_drivers = list()
for driver_name in self.excluded_driver_names:
driver: Driver | None = db.session.query(Driver).filter_by(name=driver_name).first()
if driver is None:
raise Exception(f"Error: Couldn't find driver with id {driver_name}")
self._excluded_drivers.append(driver)
return self._excluded_drivers
class RaceGuess(db.Model):
"""
A guess a user made for a race.
It stores the corresponding race and the guessed drivers for PXX and DNF.
"""
__tablename__ = "raceguess"
__csv_header__ = ["user_name", "race_name", "pxx_driver_name", "dnf_driver_name"]
def __init__(self, user_name: str, race_name: str):
self.user_name = user_name # Primary key
self.race_name = race_name # Primery key
@staticmethod
def from_csv(row: List[str]):
race_guess: RaceGuess = RaceGuess(str(row[0]), str(row[1]))
race_guess.pxx_driver_name = str(row[2])
race_guess.dnf_driver_name = str(row[3])
return race_guess
def to_csv(self) -> List[Any]:
return [
self.user_name,
self.race_name,
self.pxx_driver_name,
self.dnf_driver_name
]
user_name: Mapped[str] = mapped_column(ForeignKey("user.name"), primary_key=True)
race_name: Mapped[str] = mapped_column(ForeignKey("race.name"), primary_key=True)
pxx_driver_name: Mapped[str] = mapped_column(ForeignKey("driver.name"), nullable=True)
dnf_driver_name: Mapped[str] = mapped_column(ForeignKey("driver.name"), nullable=True)
# Relationships
user: Mapped["User"] = relationship("User", foreign_keys=[user_name])
race: Mapped["Race"] = relationship("Race", foreign_keys=[race_name])
pxx: Mapped["Driver"] = relationship("Driver", foreign_keys=[pxx_driver_name])
dnf: Mapped["Driver"] = relationship("Driver", foreign_keys=[dnf_driver_name])
class TeamWinners(db.Model):
"""
A guessed list of each best driver per team.
"""
__tablename__ = "teamwinners"
__allow_unmapped__ = True
__csv_header__ = ["user_name", "teamwinner_driver_names_json"]
def __init__(self, user_name: str):
self.user_name = user_name # Primary key
@staticmethod
def from_csv(row: List[str]):
team_winners: TeamWinners = TeamWinners(str(row[0]))
team_winners.teamwinner_driver_names_json = str(row[1])
return team_winners
def to_csv(self) -> List[Any]:
return [
self.user_name,
self.teamwinner_driver_names_json
]
user_name: Mapped[str] = mapped_column(ForeignKey("user.name"), primary_key=True)
teamwinner_driver_names_json: Mapped[str] = mapped_column(String(1024), nullable=True)
@property
def teamwinner_driver_names(self) -> List[str]:
return json.loads(self.teamwinner_driver_names_json)
@teamwinner_driver_names.setter
def teamwinner_driver_names(self, new_teamwinner_driver_names: List[str]):
self.teamwinner_driver_names_json = json.dumps(new_teamwinner_driver_names)
# Relationships
user: Mapped["User"] = relationship("User", foreign_keys=[user_name])
_teamwinner_drivers: List[Driver] | None = None
@property
def teamwinners(self) -> List[Driver]:
if self._teamwinner_drivers is None:
self._teamwinner_drivers = list()
for driver_name in self.teamwinner_driver_names:
driver: Driver | None = db.session.query(Driver).filter_by(name=driver_name).first()
if driver is None:
raise Exception(f"Error: Couldn't find driver with id {driver_name}")
self._teamwinner_drivers.append(driver)
return self._teamwinner_drivers
class PodiumDrivers(db.Model):
"""
A guessed list of each driver that will reach at least a single podium.
"""
__tablename__ = "podiumdrivers"
__allow_unmapped__ = True
__csv_header__ = ["user_name", "podium_driver_names_json"]
def __init__(self, user_name: str):
self.user_name = user_name
@staticmethod
def from_csv(row: List[str]):
podium_drivers: PodiumDrivers = PodiumDrivers(str(row[0]))
podium_drivers.podium_driver_names_json = str(row[1])
return podium_drivers
def to_csv(self) -> List[Any]:
return [
self.user_name,
self.podium_driver_names_json
]
user_name: Mapped[str] = mapped_column(ForeignKey("user.name"), primary_key=True)
podium_driver_names_json: Mapped[str] = mapped_column(String(1024), nullable=True)
@property
def podium_driver_names(self) -> List[str]:
return json.loads(self.podium_driver_names_json)
@podium_driver_names.setter
def podium_driver_names(self, new_podium_driver_names: List[str]):
self.podium_driver_names_json = json.dumps(new_podium_driver_names)
# Relationships
user: Mapped["User"] = relationship("User", foreign_keys=[user_name])
_podium_drivers: List[Driver] | None = None
@property
def podium_drivers(self) -> List[Driver]:
if self._podium_drivers is None:
self._podium_drivers = list()
for driver_name in self.podium_driver_names:
driver: Driver | None = db.session.query(Driver).filter_by(name=driver_name).first()
if driver is None:
raise Exception(f"Error: Couldn't find driver with id {driver_name}")
self._podium_drivers.append(driver)
return self._podium_drivers
class SeasonGuess(db.Model):
"""
A collection of bonus guesses for the entire season.
"""
__tablename__ = "seasonguess"
__csv_header__ = ["user_name", "hot_take", "p2_team_name",
"overtake_driver_name", "dnf_driver_name", "gained_driver_name", "lost_driver_name",
"team_winners_id", "podium_drivers_id"]
def __init__(self, user_name: str, team_winners_user_name: str | None = None, podium_drivers_user_name: str | None = None):
self.user_name = user_name # Primary key
# Although this is the same username, handle separately, in case they don't exist in the database yet
if team_winners_user_name is not None:
if user_name != team_winners_user_name:
raise Exception(f"SeasonGuess for {user_name} was supplied TeamWinners for {team_winners_user_name}")
self.team_winners_id = team_winners_user_name
if podium_drivers_user_name is not None:
if user_name != podium_drivers_user_name:
raise Exception(f"SeasonGuess for {user_name} was supplied PodiumDrivers for {podium_drivers_user_name}")
self.podium_drivers_id = podium_drivers_user_name
@staticmethod
def from_csv(row: List[str]):
season_guess: SeasonGuess = SeasonGuess(str(row[0]), team_winners_user_name=str(row[7]), podium_drivers_user_name=str(row[8]))
season_guess.hot_take = str(row[1])
season_guess.p2_team_name = str(row[2])
season_guess.overtake_driver_name = str(row[3])
season_guess.dnf_driver_name = str(row[4])
season_guess.gained_driver_name = str(row[5])
season_guess.lost_driver_name = str(row[6])
return season_guess
def to_csv(self) -> List[Any]:
return [
self.user_name,
self.hot_take,
self.p2_team_name,
self.overtake_driver_name,
self.dnf_driver_name,
self.gained_driver_name,
self.lost_driver_name,
self.team_winners_id,
self.podium_drivers_id
]
user_name: Mapped[str] = mapped_column(ForeignKey("user.name"), primary_key=True)
hot_take: Mapped[str] = mapped_column(String(512), nullable=True)
p2_team_name: Mapped[str] = mapped_column(ForeignKey("team.name"), nullable=True)
overtake_driver_name: Mapped[str] = mapped_column(ForeignKey("driver.name"), nullable=True)
dnf_driver_name: Mapped[str] = mapped_column(ForeignKey("driver.name"), nullable=True)
gained_driver_name: Mapped[str] = mapped_column(ForeignKey("driver.name"), nullable=True)
lost_driver_name: Mapped[str] = mapped_column(ForeignKey("driver.name"), nullable=True)
team_winners_id: Mapped[str] = mapped_column(ForeignKey("teamwinners.user_name"))
podium_drivers_id: Mapped[str] = mapped_column(ForeignKey("podiumdrivers.user_name"))
# Relationships
user: Mapped["User"] = relationship("User", foreign_keys=[user_name])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_name])
overtake_driver: Mapped["Driver"] = relationship("Driver", foreign_keys=[overtake_driver_name])
dnf_driver: Mapped["Driver"] = relationship("Driver", foreign_keys=[dnf_driver_name])
gained_driver: Mapped["Driver"] = relationship("Driver", foreign_keys=[gained_driver_name])
lost_driver: Mapped["Driver"] = relationship("Driver", foreign_keys=[lost_driver_name])
team_winners: Mapped["TeamWinners"] = relationship("TeamWinners", foreign_keys=[team_winners_id])
podium_drivers: Mapped["PodiumDrivers"] = relationship("PodiumDrivers", foreign_keys=[podium_drivers_id])

View File

@ -3,4 +3,12 @@ numpy
flask
flask-sqlalchemy
flask-caching
sqlalchemy
requests
werkzeug
fastf1
pandas
pytest

View File

@ -1,22 +0,0 @@
name,abbr,team_name,country_code
None,None,None,NO
Alexander Albon,ALB,Williams,TH
Fernando Alonso,ALO,Aston Martin,ES
Valteri Bottas,BOT,Sauber,FL
Pierre Gasly,GAS,Alpine,FR
Lewis Hamilton,HAM,Mercedes,UK
Nico Hulkenberg,HUL,Haas,DE
Charles Leclerc,LEC,Ferrari,MC
Kevin Magnussen,MAG,Haas,DK
Lando Norris,NOR,McLaren,UK
Esteban Ocon,OCO,Alpine,FR
Sergio Perez,PER,Red Bull,MX
Oscar Piastri,PIA,McLaren,AU
Daniel Ricciardo,RIC,VCARB,AU
George Russel,RUS,Mercedes,UK
Carlos Sainz,SAI,Ferrari,ES
Logan Sargeant,SAR,Williams,US
Lance Stroll,STR,Aston Martin,CA
Yuki Tsunoda,TSU,VCARB,JP
Max Verstappen,VER,Red Bull,NL
Zhou Guanyu,ZHO,Sauber,CN
1 name abbr team_name country_code
2 None None None NO
3 Alexander Albon ALB Williams TH
4 Fernando Alonso ALO Aston Martin ES
5 Valteri Bottas BOT Sauber FL
6 Pierre Gasly GAS Alpine FR
7 Lewis Hamilton HAM Mercedes UK
8 Nico Hulkenberg HUL Haas DE
9 Charles Leclerc LEC Ferrari MC
10 Kevin Magnussen MAG Haas DK
11 Lando Norris NOR McLaren UK
12 Esteban Ocon OCO Alpine FR
13 Sergio Perez PER Red Bull MX
14 Oscar Piastri PIA McLaren AU
15 Daniel Ricciardo RIC VCARB AU
16 George Russel RUS Mercedes UK
17 Carlos Sainz SAI Ferrari ES
18 Logan Sargeant SAR Williams US
19 Lance Stroll STR Aston Martin CA
20 Yuki Tsunoda TSU VCARB JP
21 Max Verstappen VER Red Bull NL
22 Zhou Guanyu ZHO Sauber CN

View File

@ -1,24 +0,0 @@
name,number,date,pxx
Bahrain,1,2023-03-05,4
Saudi Arabia,2,2023-03-19,17
Melbourne,3,2023-04-02,5
Baku,4,2023-04-30,6
Miami,5,2023-05-07,15
Imola,6,2023-05-21,8
Monaco,7,2023-05-28,9
Barcelona,8,2023-06-04,13
Montreal,9,2023-06-18,11
Spielberg,10,2023-07-02,12
Silverstone,11,2023-07-09,17
Budapest,12,2023-07-23,12
Spa,13,2023-07-30,13
Zandvoort,14,2023-08-27,4
Monza,15,2023-09-03,6
Singapore,16,2023-09-17,10
Suzuka,17,2023-09-24,11
Qatar,18,2023-10-08,4
Austin,19,2023-10-22,11
Mexico,20,2023-10-29,17
Brazil,21,2023-11-05,14
Las Vegas,22,2023-11-18,8
Abu Dhabi,23,2023-11-26,5
1 name number date pxx
2 Bahrain 1 2023-03-05 4
3 Saudi Arabia 2 2023-03-19 17
4 Melbourne 3 2023-04-02 5
5 Baku 4 2023-04-30 6
6 Miami 5 2023-05-07 15
7 Imola 6 2023-05-21 8
8 Monaco 7 2023-05-28 9
9 Barcelona 8 2023-06-04 13
10 Montreal 9 2023-06-18 11
11 Spielberg 10 2023-07-02 12
12 Silverstone 11 2023-07-09 17
13 Budapest 12 2023-07-23 12
14 Spa 13 2023-07-30 13
15 Zandvoort 14 2023-08-27 4
16 Monza 15 2023-09-03 6
17 Singapore 16 2023-09-17 10
18 Suzuka 17 2023-09-24 11
19 Qatar 18 2023-10-08 4
20 Austin 19 2023-10-22 11
21 Mexico 20 2023-10-29 17
22 Brazil 21 2023-11-05 14
23 Las Vegas 22 2023-11-18 8
24 Abu Dhabi 23 2023-11-26 5

View File

@ -1,11 +0,0 @@
name
Alpine
Aston Martin
Ferrari
Haas
McLaren
Mercedes
Red Bull
Sauber
VCARB
Williams
1 name
2 Alpine
3 Aston Martin
4 Ferrari
5 Haas
6 McLaren
7 Mercedes
8 Red Bull
9 Sauber
10 VCARB
11 Williams

View File

@ -1,252 +0,0 @@
from typing import List, Callable, Dict, overload
from sqlalchemy import desc
from model import User, RaceResult, RaceGuess, Race, Driver, Team, SeasonGuess, db
from validation_utils import find_first_or_none, find_multiple, find_single, find_single_or_none
# This could also be moved to database_utils (at least partially), but I though the template should cache the database responses
class TemplateModel:
"""
This class bundles all data required from inside a template.
"""
_all_users: List[User] | None = None
_all_race_results: List[RaceResult] | None = None
_all_race_guesses: List[RaceGuess] | None = None
_all_season_guesses: List[SeasonGuess] | None = None
_all_races: List[Race] | None = None
_all_drivers: List[Driver] | None = None
_all_teams: List[Team] | None = None
def all_users(self) -> List[User]:
"""
Returns a list of all users in the database.
"""
if self._all_users is None:
self._all_users = db.session.query(User).all()
return self._all_users
@overload
def user_by(self, *, user_name: str) -> User:
"""
Tries to obtain the user object for a specific username.
"""
return self.user_by(user_name=user_name)
@overload
def user_by(self, *, user_name: str, ignore: List[str]) -> User | None:
"""
Tries to obtain the user object for a specific username, but ignores certain usernames.
"""
return self.user_by(user_name=user_name, ignore=ignore)
def user_by(self, *, user_name: str, ignore: List[str] | None = None) -> User | None:
if ignore is None:
ignore = []
if len(ignore) > 0 and user_name in ignore:
return None
predicate: Callable[[User], bool] = lambda user: user.name == user_name
return find_single(predicate, self.all_users())
def all_race_results(self) -> List[RaceResult]:
"""
Returns a list of all race results in the database, in descending order (most recent first).
"""
if self._all_race_results is None:
self._all_race_results = db.session.query(RaceResult).join(RaceResult.race).order_by(desc(Race.number)).all()
return self._all_race_results
def race_result_by(self, *, race_name: str) -> RaceResult | None:
"""
Tries to obtain the race result corresponding to a race name.
"""
predicate: Callable[[RaceResult], bool] = lambda result: result.race.name == race_name
return find_single_or_none(predicate, self.all_race_results())
def all_race_guesses(self) -> List[RaceGuess]:
"""
Returns a list of all race guesses in the database.
"""
if self._all_race_guesses is None:
self._all_race_guesses = db.session.query(RaceGuess).all()
return self._all_race_guesses
@overload
def race_guesses_by(self, *, user_name: str) -> List[RaceGuess]:
"""
Returns a list of all race guesses made by a specific user.
"""
return self.race_guesses_by(user_name=user_name)
@overload
def race_guesses_by(self, *, race_name: str) -> List[RaceGuess]:
"""
Returns a list of all race guesses made for a specific race.
"""
return self.race_guesses_by(race_name=race_name)
@overload
def race_guesses_by(self, *, user_name: str, race_name: str) -> RaceGuess | None:
"""
Returns a single race guess by a specific user for a specific race, or None, if this guess doesn't exist.
"""
return self.race_guesses_by(user_name=user_name, race_name=race_name)
@overload
def race_guesses_by(self) -> Dict[str, Dict[str, RaceGuess]]:
"""
Returns a dictionary that maps race-ids to user-id - guess dictionaries.
"""
return self.race_guesses_by()
def race_guesses_by(self, *, user_name: str | None = None, race_name: str | None = None) -> RaceGuess | List[RaceGuess] | Dict[str, Dict[str, RaceGuess]] | None:
# List of all guesses by a single user
if user_name is not None and race_name is None:
predicate: Callable[[RaceGuess], bool] = lambda guess: guess.user_name == user_name
return find_multiple(predicate, self.all_race_guesses())
# List of all guesses for a single race
if user_name is None and race_name is not None:
predicate: Callable[[RaceGuess], bool] = lambda guess: guess.race_name == race_name
return find_multiple(predicate, self.all_race_guesses())
# Guess for a single race by a single user
if user_name is not None and race_name is not None:
predicate: Callable[[RaceGuess], bool] = lambda guess: guess.user_name == user_name and guess.race_name == race_name
return find_single_or_none(predicate, self.all_race_guesses())
# Dict with all guesses
if user_name is None and race_name is None:
guesses_by: Dict[str, Dict[str, RaceGuess]] = dict()
guess: RaceGuess
for guess in self.all_race_guesses():
if guess.race_name not in guesses_by:
guesses_by[guess.race_name] = dict()
guesses_by[guess.race_name][guess.user_name] = guess
return guesses_by
raise Exception("race_guesses_by encountered illegal combination of arguments")
def all_season_guesses(self) -> List[SeasonGuess]:
if self._all_season_guesses is None:
self._all_season_guesses = db.session.query(SeasonGuess).all()
return self._all_season_guesses
@overload
def season_guesses_by(self, *, user_name: str) -> SeasonGuess:
"""
Returns the season guess made by a specific user.
"""
return self.season_guesses_by(user_name=user_name)
@overload
def season_guesses_by(self) -> Dict[str, SeasonGuess]:
"""
Returns a dictionary of season guesses mapped to usernames.
"""
return self.season_guesses_by()
def season_guesses_by(self, *, user_name: str | None = None) -> SeasonGuess | Dict[str, SeasonGuess] | None:
if user_name is not None:
predicate: Callable[[SeasonGuess], bool] = lambda guess: guess.user_name == user_name
return find_single_or_none(predicate, self.all_season_guesses())
if user_name is None:
guesses_by: Dict[str, SeasonGuess] = dict()
guess: SeasonGuess
for guess in self.all_season_guesses():
guesses_by[guess.user_name] = guess
return guesses_by
raise Exception("season_guesses_by encountered illegal combination of arguments")
def all_races(self) -> List[Race]:
"""
Returns a list of all races in the database.
"""
if self._all_races is None:
self._all_races = db.session.query(Race).order_by(desc(Race.number)).all()
return self._all_races
def first_race_without_result(self) -> Race | None:
"""
Returns the first race-object with no associated race result.
"""
results: List[RaceResult] = self.all_race_results()
if len(results) == 0:
return self.all_races()[-1] # all_races is sorted descending by number
most_recent_result: RaceResult = results[0]
predicate: Callable[[Race], bool] = lambda race: race.number == most_recent_result.race.number + 1
return find_first_or_none(predicate, self.all_races())
def all_teams(self) -> List[Team]:
"""
Returns a list of all teams in the database.
"""
if self._all_teams is None:
self._all_teams = db.session.query(Team).all()
return self._all_teams
def all_drivers(self) -> List[Driver]:
"""
Returns a list of all drivers in the database, including the NONE driver.
"""
if self._all_drivers is None:
self._all_drivers = db.session.query(Driver).all()
return self._all_drivers
def all_drivers_except_none(self) -> List[Driver]:
"""
Returns a list of all drivers in the database, excluding the NONE driver.
"""
predicate: Callable[[Driver], bool] = lambda driver: driver.name != "None"
return find_multiple(predicate, self.all_drivers())
@overload
def drivers_by(self, *, team_name: str) -> List[Driver]:
"""
Returns a list of all drivers driving for a certain team.
"""
return self.drivers_by(team_name=team_name)
@overload
def drivers_by(self) -> Dict[str, List[Driver]]:
"""
Returns a dictionary of drivers mapped to team names.
"""
return self.drivers_by()
def drivers_by(self, *, team_name: str | None = None) -> List[Driver] | Dict[str, List[Driver]]:
if team_name is not None:
predicate: Callable[[Driver], bool] = lambda driver: driver.team.name == team_name
return find_multiple(predicate, self.all_drivers_except_none(), 2)
if team_name is None:
drivers_by: Dict[str, List[Driver]] = dict()
driver: Driver
team: Team
for team in self.all_teams():
drivers_by[team.name] = []
for driver in self.all_drivers_except_none():
drivers_by[driver.team.name] += [driver]
return drivers_by
raise Exception("drivers_by encountered illegal combination of arguments")

View File

@ -1,196 +0,0 @@
<!DOCTYPE html>
<html lang="en">
{# Simple driver dropdown. Requires list of drivers. #}
{% macro driver_select(name='', label='', include_none=true) %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select" aria-label="{{ name }}">
<option value="" selected disabled hidden></option>
{% if include_none == true %}
{% set drivers = model.all_drivers() %}
{% else %}
{% set drivers = model.all_drivers_except_none() %}
{% endif %}
{% for driver in drivers %}
<option value="{{ driver.name }}">{{ driver.abbr }}</option>
{% endfor %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Driver dropdown where a value might be preselected. Requires list of drivers. #}
{% macro driver_select_with_preselect(match='', name='', label='', include_none=true) %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select" aria-label="{{ name }}">
{# Use namespace wrapper to persist scope between loop iterations #}
{% set user_has_chosen = namespace(driverpre="false") %}
{% if include_none == true %}
{% set drivers = model.all_drivers() %}
{% else %}
{% set drivers = model.all_drivers_except_none() %}
{% endif %}
{% for driver in drivers %}
{% if match == driver.abbr %}
{% set user_has_chosen.driverpre = "true" %}
<option selected="selected" value="{{ driver.name }}">{{ driver.abbr }}</option>
{% else %}
<option value="{{ driver.name }}">{{ driver.abbr }}</option>
{% endif %}
{% if (include_none == true) and (driver.abbr == "None") %}
<option disabled>──────────</option>
{% endif %}
{% endfor %}
{# Add an empty default if nothing has been chosen #}
{% if user_has_chosen.driverpre == "false" %}
<option value="" selected="selected" disabled="disabled" hidden="hidden"></option>
{% endif %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Simple team dropdown. Requires list of teams. #}
{% macro team_select(name='', label='') %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select" aria-label="{{ name }}">
<option value="" selected disabled hidden></option>
{% for team in model.all_teams() %}
<option value="{{ team.name }}">{{ team.name }}</option>
{% endfor %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Team dropdown where a value might be preselected. Requires list of teams. #}
{% macro team_select_with_preselect(match='', name='', label='') %}
<div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select" aria-label="{{ name }}">
{# Use namespace wrapper to persist scope between loop iterations #}
{% set user_has_chosen = namespace(teampre="false") %}
{% for team in model.all_teams() %}
{% if match == team.name %}
{% set user_has_chosen.teampre = "true" %}
<option selected="selected" value="{{ team.name }}">{{ team.name }}</option>
{% else %}
<option value="{{ team.name }}">{{ team.name }}</option>
{% endif %}
{% endfor %}
{# Add an empty default if nothing has been chosen #}
{% if user_has_chosen.teampre == "false" %}
<option value="" selected="selected" disabled="disabled" hidden="hidden"></option>
{% endif %}
</select>
<label for="{{ name }}" class="text-primary">{{ label }}</label>
</div>
{% endmacro %}
{# Easy nav-bar entries. When a page sets the active_page variable, the current entry will be underlined #}
{% macro nav_selector(page='', text='') %}
<a class="nav-link text-nowrap" href="{{ page }}">{% if active_page == page %}<u>{% endif %} {{ text }}
{# NOTE: This should be set at the top of each template #}
{% if active_page == page %}</u>{% endif %}</a>
{% endmacro %}
{#@formatter:off#}
{% macro pxx_guess_colorization(driver_abbr='', result=none) -%}
{% if (driver_abbr == result.pxx_driver(-3).abbr) and (driver_abbr != "None") %}fw-bold
{% elif (driver_abbr == result.pxx_driver(-2).abbr) and (driver_abbr != "None") %}text-danger fw-bold
{% elif (driver_abbr == result.pxx_driver(-1).abbr) and (driver_abbr != "None") %}text-warning fw-bold
{% elif (driver_abbr == result.pxx_driver(0).abbr) %}text-success fw-bold
{% elif (driver_abbr == result.pxx_driver(1).abbr) and (driver_abbr != "None") %}text-warning fw-bold
{% elif (driver_abbr == result.pxx_driver(2).abbr) and (driver_abbr != "None") %}text-danger fw-bold
{% elif (driver_abbr == result.pxx_driver(3).abbr) and (driver_abbr != "None") %}fw-bold{% endif %}
{% endmacro %}
{% macro pxx_points_tooltip_text(driver_abbr='', result=none) -%}
{% if (driver_abbr == result.pxx_driver(-3).abbr) and (driver_abbr != "None") %}1 Point
{% elif (driver_abbr == result.pxx_driver(-2).abbr) and (driver_abbr != "None") %}3 Points
{% elif (driver_abbr == result.pxx_driver(-1).abbr) and (driver_abbr != "None") %}6 Points
{% elif (driver_abbr == result.pxx_driver(0).abbr) %}10 Points
{% elif (driver_abbr == result.pxx_driver(1).abbr) and (driver_abbr != "None") %}6 Points
{% elif (driver_abbr == result.pxx_driver(2).abbr) and (driver_abbr != "None") %}3 Points
{% elif (driver_abbr == result.pxx_driver(3).abbr) and (driver_abbr != "None") %}1 Point
{% else %}0 Points{% endif %}
{%- endmacro %}
{% macro pxx_standing_tooltip_text(result=none) -%}
P{{ result.race.pxx - 3 }}: {{ result.pxx_driver(-3).abbr }}
P{{ result.race.pxx - 2 }}: {{ result.pxx_driver(-2).abbr }}
P{{ result.race.pxx - 1 }}: {{ result.pxx_driver(-1).abbr }}
P{{ result.race.pxx }}: {{ result.pxx_driver(0).abbr }}
P{{ result.race.pxx + 1 }}: {{ result.pxx_driver(1).abbr }}
P{{ result.race.pxx + 2 }}: {{ result.pxx_driver(2).abbr }}
P{{ result.race.pxx + 3 }}: {{ result.pxx_driver(3).abbr }}
{% endmacro %}
{#@formatter:on#}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Title -->
<title>{% block title %}{% endblock title %}</title>
<link rel="icon" href="../static/image/favicon.svg" sizes="any" type="image/svg+xml">
<!-- Bootstrap -->
<link href="../static/style/bootstrap.css" rel="stylesheet">
<script src="../static/script/bootstrap.bundle.js"></script>
<script defer>
{# Initialize Bootstrap Tooltips #}
let tooltipTriggerList = document.querySelectorAll("[data-bs-toggle='tooltip']")
let tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
</script>
{% block head_extra %}{% endblock head_extra %}
</head>
<body>
<nav class="navbar fixed-top navbar-expand-lg bg-body-tertiary shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="/race">
<img src="../static/image/f1_logo.svg" alt="Logo" width="120" height="30"
class="d-inline-block align-text-top">
Formula 10
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<div class="navbar-nav me-2">
{{ nav_selector("/race/" ~ (active_user.name_sanitized if active_user is not none else "Everyone"), "Race Picks") }}
{{ nav_selector("/season/" ~ (active_user.name_sanitized if active_user is not none else "Everyone"), "Season Picks") }}
{{ nav_selector("/graphs", "Statistics") }}
{{ nav_selector("/rules", "Rules") }}
</div>
{% block navbar_center %}{% endblock navbar_center %}
<div class="flex-grow-1"></div>
<div class="navbar-nav">
{{ nav_selector("/result", "Enter Race Result") }}
{{ nav_selector("/user", "Manage Users") }}
</div>
</div>
</div>
</nav>
<div class="px-2 pt-2" style="margin-top: 55px !important;">
{% block body %}{% endblock body %}
</div>
</body>
</html>

View File

@ -1,126 +0,0 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Race Result{% endblock title %}
{% set active_page = "/result" %}
{% set active_user = none %}
{% block head_extra %}
<link href="../static/style/draggable.css" rel="stylesheet">
<script src="../static/script/draggable.js" defer></script>
{% endblock head_extra %}
{% set current_race = model.first_race_without_result() %}
{% block navbar_center %}
{% if model.all_race_results() | length > 0 %}
<div class="dropdown">
<button class="btn btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
{% if active_result is not none %}
{{ active_result.race.name }}
{% elif current_race is not none %}
{{ current_race.name }}
{% else %}
{{ model.all_race_results()[0].race.name }}
{% endif %}
</button>
<ul class="dropdown-menu">
{% if model.first_race_without_result() is not none %}
<li>
<a class="dropdown-item" href="/result/{{ current_race.name }}">{{ current_race.name }}</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
{% endif %}
{% for result in model.all_race_results() %}
<li>
<a class="dropdown-item"
href="/result/{{ result.race.name }}">{{ result.race.name }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock navbar_center %}
{% block body %}
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));">
<div class="card shadow-sm" style="width: 450px;">
<div class="card-body">
<h5 class="card-title">
{% if active_result is not none %}
{{ active_result.race.name }}
{% elif current_race is not none %}
{{ current_race.name }}
{% else %}
{{ model.all_race_results()[0].race.name }}
{% endif %}
</h5>
{# @formatter:off #}
<form action="/result-enter/{%- if active_result is not none %}{{ active_result.race.name }}{% else %}{{ current_race.name }}{% endif %}"
method="post">
{# @formatter:on #}
<ul id="columns" class="list-group list-group-flush">
{% if active_result is not none %}
{% set drivers = active_result.all_positions %}
{% else %}
{% set drivers = model.all_drivers_except_none() %}
{% endif %}
{% for driver in drivers %}
<li class="list-group-item column p-1" draggable="true">
{{ driver.name }}
<div class="d-inline-block float-end">
{# Driver DNFed at first #}
<div class="form-check form-check-reverse d-inline-block">
<input type="checkbox" class="form-check-input" value="{{ driver.name }}"
id="first-dnf-{{ driver.name }}" name="first-dnf-drivers"
{% if (active_result is not none) and (driver in active_result.first_dnf_drivers) %}checked{% endif %}>
<label for="first-dnf-{{ driver.name }}"
class="form-check-label text-muted">1. DNF</label>
</div>
{# Driver DNFed #}
<div class="form-check form-check-reverse d-inline-block mx-2">
<input type="checkbox" class="form-check-input" value="{{ driver.name }}"
id="dnf-{{ driver.name }}" name="dnf-drivers"
{% if (active_result is not none) and (driver in active_result.dnf_drivers) %}checked{% endif %}>
<label for="dnf-{{ driver.name }}"
class="form-check-label text-muted">DNF</label>
</div>
{# Driver Excluded #}
<div class="form-check form-check-reverse d-inline-block">
<input type="checkbox" class="form-check-input" value="{{ driver.name }}"
id="exclude-{{ driver.name }}" name="excluded-drivers"
{% if (active_result is not none) and (driver in active_result.excluded_drivers) %}checked{% endif %}>
<label for="exclude-{{ driver.name }}"
class="form-check-label text-muted" data-bs-toggle="tooltip"
title="Driver is not counted for standing">NC</label>
</div>
</div>
{# Standing order #}
<input type="hidden" name="pxx-drivers" value="{{ driver.name }}"></li>
{% endfor %}
</ul>
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save">
</form>
</div>
</div>
</div>
{% endblock body %}

View File

@ -1,185 +0,0 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Race{% endblock title %}
{% set active_page = "/race/" ~ (active_user.name_sanitized if active_user is not none else "Everyone") %}
{% block navbar_center %}
{% if model.all_users() | length > 1 %}
<div class="dropdown">
<button class="btn btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
{% if active_user is none %}
Everyone
{% else %}
{{ active_user.name }}
{% endif %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/race/Everyone">Everyone</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for user in model.all_users() %}
<li><a class="dropdown-item" href="/race/{{ user.name_sanitized }}">{{ user.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock navbar_center %}
{% block body %}
<table class="table table-bordered table-sm table-responsive shadow-sm">
<thead>
<tr>
<th scope="col" rowspan="2" class="text-center" style="width: 200px;">Race</th>
<th scope="col" {% if active_user is none %}colspan="{{ model.all_users() | length }}"{% endif %}
class="text-center">Call
</th>
<th scope="col" rowspan="2" class="text-center" style="width: 200px;">Result</th>
</tr>
</thead>
<tbody>
{# Users List #}
<tr>
<td>&nbsp;</td>
{% if active_user is none %}
{% for user in model.all_users() %}
<td class="text-center text-nowrap" style="min-width: 100px;">
<a href="/race/{{ user.name_sanitized }}" class="link-dark">{{ user.name }}</a>
</td>
{% endfor %}
{% else %}
<td class="text-center text-nowrap" style="min-width: 100px;">{{ active_user.name }}</td>
{% endif %}
<td>&nbsp;</td>
</tr>
{% set current_race = model.first_race_without_result() %}
{# Current Result, only displayed for all users overview and if guess is remaining #}
{% if (active_user is none) and (model.first_race_without_result() is not none) %}
<tr class="table-danger">
<td class="text-nowrap">
<span class="fw-bold">{{ current_race.number }}:</span> {{ current_race.name }}<br>
<small><span class="fw-bold">Guess:</span> P{{ current_race.pxx }}</small>
</td>
{% for user in model.all_users() %}
{% set user_guess = model.race_guesses_by(user_name=user.name, race_name=current_race.name) %}
<td class="text-center text-nowrap">
{% if user_guess is not none %}
<ul class="list-group list-group-flush">
<li class="list-group-item" style="background-color: inherit;">
{{ user_guess.pxx.abbr }}
</li>
<li class="list-group-item" style="background-color: inherit;">
{{ user_guess.dnf.abbr }}
</li>
</ul>
{% else %}
&nbsp;
{% endif %}
</td>
{% endfor %}
<td>&nbsp;</td>
</tr>
{% endif %}
{# Enter Guess, only displayed for single user focused view and if guess is remaining #}
{% if (active_user is not none) and (model.first_race_without_result() is not none) %}
<tr class="table-danger">
<td class="text-nowrap">
<span class="fw-bold">{{ current_race.number }}:</span> {{ current_race.name }}<br>
<small><span class="fw-bold">Guess:</span> P{{ current_race.pxx }}</small>
</td>
<td>
<form action="/race-guess/{{ current_race.name_sanitized }}/{{ active_user.name_sanitized }}" method="post">
{% set user_guess = model.race_guesses_by(user_name=active_user.name, race_name=current_race.name) %}
{# Driver PXX Select #}
{{ driver_select_with_preselect(user_guess.pxx.abbr if user_guess is not none else "", "pxxselect", "P" ~ current_race.pxx ~ ":") }}
<div class="mt-2"></div>
{# Driver DNF Select #}
{{ driver_select_with_preselect(user_guess.dnf.abbr if user_guess is not none else "", "dnfselect", "DNF:") }}
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save">
</form>
<form action="/race-guess-delete/{{ current_race.name_sanitized }}/{{ active_user.name_sanitized }}" method="post">
<input type="submit" class="btn btn-dark mt-2 w-100" value="Delete">
</form>
</td>
<td>&nbsp;</td>
</tr>
{% endif %}
{# Past Race Results #}
{% for past_result in model.all_race_results() %}
<tr>
<td class="text-nowrap">
<span class="fw-bold">{{ past_result.race.number }}:</span> {{ past_result.race.name }}<br>
<small><span class="fw-bold">Guessed:</span> P{{ past_result.race.pxx }}</small>
</td>
{% if active_user is none %}
{% set users = model.all_users() %}
{% else %}
{% set users = [active_user] %}
{% endif %}
{% for user in users %}
<td class="text-center text-nowrap">
{% set user_guess = model.race_guesses_by(user_name=user.name, race_name=past_result.race.name) %}
{% if user_guess is not none %}
<ul class="list-group list-group-flush">
<li class="list-group-item {{ pxx_guess_colorization(user_guess.pxx.abbr, past_result) }}">
<span data-bs-toggle="tooltip" title="{{ pxx_points_tooltip_text(user_guess.pxx.abbr, past_result) }}">
{{ user_guess.pxx.abbr }}{% if user_guess.pxx.abbr != "None" %} ({{ past_result.pxx_driver_position_string(user_guess.pxx.name) }}){% endif %}
</span>
</li>
<li class="list-group-item {% if user_guess.dnf.name in past_result.first_dnf_driver_names %}text-success fw-bold{% endif %}">
<span data-bs-toggle="tooltip" title="{% if user_guess.dnf.name in past_result.first_dnf_driver_names %}10 Points{% else %}0 Points{% endif %}">
{{ user_guess.dnf.abbr }}
</span>
</li>
</ul>
{% else %}
&nbsp;
{% endif %}
</td>
{% endfor %}
<td class="text-center text-nowrap">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<span data-bs-toggle="tooltip" title="{{ pxx_standing_tooltip_text(past_result) }}">
P{{ past_result.race.pxx }}: {{ past_result.pxx_driver().abbr }}
</span>
</li>
<li class="list-group-item">
DNF: {% for dnf_driver in past_result.first_dnf_drivers %}{{ dnf_driver.abbr }} {% endfor %}</li>
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock body %}

View File

@ -1,167 +0,0 @@
{% extends 'base.jinja' %}
{% block title %}Formula 10 - Season{% endblock title %}
{% set active_page = "/season/" ~ (active_user.name_sanitized if active_user is not none else "Everyone") %}
{% block navbar_center %}
{% if model.all_users() | length > 1 %}
<div class="dropdown">
<button class="btn btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
{% if active_user is none %}
Everyone
{% else %}
{{ active_user.name }}
{% endif %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/season/Everyone">Everyone</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for user in model.all_users() %}
<li><a class="dropdown-item" href="/season/{{ user.name }}">{{ user.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock navbar_center %}
{% block body %}
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));">
{% if active_user is none %}
{% set users = model.all_users() %}
{% else %}
{% set users = [active_user] %}
{% endif %}
{% for user in users %}
<div class="card mb-2 shadow-sm" style="width: 450px;">
<div class="card-body">
{% if active_user is none %}
<a href="/season/{{ user.name }}" class="link-dark">
<h5 class="card-title">{{ user.name }}</h5>
</a>
{% else %}
<h5 class="card-title">{{ user.name }}</h5>
{% endif %}
{% set user_guess = model.season_guesses_by(user_name=user.name) %}
<form action="/season-guess/{{ user.name }}" method="post">
{# Hot Take #}
<div class="form-floating">
{% if user_guess is not none %}
<textarea class="form-control" id="hot-take-input-{{ user.name }}" name="hottakeselect"
style="height: 50px">{{ user_guess.hot_take }}</textarea>
{% else %}
<textarea class="form-control" id="hot-take-input-{{ user.name }}" name="hottakeselect"
style="height: 50px"></textarea>
{% endif %}
<label for="hot-take-input-{{ user.name }}" class="text-primary">Hot Take:</label>
</div>
{# P2 Constructor #}
<div class="mt-2">
{{ team_select_with_preselect(user_guess.p2_team.name if user_guess is not none else "",
"p2select", "P2 in WCC:") }}
</div>
{# Most Overtakes + DNFs #}
<div class="input-group mt-2">
{{ driver_select_with_preselect(user_guess.overtake_driver.abbr if user_guess is not none else "",
"overtakeselect", "Most overtakes:", false) }}
{{ driver_select_with_preselect(user_guess.dnf_driver.abbr if user_guess is not none else "",
"dnfselect", "Most DNFs:", false) }}
</div>
{# Most Gained + Lost #}
<div class="input-group mt-2" data-bs-toggle="tooltip" title="Which driver will gain/lose the most places in comparison to last season's results?">
{{ driver_select_with_preselect(user_guess.gained_driver.abbr if user_guess is not none else "",
"gainedselect", "Most WDC places gained:", false) }}
{{ driver_select_with_preselect(user_guess.lost_driver.abbr if user_guess is not none else "",
"lostselect", "Most WDC places lost:", false) }}
</div>
{# Team-internal Winners #}
<h6 class="card-subtitle mt-2" data-bs-toggle="tooltip" title="Which driver will finish the season higher than his teammate?">Teammate battle winners:</h6>
<div class="grid mt-2" style="width: 450px; row-gap: 0;">
{% for team in model.all_teams() %}
{% set driver_a_name = model.drivers_by(team_name=team.name)[0].name %}
{% set driver_b_name = model.drivers_by(team_name=team.name)[1].name %}
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio"
name="teamwinner-{{ team.name }}"
id="teamwinner-{{ team.name }}-1-{{ user.name }}"
value="{{ driver_a_name }}"
{% if (user_guess is not none) and (driver_a_name in user_guess.team_winners.teamwinner_driver_names) %}checked="checked"{% endif %}>
<label class="form-check-label"
for="teamwinner-{{ team.name }}-1-{{ user.name }}">{{ driver_a_name }}</label>
</div>
</div>
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio"
name="teamwinner-{{ team.name }}"
id="teamwinner-{{ team.name }}-2-{{ user.name }}"
value="{{ driver_b_name }}"
{% if (user_guess is not none) and (driver_b_name in user_guess.team_winners.teamwinner_driver_names) %}checked="checked"{% endif %}>
<label class="form-check-label"
for="teamwinner-{{ team.name }}-2-{{ user.name }}">{{ driver_b_name }}</label>
</div>
</div>
{% endfor %}
</div>
{# Drivers with Podiums #}
<h6 class="card-subtitle mt-2" data-bs-toggle="tooltip" title="Which driver will reach at least a single podium?">Drivers with podium(s):</h6>
<div class="grid mt-2" style="width: 450px; row-gap: 0;">
{% for team in model.all_teams() %}
{% set driver_a_name = model.drivers_by(team_name=team.name)[0].name %}
{% set driver_b_name = model.drivers_by(team_name=team.name)[1].name %}
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox"
name="podiumdrivers"
id="podium-{{ driver_a_name }}-{{ user.name }}"
value="{{ driver_a_name }}"
{% if (user_guess is not none) and (driver_a_name in user_guess.podium_drivers.podium_driver_names) %}checked="checked"{% endif %}>
<label class="form-check-label"
for="podium-{{ driver_a_name }}-{{ user.name }}">{{ driver_a_name }}</label>
</div>
</div>
<div class="g-col-6">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox"
name="podiumdrivers"
id="podium-{{ driver_b_name }}-{{ user.name }}"
value="{{ driver_b_name }}"
{% if (user_guess is not none) and (driver_b_name in user_guess.podium_drivers.podium_driver_names) %}checked="checked"{% endif %}>
<label class="form-check-label"
for="podium-{{ driver_b_name }}-{{ user.name }}">{{ driver_b_name }}</label>
</div>
</div>
{% endfor %}
</div>
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save">
</form>
</div>
</div>
{% endfor %}
</div>
{% endblock body %}

View File

@ -1,69 +0,0 @@
from typing import Any, Callable, Iterable, List, TypeVar
_T = TypeVar("_T")
def any_is_none(*args: Any) -> bool:
for arg in args:
if arg is None:
return True
return False
def positions_are_contiguous(positions: List[str]) -> bool:
if len(positions) == 0:
return True
positions_unique = set(positions) # Remove duplicates
positions_sorted: List[int] = sorted([int(position) for position in positions_unique])
# [2, 3, 4, 5]: 2 + 3 == 5
return positions_sorted[0] + len(positions_sorted) - 1 == positions_sorted[-1]
def find_first_or_none(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> _T | None:
"""
Finds the first element in a sequence matching a predicate.
Returns None if no element is found.
"""
return next(filter(predicate, iterable), None)
def find_multiple(predicate: Callable[[_T], bool], iterable: Iterable[_T], count: int = 0) -> List[_T]:
"""
Finds <count> elements in a sequence matching a predicate (finds all if <count> is 0).
Throws exception if more/fewer elements were found than specified.
"""
filtered = list(filter(predicate, iterable))
if count != 0 and len(filtered) != count:
raise Exception(f"find_multiple found {len(filtered)} matching elements but expected {count}")
return filtered
def find_single(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> _T:
"""
Find a single element in a sequence matching a predicate.
Throws exception if more/less than a single element is found.
"""
filtered = list(filter(predicate, iterable))
if len(filtered) != 1:
raise Exception(f"find_single found {len(filtered)} matching elements but expected 1")
return filtered[0]
def find_single_or_none(predicate: Callable[[_T], bool], iterable: Iterable[_T]) -> _T | None:
"""
Find a single element in a sequence matching a predicate if it exists.
Only throws exception if more than a single element is found.
"""
filtered = list(filter(predicate, iterable))
if len(filtered) > 1:
raise Exception(f"find_single_or_none found {len(list(filtered))} matching elements but expected 0 or 1")
return filtered[0] if len(filtered) == 1 else None