Compare commits

..

10 Commits

Author SHA1 Message Date
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
23 changed files with 590 additions and 165 deletions

View File

@ -2,7 +2,7 @@ name: Build Formula10 Docker Image
on: on:
push: push:
# branches: [main] branches: [main]
paths: paths:
- ".gitea/workflows/**" - ".gitea/workflows/**"
- "Dockerfile" - "Dockerfile"

View File

@ -42,11 +42,26 @@
# Use $1 for positional args # Use $1 for positional args
commands = [ commands = [
# { {
# name = ""; name = "vscode";
# help = ""; help = "Launch VSCode";
# command = ""; 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

@ -28,4 +28,25 @@ import formula10.controller.leaderboard_controller
import formula10.controller.statistics_controller import formula10.controller.statistics_controller
import formula10.controller.rules_controller import formula10.controller.rules_controller
import formula10.controller.admin_controller import formula10.controller.admin_controller
import formula10.controller.error_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

@ -1,36 +1,61 @@
from flask import render_template, request from typing import List
from urllib.parse import unquote
from flask import redirect, render_template, request
from werkzeug import Response from werkzeug import Response
from formula10.database.update_queries import update_user from formula10.controller.error_controller import error_redirect
from formula10.database.update_queries import update_race_result, update_user
from formula10.domain.domain_model import Model
from formula10.domain.template_model import TemplateModel from formula10.domain.template_model import TemplateModel
from formula10 import app from formula10 import app
from formula10.openf1.model.api_session import ApiSession
from formula10.openf1.openf1_definitions import OPENF1_SESSION_NAME_RACE
from formula10.openf1.openf1_fetcher import fetch_openf1_driver, fetch_openf1_position, fetch_openf1_session
# @app.route("/result") @app.route("/result")
# def result_root() -> Response: def result_root() -> Response:
# return redirect("/result/Current") return redirect("/result/Current")
# @app.route("/result/<race_name>") @app.route("/result/<race_name>")
# def result_active_race(race_name: str) -> str: def result_active_race(race_name: str) -> str:
# race_name = unquote(race_name) race_name = unquote(race_name)
# model = TemplateModel(active_user_name=None, model = TemplateModel(active_user_name=None,
# active_result_race_name=race_name) active_result_race_name=race_name)
# return render_template("result.jinja", model=model) return render_template("result.jinja", model=model)
# @app.route("/result-enter/<race_name>", methods=["POST"]) @app.route("/result-enter/<race_name>", methods=["POST"])
# def result_enter_post(race_name: str) -> Response: def result_enter_post(race_name: str) -> Response:
# race_name = unquote(race_name) race_name = unquote(race_name)
# pxxs: List[str] = request.form.getlist("pxx-drivers") pxxs: List[str] = request.form.getlist("pxx-drivers")
# first_dnfs: List[str] = request.form.getlist("first-dnf-drivers") first_dnfs: List[str] = request.form.getlist("first-dnf-drivers")
# dnfs: List[str] = request.form.getlist("dnf-drivers") dnfs: List[str] = request.form.getlist("dnf-drivers")
# excluded: List[str] = request.form.getlist("excluded-drivers") excluded: List[str] = request.form.getlist("excluded-drivers")
# # @todo Ugly # Extra stats for points calculation
# race_id: int = Model().race_by(race_name=race_name).id fastest_lap: str | None = request.form.get("fastest-lap")
# return update_race_result(race_id, pxxs, first_dnfs, dnfs, excluded) 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.")
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: ApiSession = fetch_openf1_session(OPENF1_SESSION_NAME_RACE, "KSA")
fetch_openf1_driver(session.session_key, "VER")
fetch_openf1_position(session.session_key, 1)
# @todo Fetch stuff and build the race_result using update_race_result(...)
return redirect("/result")
@app.route("/user") @app.route("/user")

View File

@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from formula10 import db from formula10 import db
@ -19,4 +19,6 @@ class DbRace(db.Model):
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
number: Mapped[int] = mapped_column(Integer, nullable=False, unique=True) number: Mapped[int] = mapped_column(Integer, nullable=False, unique=True)
date: Mapped[datetime] = mapped_column(DateTime, 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 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

@ -1,14 +1,17 @@
import json import json
from typing import List, cast from typing import Dict, List, cast
from urllib.parse import quote
from flask import redirect from flask import redirect
from werkzeug import Response from werkzeug import Response
from formula10.controller.error_controller import error_redirect 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.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_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 import DbSeasonGuess
from formula10.database.model.db_user import DbUser from formula10.database.model.db_user import DbUser
from formula10.database.validation import any_is_none, race_has_started from formula10.database.validation import any_is_none, positions_are_contiguous, race_has_started
from formula10 import ENABLE_TIMING, db from formula10 import ENABLE_TIMING, db
@ -112,77 +115,82 @@ def update_season_guess(user_id: int, guesses: List[str | None], team_winner_gue
return redirect(f"/season/Everyone") return redirect(f"/season/Everyone")
# def find_or_create_race_result(race_id: int) -> DbRaceResult: 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 # 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() race_result: DbRaceResult | None = db.session.query(DbRaceResult).filter_by(race_id=race_id).first()
# if race_result is not None: if race_result is not None:
# return race_result return race_result
# race_result = DbRaceResult(race_id=race_id) race_result = DbRaceResult(race_id=race_id)
# race_result.pxx_driver_ids_json = json.dumps(["9999"]) race_result.pxx_driver_ids_json = json.dumps(["9999"])
# race_result.first_dnf_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.dnf_driver_ids_json = json.dumps(["9999"])
# race_result.excluded_driver_ids_json = json.dumps(["9999"]) race_result.excluded_driver_ids_json = json.dumps(["9999"])
# race_result.fastest_lap_id = 9999 race_result.fastest_lap_id = 9999
# race_result.sprint_dnf_driver_ids_json = json.dumps(["9999"]) race_result.sprint_dnf_driver_ids_json = json.dumps([])
# race_result.sprint_points_json = json.dumps({"9999": "9999"}) race_result.sprint_points_json = json.dumps({})
# db.session.add(race_result) db.session.add(race_result)
# db.session.commit() db.session.commit()
# # Double check if database insertion worked and obtain any values set by the database # 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() race_result = db.session.query(DbRaceResult).filter_by(race_id=race_id).first()
# if race_result is None: if race_result is None:
# raise Exception("Failed adding RaceResult to the database") raise Exception("Failed adding RaceResult to the database")
# return race_result 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]) -> Response: 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],
# if ENABLE_TIMING and not race_has_started(race_id=race_id): fastest_lap_driver_id: int, sprint_pxx_driver_ids_list: List[str], sprint_dnf_driver_ids_list: List[str]) -> Response:
# return error_redirect("No race result can be entered, as the race has not begun!") 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 # Use strings as keys, as these dicts will be serialized to json
# pxx_driver_names: Dict[str, str] = { pxx_driver_ids: Dict[str, str] = {
# str(position + 1): driver_id for position, driver_id in enumerate(pxx_driver_ids_list) str(position + 1): driver_id for position, driver_id in enumerate(pxx_driver_ids_list)
# } }
# # Not counted drivers have to be at the end # Not counted drivers have to be at the end
# excluded_driver_names: Dict[str, str] = { excluded_driver_ids: Dict[str, str] = {
# str(position + 1): driver_id for position, driver_id in enumerate(pxx_driver_ids_list) str(position + 1): driver_id for position, driver_id in enumerate(pxx_driver_ids_list)
# if driver_id in excluded_driver_ids_list if driver_id in excluded_driver_ids_list
# } }
# if len(excluded_driver_names) > 0 and (not "20" in excluded_driver_names or not positions_are_contiguous(list(excluded_driver_names.keys()))): 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!") 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 # First DNF drivers have to be contained in DNF drivers
# for driver_id in first_dnf_driver_ids_list: for driver_id in first_dnf_driver_ids_list:
# if driver_id not in dnf_driver_ids_list: if driver_id not in dnf_driver_ids_list:
# dnf_driver_ids_list.append(driver_id) dnf_driver_ids_list.append(driver_id)
# # There can't be dnfs but no initial dnfs # There can't be dnfs but no initial dnfs
# if len(dnf_driver_ids_list) > 0 and len(first_dnf_driver_ids_list) == 0: 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)!") 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: DbRaceResult = find_or_create_race_result(race_id)
# race_result.pxx_driver_ids_json = json.dumps(pxx_driver_names) 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.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.dnf_driver_ids_json = json.dumps(dnf_driver_ids_list)
# race_result.excluded_driver_ids_json = json.dumps(excluded_driver_ids_list) race_result.excluded_driver_ids_json = json.dumps(excluded_driver_ids_list)
# # @todo Dummy values # Extra stats for points calculation
# race_result.fastest_lap_id = NONE_DRIVER.id sprint_pxx_driver_ids: Dict[str, str] = {
# race_result.sprint_dnf_driver_ids_json = json.dumps([NONE_DRIVER.id]) str(position + 1): driver_id for position, driver_id in enumerate(sprint_pxx_driver_ids_list)
# race_result.sprint_points_json = json.dumps({NONE_DRIVER.id: 0}) }
# db.session.commit() 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)
# race: DbRace | None = db.session.query(DbRace).filter_by(id=race_id).first() db.session.commit()
# if race is None:
# raise Exception(f"Could not find DbRace with id {race_id}")
# return redirect(f"/result/{quote(race.name)}") 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: def update_user(user_name: str | None, add: bool = False, delete: bool = False) -> Response:

View File

@ -13,6 +13,8 @@ class Race():
race.number = db_race.number race.number = db_race.number
race.date = db_race.date race.date = db_race.date
race.place_to_guess = db_race.pxx race.place_to_guess = db_race.pxx
race.quali_date = db_race.quali_date
race.has_sprint = db_race.has_sprint
return race return race
def to_db_race(self) -> DbRace: def to_db_race(self) -> DbRace:
@ -21,6 +23,8 @@ class Race():
db_race.number = self.number db_race.number = self.number
db_race.date = self.date db_race.date = self.date
db_race.pxx = self.place_to_guess db_race.pxx = self.place_to_guess
db_race.quali_date = self.date
db_race.has_sprint = self.has_sprint
return db_race return db_race
def __eq__(self, __value: object) -> bool: def __eq__(self, __value: object) -> bool:
@ -37,6 +41,8 @@ class Race():
number: int number: int
date: datetime date: datetime
place_to_guess: int place_to_guess: int
quali_date: datetime
has_sprint: bool
@property @property
def name_sanitized(self) -> str: def name_sanitized(self) -> str:

View File

@ -154,6 +154,8 @@ class RaceResult:
return points_strings[position_offset] return points_strings[position_offset]
else: else:
return "0 Points" 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}") raise Exception(f"Could not get points string for driver {driver.name}")
@ -174,6 +176,11 @@ class RaceResult:
self.standing[str(position)] for position in range(1, 21) 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: def initial_dnf_string(self) -> str:
if len(self.initial_dnf) == 0: if len(self.initial_dnf) == 0:
return NONE_DRIVER.name return NONE_DRIVER.name

View File

@ -11,6 +11,8 @@ from formula10.domain.model.season_guess_result import SeasonGuessResult
from formula10.domain.model.team import Team from formula10.domain.model.team import Team
from formula10.domain.model.user import User from formula10.domain.model.user import User
# Guess points
RACE_GUESS_OFFSET_POINTS: Dict[int, int] = { RACE_GUESS_OFFSET_POINTS: Dict[int, int] = {
3: 1, 3: 1,
2: 3, 2: 3,
@ -18,7 +20,6 @@ RACE_GUESS_OFFSET_POINTS: Dict[int, int] = {
0: 10 0: 10
} }
RACE_GUESS_DNF_POINTS: int = 10 RACE_GUESS_DNF_POINTS: int = 10
SEASON_GUESS_HOT_TAKE_POINTS: int = 10 SEASON_GUESS_HOT_TAKE_POINTS: int = 10
SEASON_GUESS_P2_POINTS: int = 10 SEASON_GUESS_P2_POINTS: int = 10
SEASON_GUESS_OVERTAKES_POINTS: int = 10 SEASON_GUESS_OVERTAKES_POINTS: int = 10
@ -30,6 +31,8 @@ SEASON_GUESS_TEAMWINNER_FALSE_POINTS: int = -3
SEASON_GUESS_PODIUMS_CORRECT_POINTS: int = 3 SEASON_GUESS_PODIUMS_CORRECT_POINTS: int = 3
SEASON_GUESS_PODIUMS_FALSE_POINTS: int = -2 SEASON_GUESS_PODIUMS_FALSE_POINTS: int = -2
# Driver points
DRIVER_RACE_POINTS: Dict[int, int] = { DRIVER_RACE_POINTS: Dict[int, int] = {
1: 25, 1: 25,
2: 18, 2: 18,
@ -42,6 +45,19 @@ DRIVER_RACE_POINTS: Dict[int, int] = {
9: 2, 9: 2,
10: 1 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] = { WDC_STANDING_2023: Dict[str, int] = {
"Max Verstappen": 1, "Max Verstappen": 1,
@ -65,7 +81,6 @@ WDC_STANDING_2023: Dict[str, int] = {
"Kevin Magnussen": 19, "Kevin Magnussen": 19,
"Logan Sargeant": 21 "Logan Sargeant": 21
} }
WCC_STANDING_2023: Dict[str, int] = { WCC_STANDING_2023: Dict[str, int] = {
"Red Bull": 1, "Red Bull": 1,
"Mercedes": 2, "Mercedes": 2,
@ -133,7 +148,6 @@ class PointsModel(Model):
return self._points_per_step return self._points_per_step
# @todo Doesn't include fastest lap + sprint points
def driver_points_per_step(self) -> Dict[str, List[int]]: def driver_points_per_step(self) -> Dict[str, List[int]]:
""" """
Returns a dictionary of lists, containing points per race for each driver. Returns a dictionary of lists, containing points per race for each driver.
@ -144,15 +158,19 @@ class PointsModel(Model):
self._driver_points_per_step[driver.name] = [0] * (len(self.all_races()) + 1) # Start at index 1, like the race numbers self._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(): for race_result in self.all_race_results():
for position, driver in race_result.standing.items(): race_number: int = race_result.race.number
driver_name: str = driver.name
race_number: int = race_result.race.number
self._driver_points_per_step[driver_name][race_number] = DRIVER_RACE_POINTS[int(position)] if int(position) in DRIVER_RACE_POINTS else 0 for position, driver in race_result.standing.items():
self._driver_points_per_step[driver.name][race_number] = DRIVER_RACE_POINTS[int(position)] if int(position) in DRIVER_RACE_POINTS else 0
self._driver_points_per_step[driver.name][race_number] += DRIVER_FASTEST_LAP_POINTS if race_result.fastest_lap_driver == driver else 0
for position, driver in race_result.sprint_standing.items():
driver_name: str = driver.name
self._driver_points_per_step[driver_name][race_number] += DRIVER_SPRINT_POINTS[int(position)] if int(position) in DRIVER_SPRINT_POINTS else 0
return self._driver_points_per_step return self._driver_points_per_step
# @todo Doesn't include fastest lap + sprint points
def team_points_per_step(self) -> Dict[str, List[int]]: def team_points_per_step(self) -> Dict[str, List[int]]:
""" """
Returns a dictionary of lists, containing points per race for each team. Returns a dictionary of lists, containing points per race for each team.
@ -163,15 +181,14 @@ class PointsModel(Model):
self._team_points_per_step[team.name] = [0] * (len(self.all_races()) + 1) # Start at index 1, like the race numbers self._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 race_result in self.all_race_results():
for position, driver in race_result.standing.items(): for driver in race_result.standing.values():
team_name: str = driver.team.name team_name: str = driver.team.name
race_number: int = race_result.race.number race_number: int = race_result.race.number
self._team_points_per_step[team_name][race_number] += DRIVER_RACE_POINTS[int(position)] if int(position) in DRIVER_RACE_POINTS else 0 self._team_points_per_step[team_name][race_number] += self.driver_points_per_step()[driver.name][race_number]
return self._team_points_per_step return self._team_points_per_step
# @todo Doesn't include sprint dnfs
def dnfs(self) -> Dict[str, int]: def dnfs(self) -> Dict[str, int]:
if self._dnfs is None: if self._dnfs is None:
self._dnfs = dict() self._dnfs = dict()
@ -183,6 +200,9 @@ class PointsModel(Model):
for driver in race_result.all_dnfs: for driver in race_result.all_dnfs:
self._dnfs[driver.name] += 1 self._dnfs[driver.name] += 1
for driver in race_result.sprint_dnfs:
self._dnfs[driver.name] += 1
return self._dnfs return self._dnfs
# #

View File

@ -69,6 +69,14 @@ class TemplateModel(Model):
def current_race(self) -> Race | None: def current_race(self) -> Race | None:
return self.first_race_without_result() 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: def active_result_race_name_or_current_race_name(self) -> str:
if self.active_result is not None: if self.active_result is not None:
return self.active_result.race.name return self.active_result.race.name
@ -88,6 +96,9 @@ class TemplateModel(Model):
def all_drivers_or_active_result_standing_drivers(self) -> List[Driver]: def all_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) return self.active_result.ordered_standing_list() if self.active_result is not None else self.all_drivers(include_none=False)
def all_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)
def drivers_for_wdc_gained(self) -> List[Driver]: def drivers_for_wdc_gained(self) -> List[Driver]:
predicate: Callable[[Driver], bool] = lambda driver: driver.abbr not in self._wdc_gained_excluded_abbrs 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)) return find_multiple_strict(predicate, self.all_drivers(include_none=False))

View File

View File

@ -0,0 +1,55 @@
from typing import Any, Callable, Dict
class ApiDriver():
__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("ApiDriver:", 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 ApiPosition():
__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("ApiPosition:", 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,58 @@
from datetime import datetime, time
from typing import Any, Callable, Dict
class ApiSession():
__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("ApiSession:", 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.api_driver import ApiDriver
from formula10.openf1.model.api_position import ApiPosition
from formula10.openf1.model.api_session import ApiSession
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 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 fetch_openf1_latest_session(session_name: str) -> ApiSession:
# ApiSession object only supports integer session_keys
response: List[Dict[str, str]] = request_helper(OPENF1_SESSION_ENDPOINT, {
"session_key": "latest",
"session_type": OPENF1_SESSION_TYPE_RACE,
"session_name": session_name
})
return ApiSession(response[0])
def fetch_openf1_latest_race_session_key() -> int:
return fetch_openf1_latest_session(OPENF1_SESSION_NAME_RACE).session_key
def fetch_openf1_latest_sprint_session_key() -> int:
return fetch_openf1_latest_session(OPENF1_SESSION_NAME_SPRINT).session_key
def fetch_openf1_session(session_name: str, country_code: str) -> ApiSession:
_session: ApiSession = ApiSession(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]] = request_helper(OPENF1_SESSION_ENDPOINT, _session.to_params())
return ApiSession(response[0])
def fetch_openf1_driver(session_key: int, name_acronym: str) -> ApiDriver:
_driver: ApiDriver = ApiDriver(None)
_driver.name_acronym = name_acronym
_driver.session_key = session_key
response: List[Dict[str, str]] = request_helper(OPENF1_DRIVER_ENDPOINT, _driver.to_params())
return ApiDriver(response[0])
def fetch_openf1_position(session_key: int, position: int):
_position: ApiPosition = ApiPosition(None)
_position.session_key = session_key
_position.position = position
response: List[Dict[str, str]] = 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 ApiPosition(max(response, key=predicate))

View File

@ -24,4 +24,4 @@
grid-row-gap: 0; grid-row-gap: 0;
grid-column-gap: 8px; grid-column-gap: 8px;
} }
} }

View File

@ -26,8 +26,7 @@
{# Simple driver select for forms #} {# Simple driver select for forms #}
{% macro driver_select(name, label, include_none, drivers=none, disabled=false, border="") %} {% macro driver_select(name, label, include_none, drivers=none, disabled=false, border="") %}
<div class="form-floating"> <div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" <select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
{% if disabled %}disabled="disabled"{% endif %}>
<option value="" selected disabled hidden></option> <option value="" selected disabled hidden></option>
{% if drivers == none %} {% if drivers == none %}
@ -45,8 +44,7 @@
{# Driver select for forms where a value might be preselected #} {# Driver select for forms where a value might be preselected #}
{% macro driver_select_with_preselect(driver_match, name, label, include_none, drivers=none, disabled=false, border="") %} {% macro driver_select_with_preselect(driver_match, name, label, include_none, drivers=none, disabled=false, border="") %}
<div class="form-floating"> <div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" <select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
{% if disabled %}disabled="disabled"{% endif %}>
{# Use namespace wrapper to persist scope between loop iterations #} {# Use namespace wrapper to persist scope between loop iterations #}
{% set user_has_chosen = namespace(driverpre=false) %} {% set user_has_chosen = namespace(driverpre=false) %}
@ -79,8 +77,7 @@
{# Simple team select for forms #} {# Simple team select for forms #}
{% macro team_select(name, label, include_none, teams=none, disabled=false, border="") %} {% macro team_select(name, label, include_none, teams=none, disabled=false, border="") %}
<div class="form-floating"> <div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" <select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
{% if disabled %}disabled="disabled"{% endif %}>
<option value="" selected disabled hidden></option> <option value="" selected disabled hidden></option>
{% if teams == none %} {% if teams == none %}
@ -98,8 +95,7 @@
{# Team select for forms where a value might be preselected #} {# 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="") %} {% macro team_select_with_preselect(team_match, name, label, include_none, teams=none, disabled=false, border="") %}
<div class="form-floating"> <div class="form-floating">
<select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" <select name="{{ name }}" id="{{ name }}" class="form-select {{ border }}" aria-label="{{ name }}" {% if disabled %}disabled="disabled"{% endif %}>
{% if disabled %}disabled="disabled"{% endif %}>
{# Use namespace wrapper to persist scope between loop iterations #} {# Use namespace wrapper to persist scope between loop iterations #}
{% set user_has_chosen = namespace(teampre=false) %} {% set user_has_chosen = namespace(teampre=false) %}
@ -222,7 +218,7 @@
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<div class="navbar-nav"> <div class="navbar-nav">
{# {{ nav_selector(page="/result", text="Enter Race Result") }} #} {{ nav_selector(page="/result", text="Enter Race Result") }}
{{ nav_selector(page="/user", text="Manage Users") }} {{ nav_selector(page="/user", text="Manage Users") }}
</div> </div>
</div> </div>

View File

@ -42,90 +42,176 @@
{% block body %} {% block body %}
<div class="grid card-grid"> {% 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 %}
<div class="card shadow-sm mb-2" style="max-width: 450px;"> <form action="{{ action_fetch_href }}" method="post">
<div class="card shadow-sm mb-2 w-100">
<div class="card-header">
OpenF1
</div>
<div class="card-body">
<input type="submit" class="btn btn-danger mt-2 w-100" value="Fetch from OpenF1"
{% 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"> <div class="card-header">
{{ model.active_result_race_name_or_current_race_name() }} {{ model.active_result_race_name_or_current_race_name() }}
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-inline-block overflow-x-scroll w-100"> <div class="d-inline-block overflow-x-scroll w-100">
<div style="width: 410px;"> <div style="width: 460px;">
{% set race_result_open=model.race_result_open(model.active_result_race_name_or_current_race_name()) %}
{% if race_result_open == true %} {# Place numbers #}
{% set action_save_href = "/result-enter/" ~ model.active_result_race_name_or_current_race_name_sanitized() %} <ul class="list-group list-group-flush d-inline-block">
{% else %} {% for driver in model.all_drivers_or_active_result_standing_drivers() %}
{% set action_save_href = "" %} <li class="list-group-item p-1"><span id="place_number"
{% endif %} 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_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;">
<form action="{{ action_save_href }}" method="post">
{# Place numbers #} {# Place numbers #}
<ul class="list-group list-group-flush d-inline-block"> <ul class="list-group list-group-flush d-inline-block">
{% for driver in model.all_drivers_or_active_result_standing_drivers() %} {% for driver in model.all_drivers_or_active_result_sprint_standing_drivers() %}
<li class="list-group-item p-1"><span id="place_number" <li class="list-group-item p-1"><span id="place_number"
class="fw-bold">P{{ "%02d" % loop.index }}</span>: class="fw-bold">P{{ "%02d" % loop.index }}</span>:
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{# Drag and drop #} {# 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"> <ul id="columns" class="list-group list-group-flush d-inline-block float-end">
{% for driver in model.all_drivers_or_active_result_sprint_standing_drivers() %}
{% for driver in model.all_drivers_or_active_result_standing_drivers() %}
<li class="list-group-item {% if race_result_open == true %}column{% endif %} p-1" <li class="list-group-item {% if race_result_open == true %}column{% endif %} p-1"
{% if race_result_open == true %}draggable="true"{% endif %}> {% if race_result_open == true %}draggable="true"{% endif %}>
{{ driver.name }} {{ driver.name }}
<div class="d-inline-block float-end" style="margin-left: 30px;"> <div class="d-inline-block float-end" style="margin-left: 30px;">
{# Driver DNFed at first #}
<div class="form-check form-check-reverse d-inline-block">
<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 #} {# Driver DNFed #}
<div class="form-check form-check-reverse d-inline-block mx-2"> <div class="form-check form-check-reverse d-inline-block"
style="margin-left: 2px;">
<input type="checkbox" class="form-check-input" <input type="checkbox" class="form-check-input"
value="{{ driver.id }}" value="{{ driver.id }}"
id="dnf-{{ driver.id }}" name="dnf-drivers" id="sprint-dnf-{{ driver.id }}" name="sprint-dnf-drivers"
{% if (model.active_result is not none) and (driver in model.active_result.all_dnfs) %}checked{% endif %} {% 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 %}> {% if race_result_open == false %}disabled="disabled"{% endif %}>
<label for="dnf-{{ driver.id }}" <label for="sprint-dnf-{{ driver.id }}"
class="form-check-label text-muted">DNF</label> class="form-check-label text-muted">DNF</label>
</div> </div>
{# Driver Excluded #}
<div class="form-check form-check-reverse d-inline-block">
<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> </div>
{# Standing order #} {# Standing order #}
<input type="hidden" name="pxx-drivers" value="{{ driver.id }}"> <input type="hidden" name="sprint-pxx-drivers" value="{{ driver.id }}">
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<input type="submit" class="btn btn-danger mt-2 w-100" value="Save"
{% if race_result_open == false %}disabled="disabled"{% endif %}> </div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> {% endif %}
</div>
</form>
{% endblock body %} {% endblock body %}

View File

@ -18,7 +18,6 @@
<div class="card-body"> <div class="card-body">
Picks that match the current standings are marked in green, except for the hot-take and overtake picks, as 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> those are not evaluated automatically.<br>
Points from sprints and fastest laps are not tracked currently.
</div> </div>
</div> </div>

View File

@ -6,16 +6,6 @@
{% block body %} {% block body %}
<div class="card shadow-sm mb-2">
<div class="card-header">
Note
</div>
<div class="card-body">
Points from sprints and fastest laps are not tracked currently.
</div>
</div>
<div class="grid card-grid-2"> <div class="grid card-grid-2">
<div class="card shadow-sm mb-2"> <div class="card shadow-sm mb-2">

View File

@ -4,5 +4,6 @@ numpy
flask flask
flask-sqlalchemy flask-sqlalchemy
sqlalchemy sqlalchemy
requests
pytest pytest