From a21bb2a3803d7e06da6a987f9b2c24d811ad3806 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 17 Mar 2026 17:33:47 -0500 Subject: [PATCH 01/20] fix: add combined_season classmethod to PitchingStat (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #65 `PitchingStat.combined_season()` was referenced in the `get_pitstats` handler but never defined, causing a 500 on `s_type=combined/total/all`. Added `combined_season` as a `@staticmethod` matching the pattern of `BattingStat.combined_season` — returns all rows for the given season with no week filter (both regular and postseason). Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/db_engine.py b/app/db_engine.py index c1979cf..0495ae1 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1175,6 +1175,10 @@ class PitchingStat(BaseModel): def select_season(season): return PitchingStat.select().where(PitchingStat.season == season) + @staticmethod + def combined_season(season): + return PitchingStat.select().where(PitchingStat.season == season) + @staticmethod def regular_season(season): if season == 1: From c451e02c525aa3d225304cb677dedac73d460a73 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 26 Mar 2026 23:15:07 -0500 Subject: [PATCH 02/20] fix: remove hardcoded fallback password from PostgreSQL connection Raise RuntimeError on startup if POSTGRES_PASSWORD env var is not set, instead of silently falling back to a known password in source code. Closes #C2 from postgres migration review. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/db_engine.py | 1280 ++++++++++++++++++++++++++++------------------ 1 file changed, 788 insertions(+), 492 deletions(-) diff --git a/app/db_engine.py b/app/db_engine.py index c1979cf..7cc23e3 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -11,35 +11,38 @@ from peewee import ModelSelect from playhouse.shortcuts import model_to_dict # Database configuration - supports both SQLite and PostgreSQL -DATABASE_TYPE = os.environ.get('DATABASE_TYPE', 'sqlite') +DATABASE_TYPE = os.environ.get("DATABASE_TYPE", "sqlite") -if DATABASE_TYPE.lower() == 'postgresql': +if DATABASE_TYPE.lower() == "postgresql": from playhouse.pool import PooledPostgresqlDatabase + + _postgres_password = os.environ.get("POSTGRES_PASSWORD") + if _postgres_password is None: + raise RuntimeError( + "POSTGRES_PASSWORD environment variable is not set. " + "This variable is required when DATABASE_TYPE=postgresql." + ) db = PooledPostgresqlDatabase( - os.environ.get('POSTGRES_DB', 'sba_master'), - user=os.environ.get('POSTGRES_USER', 'sba_admin'), - password=os.environ.get('POSTGRES_PASSWORD', 'sba_dev_password_2024'), - host=os.environ.get('POSTGRES_HOST', 'sba_postgres'), - port=int(os.environ.get('POSTGRES_PORT', '5432')), + os.environ.get("POSTGRES_DB", "sba_master"), + user=os.environ.get("POSTGRES_USER", "sba_admin"), + password=_postgres_password, + host=os.environ.get("POSTGRES_HOST", "sba_postgres"), + port=int(os.environ.get("POSTGRES_PORT", "5432")), max_connections=20, stale_timeout=300, # 5 minutes timeout=0, autoconnect=True, - autorollback=True # Automatically rollback failed transactions + autorollback=True, # Automatically rollback failed transactions ) else: # Default SQLite configuration db = SqliteDatabase( - 'storage/sba_master.db', - pragmas={ - 'journal_mode': 'wal', - 'cache_size': -1 * 64000, - 'synchronous': 0 - } + "storage/sba_master.db", + pragmas={"journal_mode": "wal", "cache_size": -1 * 64000, "synchronous": 0}, ) -date = f'{datetime.datetime.now().year}-{datetime.datetime.now().month}-{datetime.datetime.now().day}' -logger = logging.getLogger('discord_app') +date = f"{datetime.datetime.now().year}-{datetime.datetime.now().month}-{datetime.datetime.now().day}" +logger = logging.getLogger("discord_app") """ @@ -51,7 +54,6 @@ Per season updates: """ - def model_csv_headers(this_obj, exclude=None) -> List: data = model_to_dict(this_obj, recurse=False, exclude=exclude) return [x for x in data.keys()] @@ -64,7 +66,7 @@ def model_to_csv(this_obj, exclude=None) -> List: def query_to_csv(all_items: ModelSelect, exclude=None): if all_items.count() == 0: - data_list = [['No data found']] + data_list = [["No data found"]] else: data_list = [model_csv_headers(all_items[0], exclude=exclude)] for x in all_items: @@ -75,29 +77,29 @@ def query_to_csv(all_items: ModelSelect, exclude=None): def complex_data_to_csv(complex_data: List): if len(complex_data) == 0: - data_list = [['No data found']] + data_list = [["No data found"]] else: data_list = [[x for x in complex_data[0].keys()]] for line in complex_data: - logger.info(f'line: {line}') + logger.info(f"line: {line}") this_row = [] for key in line: - logger.info(f'key: {key}') + logger.info(f"key: {key}") if line[key] is None: - this_row.append('') + this_row.append("") elif isinstance(line[key], dict): - if 'name' in line[key]: - this_row.append(line[key]['name']) - elif 'abbrev' in line[key]: - this_row.append(line[key]['abbrev']) + if "name" in line[key]: + this_row.append(line[key]["name"]) + elif "abbrev" in line[key]: + this_row.append(line[key]["abbrev"]) else: - this_row.append(line[key]['id']) + this_row.append(line[key]["id"]) elif isinstance(line[key], int) and line[key] > 100000000: this_row.append(f"'{line[key]}") - elif isinstance(line[key], str) and ',' in line[key]: + elif isinstance(line[key], str) and "," in line[key]: this_row.append(line[key].replace(",", "-_-")) else: @@ -108,37 +110,38 @@ def complex_data_to_csv(complex_data: List): return DataFrame(data_list).to_csv(header=False, index=False) -def per_season_weeks(season: int, s_type: Literal['regular', 'post', 'total']): +def per_season_weeks(season: int, s_type: Literal["regular", "post", "total"]): if season == 1: - if s_type == 'regular': - return {'start': 1, 'end': 20} - elif s_type == 'post': - return {'start': 21, 'end': 22} + if s_type == "regular": + return {"start": 1, "end": 20} + elif s_type == "post": + return {"start": 21, "end": 22} else: - return {'start': 1, 'end': 22} + return {"start": 1, "end": 22} elif season in [3, 4, 5, 6, 7]: - if s_type == 'regular': - return {'start': 1, 'end': 22} - elif s_type == 'post': - return {'start': 23, 'end': 25} + if s_type == "regular": + return {"start": 1, "end": 22} + elif s_type == "post": + return {"start": 23, "end": 25} else: - return {'start': 1, 'end': 25} + return {"start": 1, "end": 25} # Season 2, 8, and beyond else: - if s_type == 'regular': - return {'start': 1, 'end': 18} - elif s_type == 'post': - return {'start': 19, 'end': 21} + if s_type == "regular": + return {"start": 1, "end": 18} + elif s_type == "post": + return {"start": 19, "end": 21} else: - return {'start': 1, 'end': 21} + return {"start": 1, "end": 21} def win_pct(this_team_stan): if this_team_stan.wins + this_team_stan.losses == 0: return 0 else: - return (this_team_stan.wins / (this_team_stan.wins + this_team_stan.losses)) + \ - (this_team_stan.run_diff * .000001) + return (this_team_stan.wins / (this_team_stan.wins + this_team_stan.losses)) + ( + this_team_stan.run_diff * 0.000001 + ) def games_back(leader, chaser): @@ -183,19 +186,21 @@ class Division(BaseModel): season = IntegerField(default=0) def abbrev(self): - league_short = self.league_abbrev + ' ' if self.league_abbrev else '' - return f'{league_short}{self.division_abbrev}' + league_short = self.league_abbrev + " " if self.league_abbrev else "" + return f"{league_short}{self.division_abbrev}" def short_name(self): - league_short = self.league_abbrev + ' ' if self.league_abbrev else '' - return f'{league_short}{self.division_name}' + league_short = self.league_abbrev + " " if self.league_abbrev else "" + return f"{league_short}{self.division_name}" def full_name(self): - league_long = self.league_name + ' ' if self.league_name else '' - return f'{league_long}{self.division_name}' + league_long = self.league_name + " " if self.league_name else "" + return f"{league_long}{self.division_name}" def sort_division(self, season): - div_query = Standings.select_season(season).where(Standings.team.division == self) + div_query = Standings.select_season(season).where( + Standings.team.division == self + ) div_teams = [team_stan for team_stan in div_query] div_teams.sort(key=lambda team: win_pct(team), reverse=True) @@ -297,27 +302,40 @@ class Team(BaseModel): @staticmethod def get_season(name_or_abbrev, season): - team = Team.get_or_none(fn.Upper(Team.abbrev) == name_or_abbrev.upper(), Team.season == season) + team = Team.get_or_none( + fn.Upper(Team.abbrev) == name_or_abbrev.upper(), Team.season == season + ) if not team: - team = Team.get_or_none(fn.Lower(Team.sname) == name_or_abbrev.lower(), Team.season == season) + team = Team.get_or_none( + fn.Lower(Team.sname) == name_or_abbrev.lower(), Team.season == season + ) if not team: - team = Team.get_or_none(fn.Lower(Team.lname) == name_or_abbrev.lower(), Team.season == season) + team = Team.get_or_none( + fn.Lower(Team.lname) == name_or_abbrev.lower(), + Team.season == season, + ) return team def get_record(self, week): wins = Result.select_season(Current.latest().season).where( - (((Result.hometeam == self) & (Result.homescore > Result.awayscore)) | - ((Result.awayteam == self) & (Result.awayscore > Result.homescore))) & (Result.week <= week) + ( + ((Result.hometeam == self) & (Result.homescore > Result.awayscore)) + | ((Result.awayteam == self) & (Result.awayscore > Result.homescore)) + ) + & (Result.week <= week) ) losses = Result.select_season(Current.latest().season).where( - (((Result.awayteam == self) & (Result.homescore > Result.awayscore)) | - ((Result.hometeam == self) & (Result.awayscore > Result.homescore))) & (Result.week <= week) + ( + ((Result.awayteam == self) & (Result.homescore > Result.awayscore)) + | ((Result.hometeam == self) & (Result.awayscore > Result.homescore)) + ) + & (Result.week <= week) ) if wins.count() + losses.count() > 0: pct = wins.count() / (wins.count() + losses.count()) else: pct = 0 - return {'w': wins.count(), 'l': losses.count(), 'pct': pct} + return {"w": wins.count(), "l": losses.count(), "pct": pct} def get_gms(self): if self.gmid2: @@ -326,17 +344,35 @@ class Team(BaseModel): return [self.gmid] def get_this_week(self): - active_team = Player.select_season(self.season).where(Player.team == self).order_by(Player.wara) + active_team = ( + Player.select_season(self.season) + .where(Player.team == self) + .order_by(Player.wara) + ) - active_roster = {'C': 0, '1B': 0, '2B': 0, '3B': 0, 'SS': 0, 'LF': 0, 'CF': 0, 'RF': 0, 'DH': 0, - 'SP': 0, 'RP': 0, 'CP': 0, 'WARa': 0, 'players': []} + active_roster = { + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "SP": 0, + "RP": 0, + "CP": 0, + "WARa": 0, + "players": [], + } combo_pitchers = 0 for guy in active_team: - active_roster['WARa'] += guy.wara - active_roster['players'].append(guy) + active_roster["WARa"] += guy.wara + active_roster["players"].append(guy) guy_pos = guy.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: try: @@ -347,276 +383,374 @@ class Team(BaseModel): pass if combo_pitchers > 0: - if active_roster['SP'] < 5: - if 5 - active_roster['SP'] <= combo_pitchers: - delta = 5 - active_roster['SP'] + if active_roster["SP"] < 5: + if 5 - active_roster["SP"] <= combo_pitchers: + delta = 5 - active_roster["SP"] else: delta = combo_pitchers - active_roster['SP'] += delta + active_roster["SP"] += delta combo_pitchers -= delta if combo_pitchers > 0: - active_roster['RP'] += combo_pitchers + active_roster["RP"] += combo_pitchers - short_il = Player.select_season(self.season).join(Team).where(Player.team.abbrev == f'{self.abbrev}IL') + short_il = ( + Player.select_season(self.season) + .join(Team) + .where(Player.team.abbrev == f"{self.abbrev}IL") + ) - short_roster = {'C': 0, '1B': 0, '2B': 0, '3B': 0, 'SS': 0, 'LF': 0, 'CF': 0, 'RF': 0, 'DH': 0, - 'SP': 0, 'RP': 0, 'CP': 0, 'WARa': 0, 'players': []} + short_roster = { + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "SP": 0, + "RP": 0, + "CP": 0, + "WARa": 0, + "players": [], + } combo_pitchers = 0 for guy in short_il: - short_roster['WARa'] += guy.wara - short_roster['players'].append(guy) + short_roster["WARa"] += guy.wara + short_roster["players"].append(guy) guy_pos = guy.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: short_roster[pos] += 1 if combo_pitchers > 0: - if short_roster['SP'] < 5: - if 5 - short_roster['SP'] <= combo_pitchers: - delta = 5 - short_roster['SP'] + if short_roster["SP"] < 5: + if 5 - short_roster["SP"] <= combo_pitchers: + delta = 5 - short_roster["SP"] else: delta = combo_pitchers - short_roster['SP'] += delta + short_roster["SP"] += delta combo_pitchers -= delta if combo_pitchers > 0: - short_roster['RP'] += combo_pitchers + short_roster["RP"] += combo_pitchers - long_il = Player.select_season(self.season).join(Team).where(Player.team.abbrev == f'{self.abbrev}MiL') + long_il = ( + Player.select_season(self.season) + .join(Team) + .where(Player.team.abbrev == f"{self.abbrev}MiL") + ) - long_roster = {'C': 0, '1B': 0, '2B': 0, '3B': 0, 'SS': 0, 'LF': 0, 'CF': 0, 'RF': 0, 'DH': 0, - 'SP': 0, 'RP': 0, 'CP': 0, 'WARa': 0, 'players': []} + long_roster = { + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "SP": 0, + "RP": 0, + "CP": 0, + "WARa": 0, + "players": [], + } combo_pitchers = 0 for guy in long_il: - long_roster['WARa'] += guy.wara - long_roster['players'].append(guy) + long_roster["WARa"] += guy.wara + long_roster["players"].append(guy) guy_pos = guy.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: long_roster[pos] += 1 if combo_pitchers > 0: - if long_roster['SP'] < 5: - if 5 - long_roster['SP'] <= combo_pitchers: - delta = 5 - long_roster['SP'] + if long_roster["SP"] < 5: + if 5 - long_roster["SP"] <= combo_pitchers: + delta = 5 - long_roster["SP"] else: delta = combo_pitchers - long_roster['SP'] += delta + long_roster["SP"] += delta combo_pitchers -= delta if combo_pitchers > 0: - long_roster['RP'] += combo_pitchers + long_roster["RP"] += combo_pitchers - return {'active': active_roster, 'shortil': short_roster, 'longil': long_roster} + return {"active": active_roster, "shortil": short_roster, "longil": long_roster} def get_next_week(self): current = Current.latest() active_team = Player.select_season(current.season).where(Player.team == self) - active_roster = {'C': 0, '1B': 0, '2B': 0, '3B': 0, 'SS': 0, 'LF': 0, 'CF': 0, 'RF': 0, 'DH': 0, - 'SP': 0, 'RP': 0, 'CP': 0, 'WARa': 0, 'players': []} + active_roster = { + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "SP": 0, + "RP": 0, + "CP": 0, + "WARa": 0, + "players": [], + } combo_pitchers = 0 for guy in active_team: - active_roster['WARa'] += guy.wara - active_roster['players'].append(guy) + active_roster["WARa"] += guy.wara + active_roster["players"].append(guy) guy_pos = guy.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: active_roster[pos] += 1 all_drops = Transaction.select_season(Current.latest().season).where( - (Transaction.oldteam == self) & (Transaction.week == current.week + 1) & (Transaction.cancelled == 0) + (Transaction.oldteam == self) + & (Transaction.week == current.week + 1) + & (Transaction.cancelled == 0) ) all_adds = Transaction.select_season(Current.latest().season).where( - (Transaction.newteam == self) & (Transaction.week == current.week + 1) & (Transaction.cancelled == 0) + (Transaction.newteam == self) + & (Transaction.week == current.week + 1) + & (Transaction.cancelled == 0) ) for move in all_drops: guy_pos = move.player.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers -= 1 else: for pos in guy_pos: active_roster[pos] -= 1 # print(f'dropping {move.player.name} id ({move.player.get_id()}) for {move.player.wara} WARa') - active_roster['WARa'] -= move.player.wara + active_roster["WARa"] -= move.player.wara try: - active_roster['players'].remove(move.player) + active_roster["players"].remove(move.player) except Exception: - print(f'I could not drop {move.player.name}') + print(f"I could not drop {move.player.name}") for move in all_adds: guy_pos = move.player.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: active_roster[pos] += 1 # print(f'adding {move.player.name} id ({move.player.get_id()}) for {move.player.wara} WARa') - active_roster['WARa'] += move.player.wara - active_roster['players'].append(move.player) + active_roster["WARa"] += move.player.wara + active_roster["players"].append(move.player) if combo_pitchers > 0: - if active_roster['SP'] < 5: - if 5 - active_roster['SP'] <= combo_pitchers: - delta = 5 - active_roster['SP'] + if active_roster["SP"] < 5: + if 5 - active_roster["SP"] <= combo_pitchers: + delta = 5 - active_roster["SP"] else: delta = combo_pitchers - active_roster['SP'] += delta + active_roster["SP"] += delta combo_pitchers -= delta if combo_pitchers > 0: - active_roster['RP'] += combo_pitchers + active_roster["RP"] += combo_pitchers - short_il = Player.select_season(current.season).join(Team).where(Player.team.abbrev == f'{self.abbrev}SIL') + short_il = ( + Player.select_season(current.season) + .join(Team) + .where(Player.team.abbrev == f"{self.abbrev}SIL") + ) - short_roster = {'C': 0, '1B': 0, '2B': 0, '3B': 0, 'SS': 0, 'LF': 0, 'CF': 0, 'RF': 0, 'DH': 0, - 'SP': 0, 'RP': 0, 'CP': 0, 'WARa': 0, 'players': []} + short_roster = { + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "SP": 0, + "RP": 0, + "CP": 0, + "WARa": 0, + "players": [], + } combo_pitchers = 0 for guy in short_il: - short_roster['WARa'] += guy.wara - short_roster['players'].append(guy) + short_roster["WARa"] += guy.wara + short_roster["players"].append(guy) guy_pos = guy.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: short_roster[pos] += 1 - sil_team = Team.get_season(f'{self.abbrev}SIL', current.season) + sil_team = Team.get_season(f"{self.abbrev}SIL", current.season) all_drops = Transaction.select_season(Current.latest().season).where( - (Transaction.oldteam == sil_team) & (Transaction.week == current.week + 1) & (Transaction.cancelled == 0) + (Transaction.oldteam == sil_team) + & (Transaction.week == current.week + 1) + & (Transaction.cancelled == 0) ) all_adds = Transaction.select_season(Current.latest().season).where( - (Transaction.newteam == sil_team) & (Transaction.week == current.week + 1) & (Transaction.cancelled == 0) + (Transaction.newteam == sil_team) + & (Transaction.week == current.week + 1) + & (Transaction.cancelled == 0) ) for move in all_drops: guy_pos = move.player.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers -= 1 else: for pos in guy_pos: short_roster[pos] -= 1 - short_roster['WARa'] -= move.player.wara + short_roster["WARa"] -= move.player.wara # print(f'SIL dropping {move.player.name} id ({move.player.get_id()}) for {move.player.wara} WARa') try: - short_roster['players'].remove(move.player) + short_roster["players"].remove(move.player) except Exception: - print(f'I could not drop {move.player.name}') + print(f"I could not drop {move.player.name}") for move in all_adds: guy_pos = move.player.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: short_roster[pos] += 1 # print(f'SIL adding {move.player.name} id ({move.player.get_id()}) for {move.player.wara} WARa') - short_roster['WARa'] += move.player.wara - short_roster['players'].append(move.player) + short_roster["WARa"] += move.player.wara + short_roster["players"].append(move.player) if combo_pitchers > 0: - if short_roster['SP'] < 5: - if 5 - short_roster['SP'] <= combo_pitchers: - delta = 5 - short_roster['SP'] + if short_roster["SP"] < 5: + if 5 - short_roster["SP"] <= combo_pitchers: + delta = 5 - short_roster["SP"] else: delta = combo_pitchers - short_roster['SP'] += delta + short_roster["SP"] += delta combo_pitchers -= delta if combo_pitchers > 0: - short_roster['RP'] += combo_pitchers + short_roster["RP"] += combo_pitchers - long_il = Player.select_season(current.season).join(Team).where(Player.team.abbrev == f'{self.abbrev}MiL') + long_il = ( + Player.select_season(current.season) + .join(Team) + .where(Player.team.abbrev == f"{self.abbrev}MiL") + ) - long_roster = {'C': 0, '1B': 0, '2B': 0, '3B': 0, 'SS': 0, 'LF': 0, 'CF': 0, 'RF': 0, 'DH': 0, - 'SP': 0, 'RP': 0, 'CP': 0, 'WARa': 0, 'players': []} + long_roster = { + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "SP": 0, + "RP": 0, + "CP": 0, + "WARa": 0, + "players": [], + } combo_pitchers = 0 for guy in long_il: - long_roster['WARa'] += guy.wara - long_roster['players'].append(guy) + long_roster["WARa"] += guy.wara + long_roster["players"].append(guy) guy_pos = guy.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: long_roster[pos] += 1 - lil_team = Team.get_season(f'{self.abbrev}LIL', current.season) + lil_team = Team.get_season(f"{self.abbrev}LIL", current.season) all_drops = Transaction.select_season(Current.latest().season).where( - (Transaction.oldteam == lil_team) & (Transaction.week == current.week + 1) & (Transaction.cancelled == 0) + (Transaction.oldteam == lil_team) + & (Transaction.week == current.week + 1) + & (Transaction.cancelled == 0) ) all_adds = Transaction.select_season(Current.latest().season).where( - (Transaction.newteam == lil_team) & (Transaction.week == current.week + 1) & (Transaction.cancelled == 0) + (Transaction.newteam == lil_team) + & (Transaction.week == current.week + 1) + & (Transaction.cancelled == 0) ) for move in all_drops: guy_pos = move.player.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers -= 1 else: for pos in guy_pos: long_roster[pos] -= 1 - long_roster['WARa'] -= move.player.wara + long_roster["WARa"] -= move.player.wara # print(f'LIL dropping {move.player.name} id ({move.player.get_id()}) for {move.player.wara} WARa') try: - long_roster['players'].remove(move.player) + long_roster["players"].remove(move.player) except Exception: - print(f'I could not drop {move.player.name}') + print(f"I could not drop {move.player.name}") for move in all_adds: guy_pos = move.player.get_positions() - if 'SP' in guy_pos and 'RP' in guy_pos: + if "SP" in guy_pos and "RP" in guy_pos: combo_pitchers += 1 else: for pos in guy_pos: long_roster[pos] += 1 # print(f'LIL adding {move.player.name} id ({move.player.get_id()}) for {move.player.wara} WARa') - long_roster['WARa'] += move.player.wara - long_roster['players'].append(move.player) + long_roster["WARa"] += move.player.wara + long_roster["players"].append(move.player) if combo_pitchers > 0: - if long_roster['SP'] < 5: - if 5 - long_roster['SP'] <= combo_pitchers: - delta = 5 - long_roster['SP'] + if long_roster["SP"] < 5: + if 5 - long_roster["SP"] <= combo_pitchers: + delta = 5 - long_roster["SP"] else: delta = combo_pitchers - long_roster['SP'] += delta + long_roster["SP"] += delta combo_pitchers -= delta if combo_pitchers > 0: - long_roster['RP'] += combo_pitchers + long_roster["RP"] += combo_pitchers - return {'active': active_roster, 'shortil': short_roster, 'longil': long_roster} + return {"active": active_roster, "shortil": short_roster, "longil": long_roster} def run_pythag_last8(self): team_stan = Standings.get_or_none(Standings.team == self) runs_scored, runs_allowed = 0, 0 away_games = StratGame.select( - fn.SUM(StratGame.away_score).alias('r_scored'), - fn.SUM(StratGame.home_score).alias('r_allowed') + fn.SUM(StratGame.away_score).alias("r_scored"), + fn.SUM(StratGame.home_score).alias("r_allowed"), ).where((StratGame.away_team == self) & StratGame.game_num.is_null(False)) if away_games.count() > 0: runs_scored += away_games[0].r_scored runs_allowed += away_games[0].r_allowed home_games = StratGame.select( - fn.SUM(StratGame.home_score).alias('r_scored'), - fn.SUM(StratGame.away_score).alias('r_allowed') + fn.SUM(StratGame.home_score).alias("r_scored"), + fn.SUM(StratGame.away_score).alias("r_allowed"), ).where((StratGame.home_team == self) & StratGame.game_num.is_null(False)) if home_games.count() > 0: runs_scored += home_games[0].r_scored @@ -627,17 +761,23 @@ class Team(BaseModel): elif runs_scored == 0: pythag_win_pct = 0 else: - pythag_win_pct = runs_scored ** 1.83 / ((runs_scored ** 1.83) + (runs_allowed ** 1.83)) + pythag_win_pct = runs_scored**1.83 / ( + (runs_scored**1.83) + (runs_allowed**1.83) + ) games_played = team_stan.wins + team_stan.losses team_stan.pythag_wins = round(games_played * pythag_win_pct) team_stan.pythag_losses = games_played - team_stan.pythag_wins - last_games = StratGame.select().where( - ((StratGame.home_team == self) | (StratGame.away_team == self)) & (StratGame.game_num.is_null(False)) - ).order_by( - -StratGame.season, -StratGame.week, -StratGame.game_num - ).limit(8) + last_games = ( + StratGame.select() + .where( + ((StratGame.home_team == self) | (StratGame.away_team == self)) + & (StratGame.game_num.is_null(False)) + ) + .order_by(-StratGame.season, -StratGame.week, -StratGame.game_num) + .limit(8) + ) for game in last_games: if game.home_score > game.away_score: @@ -879,9 +1019,11 @@ class Player(BaseModel): def get_season(name, num): player = None try: - player = Player.get(fn.Lower(Player.name) == name.lower(), Player.season == num) + player = Player.get( + fn.Lower(Player.name) == name.lower(), Player.season == num + ) except Exception as e: - print(f'**Error** (db_engine player): {e}') + print(f"**Error** (db_engine player): {e}") finally: return player @@ -993,14 +1135,23 @@ class BattingStat(BaseModel): Return: ModelSelect object for season's regular season """ if season == 1: - return BattingStat.select().where((BattingStat.season == 1) & (BattingStat.week < 21))\ + return ( + BattingStat.select() + .where((BattingStat.season == 1) & (BattingStat.week < 21)) .order_by(BattingStat.week) + ) elif season == 2: - return BattingStat.select().where((BattingStat.season == 2) & (BattingStat.week < 19))\ + return ( + BattingStat.select() + .where((BattingStat.season == 2) & (BattingStat.week < 19)) .order_by(BattingStat.week) + ) elif season > 2: - return BattingStat.select().where((BattingStat.season == season) & (BattingStat.week < 23))\ + return ( + BattingStat.select() + .where((BattingStat.season == season) & (BattingStat.week < 23)) .order_by(BattingStat.week) + ) else: return None @@ -1011,137 +1162,176 @@ class BattingStat(BaseModel): Return: ModelSelect object for season's post season """ if season == 1: - return BattingStat.select().where((BattingStat.season == 1) & (BattingStat.week >= 21)) + return BattingStat.select().where( + (BattingStat.season == 1) & (BattingStat.week >= 21) + ) elif season == 2: - return BattingStat.select().where((BattingStat.season == 2) & (BattingStat.week >= 19)) + return BattingStat.select().where( + (BattingStat.season == 2) & (BattingStat.week >= 19) + ) elif season > 2: - return BattingStat.select().where((BattingStat.season == season) & (BattingStat.week >= 23)) + return BattingStat.select().where( + (BattingStat.season == season) & (BattingStat.week >= 23) + ) else: return None @staticmethod def team_season(team, season): - b_stats = BattingStat.regular_season(season).join(Player).select( - fn.SUM(BattingStat.pa).alias('pas'), - fn.SUM(BattingStat.ab).alias('abs'), - fn.SUM(BattingStat.run).alias('runs'), - fn.SUM(BattingStat.hit).alias('hits'), - fn.SUM(BattingStat.rbi).alias('rbis'), - fn.SUM(BattingStat.double).alias('doubles'), - fn.SUM(BattingStat.triple).alias('triples'), - fn.SUM(BattingStat.hr).alias('hrs'), - fn.SUM(BattingStat.bb).alias('bbs'), - fn.SUM(BattingStat.so).alias('sos'), - fn.SUM(BattingStat.hbp).alias('hbps'), - fn.SUM(BattingStat.sac).alias('sacs'), - fn.SUM(BattingStat.ibb).alias('ibbs'), - fn.SUM(BattingStat.gidp).alias('gidps'), - fn.SUM(BattingStat.sb).alias('sbs'), - fn.SUM(BattingStat.cs).alias('css'), - fn.SUM(BattingStat.bphr).alias('bphr'), - fn.SUM(BattingStat.bpfo).alias('bpfo'), - fn.SUM(BattingStat.bp1b).alias('bp1b'), - fn.SUM(BattingStat.bplo).alias('bplo'), - # fn.SUM(BattingStat.xba).alias('xba'), - # fn.SUM(BattingStat.xbt).alias('xbt'), - fn.COUNT(BattingStat.game).alias('games'), - ).where(BattingStat.team == team) + b_stats = ( + BattingStat.regular_season(season) + .join(Player) + .select( + fn.SUM(BattingStat.pa).alias("pas"), + fn.SUM(BattingStat.ab).alias("abs"), + fn.SUM(BattingStat.run).alias("runs"), + fn.SUM(BattingStat.hit).alias("hits"), + fn.SUM(BattingStat.rbi).alias("rbis"), + fn.SUM(BattingStat.double).alias("doubles"), + fn.SUM(BattingStat.triple).alias("triples"), + fn.SUM(BattingStat.hr).alias("hrs"), + fn.SUM(BattingStat.bb).alias("bbs"), + fn.SUM(BattingStat.so).alias("sos"), + fn.SUM(BattingStat.hbp).alias("hbps"), + fn.SUM(BattingStat.sac).alias("sacs"), + fn.SUM(BattingStat.ibb).alias("ibbs"), + fn.SUM(BattingStat.gidp).alias("gidps"), + fn.SUM(BattingStat.sb).alias("sbs"), + fn.SUM(BattingStat.cs).alias("css"), + fn.SUM(BattingStat.bphr).alias("bphr"), + fn.SUM(BattingStat.bpfo).alias("bpfo"), + fn.SUM(BattingStat.bp1b).alias("bp1b"), + fn.SUM(BattingStat.bplo).alias("bplo"), + # fn.SUM(BattingStat.xba).alias('xba'), + # fn.SUM(BattingStat.xbt).alias('xbt'), + fn.COUNT(BattingStat.game).alias("games"), + ) + .where(BattingStat.team == team) + ) total = { - 'game': b_stats[0].games if b_stats[0].games else 0, - 'pa': b_stats[0].pas if b_stats[0].pas else 0, - 'ab': b_stats[0].abs if b_stats[0].abs else 0, - 'run': b_stats[0].runs if b_stats[0].runs else 0, - 'hit': b_stats[0].hits if b_stats[0].hits else 0, - 'rbi': b_stats[0].rbis if b_stats[0].rbis else 0, - 'double': b_stats[0].doubles if b_stats[0].doubles else 0, - 'triple': b_stats[0].triples if b_stats[0].triples else 0, - 'hr': b_stats[0].hrs if b_stats[0].hrs else 0, - 'bb': b_stats[0].bbs if b_stats[0].bbs else 0, - 'so': b_stats[0].sos if b_stats[0].sos else 0, - 'hbp': b_stats[0].hbps if b_stats[0].hbps else 0, - 'sac': b_stats[0].sacs if b_stats[0].sacs else 0, - 'ibb': b_stats[0].ibbs if b_stats[0].ibbs else 0, - 'gidp': b_stats[0].gidps if b_stats[0].gidps else 0, - 'sb': b_stats[0].sbs if b_stats[0].sbs else 0, - 'cs': b_stats[0].css if b_stats[0].css else 0, - 'ba': 0, - 'obp': 0, - 'slg': 0, - 'woba': 0, - 'kpct': 0, - 'bphr': b_stats[0].bphr if b_stats[0].bphr else 0, - 'bpfo': b_stats[0].bpfo if b_stats[0].bpfo else 0, - 'bp1b': b_stats[0].bp1b if b_stats[0].bp1b else 0, - 'bplo': b_stats[0].bplo if b_stats[0].bplo else 0, + "game": b_stats[0].games if b_stats[0].games else 0, + "pa": b_stats[0].pas if b_stats[0].pas else 0, + "ab": b_stats[0].abs if b_stats[0].abs else 0, + "run": b_stats[0].runs if b_stats[0].runs else 0, + "hit": b_stats[0].hits if b_stats[0].hits else 0, + "rbi": b_stats[0].rbis if b_stats[0].rbis else 0, + "double": b_stats[0].doubles if b_stats[0].doubles else 0, + "triple": b_stats[0].triples if b_stats[0].triples else 0, + "hr": b_stats[0].hrs if b_stats[0].hrs else 0, + "bb": b_stats[0].bbs if b_stats[0].bbs else 0, + "so": b_stats[0].sos if b_stats[0].sos else 0, + "hbp": b_stats[0].hbps if b_stats[0].hbps else 0, + "sac": b_stats[0].sacs if b_stats[0].sacs else 0, + "ibb": b_stats[0].ibbs if b_stats[0].ibbs else 0, + "gidp": b_stats[0].gidps if b_stats[0].gidps else 0, + "sb": b_stats[0].sbs if b_stats[0].sbs else 0, + "cs": b_stats[0].css if b_stats[0].css else 0, + "ba": 0, + "obp": 0, + "slg": 0, + "woba": 0, + "kpct": 0, + "bphr": b_stats[0].bphr if b_stats[0].bphr else 0, + "bpfo": b_stats[0].bpfo if b_stats[0].bpfo else 0, + "bp1b": b_stats[0].bp1b if b_stats[0].bp1b else 0, + "bplo": b_stats[0].bplo if b_stats[0].bplo else 0, # 'xba': b_stats[0].xba if b_stats[0].xba else 0, # 'xbt': b_stats[0].xbt if b_stats[0].xbt else 0, } if b_stats[0].abs: - total['ba'] = b_stats[0].hits / b_stats[0].abs + total["ba"] = b_stats[0].hits / b_stats[0].abs - total['obp'] = ( - (b_stats[0].bbs + b_stats[0].hits + b_stats[0].hbps + b_stats[0].ibbs) / b_stats[0].pas + total["obp"] = ( + b_stats[0].bbs + b_stats[0].hits + b_stats[0].hbps + b_stats[0].ibbs + ) / b_stats[0].pas + + total["slg"] = ( + (b_stats[0].hrs * 4) + + (b_stats[0].triples * 3) + + (b_stats[0].doubles * 2) + + ( + b_stats[0].hits + - b_stats[0].hrs + - b_stats[0].triples + - b_stats[0].doubles + ) + ) / b_stats[0].abs + + total["woba"] = ( + (b_stats[0].bbs * 0.69) + + (b_stats[0].hbps * 0.722) + + (b_stats[0].doubles * 1.271) + + (b_stats[0].triples * 1.616) + + (b_stats[0].hrs * 2.101) + + ( + ( + b_stats[0].hits + - b_stats[0].hrs + - b_stats[0].triples + - b_stats[0].doubles + ) + * 0.888 + ) + ) / (b_stats[0].pas - b_stats[0].ibbs) + + total["kpct"] = (total["so"] * 100) / total["ab"] + + total_innings = ( + PitchingStat.regular_season(season) + .join(Player) + .select( + fn.SUM(PitchingStat.ip).alias("ips"), + ) + .where(PitchingStat.player.team == team) ) - - total['slg'] = ( - ((b_stats[0].hrs * 4) + (b_stats[0].triples * 3) + (b_stats[0].doubles * 2) + - (b_stats[0].hits - b_stats[0].hrs - b_stats[0].triples - b_stats[0].doubles)) / b_stats[0].abs - ) - - total['woba'] = ( - ((b_stats[0].bbs * .69) + (b_stats[0].hbps * .722) + (b_stats[0].doubles * 1.271) + - (b_stats[0].triples * 1.616) + (b_stats[0].hrs * 2.101) + - ((b_stats[0].hits - b_stats[0].hrs - b_stats[0].triples - b_stats[0].doubles) * .888)) / - (b_stats[0].pas - b_stats[0].ibbs) - ) - - total['kpct'] = (total['so'] * 100) / total['ab'] - - total_innings = PitchingStat.regular_season(season).join(Player).select( - fn.SUM(PitchingStat.ip).alias('ips'), - ).where(PitchingStat.player.team == team) - total['rper9'] = (total['run'] * 9) / total_innings[0].ips + total["rper9"] = (total["run"] * 9) / total_innings[0].ips return total @staticmethod def team_fielding_season(team, season): - f_stats = BattingStat.regular_season(season).select( - fn.SUM(BattingStat.xch).alias('xchs'), - fn.SUM(BattingStat.xhit).alias('xhits'), - fn.SUM(BattingStat.error).alias('errors'), - # fn.SUM(BattingStat.roba).alias('roba'), - # fn.SUM(BattingStat.robs).alias('robs'), - # fn.SUM(BattingStat.raa).alias('raa'), - # fn.SUM(BattingStat.rto).alias('rto'), - fn.SUM(BattingStat.pb).alias('pbs'), - fn.SUM(BattingStat.sbc).alias('sbas'), - fn.SUM(BattingStat.csc).alias('cscs'), - fn.COUNT(BattingStat.game).alias('games'), - ).where(BattingStat.team == team) + f_stats = ( + BattingStat.regular_season(season) + .select( + fn.SUM(BattingStat.xch).alias("xchs"), + fn.SUM(BattingStat.xhit).alias("xhits"), + fn.SUM(BattingStat.error).alias("errors"), + # fn.SUM(BattingStat.roba).alias('roba'), + # fn.SUM(BattingStat.robs).alias('robs'), + # fn.SUM(BattingStat.raa).alias('raa'), + # fn.SUM(BattingStat.rto).alias('rto'), + fn.SUM(BattingStat.pb).alias("pbs"), + fn.SUM(BattingStat.sbc).alias("sbas"), + fn.SUM(BattingStat.csc).alias("cscs"), + fn.COUNT(BattingStat.game).alias("games"), + ) + .where(BattingStat.team == team) + ) total = { - 'game': f_stats[0].games if f_stats[0].games else 0, - 'xch': f_stats[0].xchs if f_stats[0].xchs else 0, - 'xhit': f_stats[0].xhits if f_stats[0].xhits else 0, - 'error': f_stats[0].errors if f_stats[0].errors else 0, + "game": f_stats[0].games if f_stats[0].games else 0, + "xch": f_stats[0].xchs if f_stats[0].xchs else 0, + "xhit": f_stats[0].xhits if f_stats[0].xhits else 0, + "error": f_stats[0].errors if f_stats[0].errors else 0, # 'roba': f_stats[0].roba if f_stats[0].roba else 0, # 'robs': f_stats[0].robs if f_stats[0].robs else 0, # 'raa': f_stats[0].raa if f_stats[0].raa else 0, # 'rto': f_stats[0].rto if f_stats[0].rto else 0, - 'pb': f_stats[0].pbs if f_stats[0].pbs else 0, - 'sbc': f_stats[0].sbas if f_stats[0].sbas else 0, - 'csc': f_stats[0].cscs if f_stats[0].cscs else 0, - 'wfpct': 0, - 'cspct': 0, + "pb": f_stats[0].pbs if f_stats[0].pbs else 0, + "sbc": f_stats[0].sbas if f_stats[0].sbas else 0, + "csc": f_stats[0].cscs if f_stats[0].cscs else 0, + "wfpct": 0, + "cspct": 0, } - if total['xch'] > 0: - total['wfpct'] = (total['xch'] - (total['error'] * .5) - (total['xhit'] * .75)) / (total['xch']) - if total['sbc'] > 0: - total['cspct'] = (total['csc'] / total['sbc']) * 100 + if total["xch"] > 0: + total["wfpct"] = ( + total["xch"] - (total["error"] * 0.5) - (total["xhit"] * 0.75) + ) / (total["xch"]) + if total["sbc"] > 0: + total["cspct"] = (total["csc"] / total["sbc"]) * 100 return total @@ -1178,87 +1368,109 @@ class PitchingStat(BaseModel): @staticmethod def regular_season(season): if season == 1: - return PitchingStat.select().where((PitchingStat.season == 1) & (PitchingStat.week < 21))\ + return ( + PitchingStat.select() + .where((PitchingStat.season == 1) & (PitchingStat.week < 21)) .order_by(PitchingStat.week) + ) elif season == 2: - return PitchingStat.select().where((PitchingStat.season == 2) & (PitchingStat.week < 19))\ + return ( + PitchingStat.select() + .where((PitchingStat.season == 2) & (PitchingStat.week < 19)) .order_by(PitchingStat.week) + ) elif season > 2: - return PitchingStat.select().where((PitchingStat.season == season) & (PitchingStat.week < 23))\ + return ( + PitchingStat.select() + .where((PitchingStat.season == season) & (PitchingStat.week < 23)) .order_by(PitchingStat.week) + ) else: return None @staticmethod def post_season(season): if season == 1: - return PitchingStat.select().where((PitchingStat.season == 1) & (PitchingStat.week >= 21))\ + return ( + PitchingStat.select() + .where((PitchingStat.season == 1) & (PitchingStat.week >= 21)) .order_by(PitchingStat.week) + ) elif season == 2: - return PitchingStat.select().where((PitchingStat.season == 2) & (PitchingStat.week >= 19))\ + return ( + PitchingStat.select() + .where((PitchingStat.season == 2) & (PitchingStat.week >= 19)) .order_by(PitchingStat.week) + ) elif season > 2: - return PitchingStat.select().where((PitchingStat.season == season) & (PitchingStat.week >= 23))\ + return ( + PitchingStat.select() + .where((PitchingStat.season == season) & (PitchingStat.week >= 23)) .order_by(PitchingStat.week) + ) else: return None @staticmethod def team_season(team, season): - p_stats = PitchingStat.regular_season(season).select( - fn.SUM(PitchingStat.ip).alias('ips'), - fn.SUM(PitchingStat.hit).alias('hits'), - fn.SUM(PitchingStat.run).alias('runs'), - fn.SUM(PitchingStat.erun).alias('eruns'), - fn.SUM(PitchingStat.so).alias('sos'), - fn.SUM(PitchingStat.bb).alias('bbs'), - fn.SUM(PitchingStat.hbp).alias('hbps'), - fn.SUM(PitchingStat.wp).alias('wps'), - fn.SUM(PitchingStat.ir).alias('ir'), - fn.SUM(PitchingStat.irs).alias('irs'), - fn.SUM(PitchingStat.balk).alias('balks'), - fn.SUM(PitchingStat.hr).alias('hrs'), - fn.COUNT(PitchingStat.game).alias('games'), - fn.SUM(PitchingStat.gs).alias('gss'), - fn.SUM(PitchingStat.win).alias('wins'), - fn.SUM(PitchingStat.loss).alias('losses'), - fn.SUM(PitchingStat.hold).alias('holds'), - fn.SUM(PitchingStat.sv).alias('saves'), - fn.SUM(PitchingStat.bsv).alias('bsaves'), - ).where(PitchingStat.team == team) + p_stats = ( + PitchingStat.regular_season(season) + .select( + fn.SUM(PitchingStat.ip).alias("ips"), + fn.SUM(PitchingStat.hit).alias("hits"), + fn.SUM(PitchingStat.run).alias("runs"), + fn.SUM(PitchingStat.erun).alias("eruns"), + fn.SUM(PitchingStat.so).alias("sos"), + fn.SUM(PitchingStat.bb).alias("bbs"), + fn.SUM(PitchingStat.hbp).alias("hbps"), + fn.SUM(PitchingStat.wp).alias("wps"), + fn.SUM(PitchingStat.ir).alias("ir"), + fn.SUM(PitchingStat.irs).alias("irs"), + fn.SUM(PitchingStat.balk).alias("balks"), + fn.SUM(PitchingStat.hr).alias("hrs"), + fn.COUNT(PitchingStat.game).alias("games"), + fn.SUM(PitchingStat.gs).alias("gss"), + fn.SUM(PitchingStat.win).alias("wins"), + fn.SUM(PitchingStat.loss).alias("losses"), + fn.SUM(PitchingStat.hold).alias("holds"), + fn.SUM(PitchingStat.sv).alias("saves"), + fn.SUM(PitchingStat.bsv).alias("bsaves"), + ) + .where(PitchingStat.team == team) + ) total = { - 'ip': p_stats[0].ips if p_stats[0].ips else 0, - 'hit': int(p_stats[0].hits) if p_stats[0].hits else 0, - 'run': int(p_stats[0].runs) if p_stats[0].runs else 0, - 'erun': int(p_stats[0].eruns) if p_stats[0].eruns else 0, - 'so': int(p_stats[0].sos) if p_stats[0].sos else 0, - 'bb': int(p_stats[0].bbs) if p_stats[0].bbs else 0, - 'hbp': int(p_stats[0].hbps) if p_stats[0].hbps else 0, - 'wp': int(p_stats[0].wps) if p_stats[0].wps else 0, - 'balk': int(p_stats[0].balks) if p_stats[0].balks else 0, - 'hr': int(p_stats[0].hrs) if p_stats[0].hrs else 0, - 'game': int(p_stats[0].games) if p_stats[0].games else 0, - 'gs': int(p_stats[0].gss) if p_stats[0].gss else 0, - 'win': int(p_stats[0].wins) if p_stats[0].wins else 0, - 'loss': int(p_stats[0].losses) if p_stats[0].losses else 0, - 'hold': int(p_stats[0].holds) if p_stats[0].holds else 0, - 'sv': int(p_stats[0].saves) if p_stats[0].saves else 0, - 'bsv': int(p_stats[0].bsaves) if p_stats[0].bsaves else 0, - 'wl%': 0, - 'era': 0, - 'whip': 0, - 'ir': int(p_stats[0].ir) if p_stats[0].ir else 0, - 'irs': int(p_stats[0].irs) if p_stats[0].irs else 0, + "ip": p_stats[0].ips if p_stats[0].ips else 0, + "hit": int(p_stats[0].hits) if p_stats[0].hits else 0, + "run": int(p_stats[0].runs) if p_stats[0].runs else 0, + "erun": int(p_stats[0].eruns) if p_stats[0].eruns else 0, + "so": int(p_stats[0].sos) if p_stats[0].sos else 0, + "bb": int(p_stats[0].bbs) if p_stats[0].bbs else 0, + "hbp": int(p_stats[0].hbps) if p_stats[0].hbps else 0, + "wp": int(p_stats[0].wps) if p_stats[0].wps else 0, + "balk": int(p_stats[0].balks) if p_stats[0].balks else 0, + "hr": int(p_stats[0].hrs) if p_stats[0].hrs else 0, + "game": int(p_stats[0].games) if p_stats[0].games else 0, + "gs": int(p_stats[0].gss) if p_stats[0].gss else 0, + "win": int(p_stats[0].wins) if p_stats[0].wins else 0, + "loss": int(p_stats[0].losses) if p_stats[0].losses else 0, + "hold": int(p_stats[0].holds) if p_stats[0].holds else 0, + "sv": int(p_stats[0].saves) if p_stats[0].saves else 0, + "bsv": int(p_stats[0].bsaves) if p_stats[0].bsaves else 0, + "wl%": 0, + "era": 0, + "whip": 0, + "ir": int(p_stats[0].ir) if p_stats[0].ir else 0, + "irs": int(p_stats[0].irs) if p_stats[0].irs else 0, } - if total['ip']: - total['era'] = (total['erun'] * 9) / total['ip'] + if total["ip"]: + total["era"] = (total["erun"] * 9) / total["ip"] - total['whip'] = (total['bb'] + total['hit']) / total['ip'] + total["whip"] = (total["bb"] + total["hit"]) / total["ip"] - if total['win'] + total['loss'] > 0: - total['wl%'] = total['win'] / (total['win'] + total['loss']) + if total["win"] + total["loss"] > 0: + total["wl%"] = total["win"] / (total["win"] + total["loss"]) return total @@ -1278,7 +1490,7 @@ class Standings(BaseModel): away_losses = IntegerField(default=0) last8_wins = IntegerField(default=0) last8_losses = IntegerField(default=0) - streak_wl = CharField(default='w') + streak_wl = CharField(default="w") streak_num = IntegerField(default=0) one_run_wins = IntegerField(default=0) one_run_losses = IntegerField(default=0) @@ -1319,9 +1531,15 @@ class Standings(BaseModel): # Iterate through each individual result # for game in Result.select_season(season).where(Result.week <= 22): - for game in StratGame.select().where( - (StratGame.season == season) & (StratGame.week <= 18) & (StratGame.game_num.is_null(False)) - ).order_by(StratGame.week, StratGame.game_num): + for game in ( + StratGame.select() + .where( + (StratGame.season == season) + & (StratGame.week <= 18) + & (StratGame.game_num.is_null(False)) + ) + .order_by(StratGame.week, StratGame.game_num) + ): # tally win and loss for each standings object game.update_standings() @@ -1336,7 +1554,7 @@ class Standings(BaseModel): # Pull each league (filter by not null wc_gb) and sort by win pct # # For one league: - Division.sort_wildcard(season, 'SBa') + Division.sort_wildcard(season, "SBa") # For two leagues # Division.sort_wildcard(season, 'AL') @@ -1377,8 +1595,12 @@ class BattingCareer(BaseModel): line.delete_instance() # For each seasonstat, find career or create new and increment - for this_season in BattingSeason.select().where(BattingSeason.season_type == 'Regular'): - this_career = BattingCareer.get_or_none(BattingCareer.name == this_season.player.name) + for this_season in BattingSeason.select().where( + BattingSeason.season_type == "Regular" + ): + this_career = BattingCareer.get_or_none( + BattingCareer.name == this_season.player.name + ) if not this_career: this_career = BattingCareer(name=this_season.player.name) this_career.save() @@ -1438,8 +1660,12 @@ class PitchingCareer(BaseModel): line.delete_instance() # For each seasonstat, find career or create new and increment - for this_season in PitchingSeason.select().where(PitchingSeason.season_type == 'Regular'): - this_career = PitchingCareer.get_or_none(PitchingCareer.name == this_season.player.name) + for this_season in PitchingSeason.select().where( + PitchingSeason.season_type == "Regular" + ): + this_career = PitchingCareer.get_or_none( + PitchingCareer.name == this_season.player.name + ) if not this_career: this_career = PitchingCareer(name=this_season.player.name) this_career.save() @@ -1488,12 +1714,17 @@ class FieldingCareer(BaseModel): line.delete_instance() # For each seasonstat, find career or create new and increment - for this_season in FieldingSeason.select().where(FieldingSeason.season_type == 'Regular'): + for this_season in FieldingSeason.select().where( + FieldingSeason.season_type == "Regular" + ): this_career = FieldingCareer.get_or_none( - FieldingCareer.name == this_season.player.name, FieldingCareer.pos == this_season.pos + FieldingCareer.name == this_season.player.name, + FieldingCareer.pos == this_season.pos, ) if not this_career: - this_career = FieldingCareer(name=this_season.player.name, pos=this_season.pos) + this_career = FieldingCareer( + name=this_season.player.name, pos=this_season.pos + ) this_career.save() this_career.xch += this_season.xch @@ -1512,7 +1743,7 @@ class FieldingCareer(BaseModel): class BattingSeason(BaseModel): player = ForeignKeyField(Player) season = IntegerField() - season_type = CharField(default='Regular') + season_type = CharField(default="Regular") career = ForeignKeyField(BattingCareer, null=True) pa = FloatField(default=0) ab = FloatField(default=0) @@ -1608,10 +1839,14 @@ class BattingSeason(BaseModel): self.xbt = 0 self.game = 0 - if self.season_type == 'Regular': - all_stats = BattingStat.regular_season(self.season).where(BattingStat.player == self.player) + if self.season_type == "Regular": + all_stats = BattingStat.regular_season(self.season).where( + BattingStat.player == self.player + ) else: - all_stats = BattingStat.post_season(self.season).where(BattingStat.player == self.player) + all_stats = BattingStat.post_season(self.season).where( + BattingStat.player == self.player + ) for line in all_stats: self.pa += line.pa self.ab += line.ab @@ -1644,7 +1879,7 @@ class BattingSeason(BaseModel): class PitchingSeason(BaseModel): player = ForeignKeyField(Player) season = IntegerField() - season_type = CharField(default='Regular') + season_type = CharField(default="Regular") career = ForeignKeyField(PitchingCareer, null=True) ip = FloatField(default=0) hit = FloatField(default=0) @@ -1733,10 +1968,14 @@ class PitchingSeason(BaseModel): self.bsv = 0 self.game = 0 - if self.season_type == 'Regular': - all_stats = PitchingStat.regular_season(self.season).where(PitchingStat.player == self.player) + if self.season_type == "Regular": + all_stats = PitchingStat.regular_season(self.season).where( + PitchingStat.player == self.player + ) else: - all_stats = PitchingStat.post_season(self.season).where(PitchingStat.player == self.player) + all_stats = PitchingStat.post_season(self.season).where( + PitchingStat.player == self.player + ) for line in all_stats: self.ip += line.ip self.hit += line.hit @@ -1765,7 +2004,7 @@ class PitchingSeason(BaseModel): class FieldingSeason(BaseModel): player = ForeignKeyField(Player) season = IntegerField() - season_type = CharField(default='Regular') + season_type = CharField(default="Regular") pos = CharField() career = ForeignKeyField(FieldingCareer, null=True) xch = IntegerField(default=0) @@ -1831,7 +2070,7 @@ class FieldingSeason(BaseModel): self.rto = 0 self.game = 0 - if self.season_type == 'Regular': + if self.season_type == "Regular": all_stats = BattingStat.regular_season(self.season).where( (BattingStat.player == self.player) & (BattingStat.pos == self.pos) ) @@ -1871,7 +2110,9 @@ class DraftPick(BaseModel): @staticmethod def get_season(team, rd, num): - return DraftPick.get(DraftPick.season == num, DraftPick.origowner == team, DraftPick.round == rd) + return DraftPick.get( + DraftPick.season == num, DraftPick.origowner == team, DraftPick.round == rd + ) class DraftData(BaseModel): @@ -1879,7 +2120,7 @@ class DraftData(BaseModel): timer = BooleanField() pick_deadline = DateTimeField(null=True) result_channel = CharField(max_length=20, null=True) # Discord channel ID as string - ping_channel = CharField(max_length=20, null=True) # Discord channel ID as string + ping_channel = CharField(max_length=20, null=True) # Discord channel ID as string pick_minutes = IntegerField(null=True) @@ -1896,7 +2137,7 @@ class Award(BaseModel): class DiceRoll(BaseModel): season = IntegerField(default=12) # Will be updated to current season when needed - week = IntegerField(default=1) # Will be updated to current week when needed + week = IntegerField(default=1) # Will be updated to current week when needed team = ForeignKeyField(Team, null=True) roller = CharField(max_length=20) dsix = IntegerField(null=True) @@ -1933,7 +2174,7 @@ class StratGame(BaseModel): season = IntegerField() week = IntegerField() game_num = IntegerField(null=True) - season_type = CharField(default='regular') + season_type = CharField(default="regular") away_team = ForeignKeyField(Team) home_team = ForeignKeyField(Team) away_score = IntegerField(null=True) @@ -1957,16 +2198,16 @@ class StratGame(BaseModel): away_stan.away_losses += 1 # - update streak wl and num - if home_stan.streak_wl == 'w': + if home_stan.streak_wl == "w": home_stan.streak_num += 1 else: - home_stan.streak_wl = 'w' + home_stan.streak_wl = "w" home_stan.streak_num = 1 - if away_stan.streak_wl == 'l': + if away_stan.streak_wl == "l": away_stan.streak_num += 1 else: - away_stan.streak_wl = 'l' + away_stan.streak_wl = "l" away_stan.streak_num = 1 # - if 1-run, tally accordingly @@ -1992,22 +2233,22 @@ class StratGame(BaseModel): # Used for one league with 4 divisions # - update record v division (check opponent's division) - if away_div.division_abbrev == 'TC': + if away_div.division_abbrev == "TC": home_stan.div1_wins += 1 - elif away_div.division_abbrev == 'ETSOS': + elif away_div.division_abbrev == "ETSOS": home_stan.div2_wins += 1 - elif away_div.division_abbrev == 'APL': + elif away_div.division_abbrev == "APL": home_stan.div3_wins += 1 - elif away_div.division_abbrev == 'BBC': + elif away_div.division_abbrev == "BBC": home_stan.div4_wins += 1 - if home_div.division_abbrev == 'TC': + if home_div.division_abbrev == "TC": away_stan.div1_losses += 1 - elif home_div.division_abbrev == 'ETSOS': + elif home_div.division_abbrev == "ETSOS": away_stan.div2_losses += 1 - elif home_div.division_abbrev == 'APL': + elif home_div.division_abbrev == "APL": away_stan.div3_losses += 1 - elif home_div.division_abbrev == 'BBC': + elif home_div.division_abbrev == "BBC": away_stan.div4_losses += 1 # Used for two league plus divisions @@ -2045,16 +2286,16 @@ class StratGame(BaseModel): away_stan.away_wins += 1 # - update streak wl and num - if home_stan.streak_wl == 'l': + if home_stan.streak_wl == "l": home_stan.streak_num += 1 else: - home_stan.streak_wl = 'l' + home_stan.streak_wl = "l" home_stan.streak_num = 1 - if away_stan.streak_wl == 'w': + if away_stan.streak_wl == "w": away_stan.streak_num += 1 else: - away_stan.streak_wl = 'w' + away_stan.streak_wl = "w" away_stan.streak_num = 1 # - if 1-run, tally accordingly @@ -2080,22 +2321,22 @@ class StratGame(BaseModel): # Used for one league with 4 divisions # - update record v division (check opponent's division) - if away_div.division_abbrev == 'TC': + if away_div.division_abbrev == "TC": home_stan.div1_losses += 1 - elif away_div.division_abbrev == 'ETSOS': + elif away_div.division_abbrev == "ETSOS": home_stan.div2_losses += 1 - elif away_div.division_abbrev == 'APL': + elif away_div.division_abbrev == "APL": home_stan.div3_losses += 1 - elif away_div.division_abbrev == 'BBC': + elif away_div.division_abbrev == "BBC": home_stan.div4_losses += 1 - if home_div.division_abbrev == 'TC': + if home_div.division_abbrev == "TC": away_stan.div1_wins += 1 - elif home_div.division_abbrev == 'ETSOS': + elif home_div.division_abbrev == "ETSOS": away_stan.div2_wins += 1 - elif home_div.division_abbrev == 'APL': + elif home_div.division_abbrev == "APL": away_stan.div3_wins += 1 - elif home_div.division_abbrev == 'BBC': + elif home_div.division_abbrev == "BBC": away_stan.div4_wins += 1 # Used for two league plus divisions @@ -2224,6 +2465,7 @@ class Decision(BaseModel): class CustomCommandCreator(BaseModel): """Model for custom command creators.""" + discord_id = CharField(max_length=20, unique=True) # Discord snowflake ID as string username = CharField(max_length=32) display_name = CharField(max_length=32, null=True) @@ -2232,126 +2474,147 @@ class CustomCommandCreator(BaseModel): active_commands = IntegerField(default=0) class Meta: - table_name = 'custom_command_creators' + table_name = "custom_command_creators" class CustomCommand(BaseModel): """Model for custom commands created by users.""" + name = CharField(max_length=32, unique=True) content = TextField() - creator = ForeignKeyField(CustomCommandCreator, backref='commands') - + creator = ForeignKeyField(CustomCommandCreator, backref="commands") + # Timestamps created_at = DateTimeField() updated_at = DateTimeField(null=True) last_used = DateTimeField(null=True) - + # Usage tracking use_count = IntegerField(default=0) warning_sent = BooleanField(default=False) - + # Metadata is_active = BooleanField(default=True) tags = TextField(null=True) # JSON string for tags list class Meta: - table_name = 'custom_commands' + table_name = "custom_commands" @staticmethod def get_by_name(name: str): """Get a custom command by name (case-insensitive).""" return CustomCommand.get_or_none(fn.Lower(CustomCommand.name) == name.lower()) - + @staticmethod def search_by_name(partial_name: str, limit: int = 25): """Search commands by partial name match.""" - return (CustomCommand - .select() - .where((CustomCommand.is_active == True) & - (fn.Lower(CustomCommand.name).contains(partial_name.lower()))) - .order_by(CustomCommand.name) - .limit(limit)) - + return ( + CustomCommand.select() + .where( + (CustomCommand.is_active == True) + & (fn.Lower(CustomCommand.name).contains(partial_name.lower())) + ) + .order_by(CustomCommand.name) + .limit(limit) + ) + @staticmethod def get_popular(limit: int = 10): """Get most popular commands by usage.""" - return (CustomCommand - .select() - .where(CustomCommand.is_active == True) - .order_by(CustomCommand.use_count.desc()) - .limit(limit)) - + return ( + CustomCommand.select() + .where(CustomCommand.is_active == True) + .order_by(CustomCommand.use_count.desc()) + .limit(limit) + ) + @staticmethod def get_by_creator(creator_id: int, limit: int = 25, offset: int = 0): """Get commands by creator ID.""" - return (CustomCommand - .select() - .where((CustomCommand.creator == creator_id) & (CustomCommand.is_active == True)) - .order_by(CustomCommand.name) - .limit(limit) - .offset(offset)) - + return ( + CustomCommand.select() + .where( + (CustomCommand.creator == creator_id) + & (CustomCommand.is_active == True) + ) + .order_by(CustomCommand.name) + .limit(limit) + .offset(offset) + ) + @staticmethod def get_unused_commands(days: int = 60): """Get commands that haven't been used in specified days.""" from datetime import datetime, timedelta + cutoff_date = datetime.now() - timedelta(days=days) - return (CustomCommand - .select() - .where((CustomCommand.is_active == True) & - ((CustomCommand.last_used.is_null()) | - (CustomCommand.last_used < cutoff_date)))) - + return CustomCommand.select().where( + (CustomCommand.is_active == True) + & ( + (CustomCommand.last_used.is_null()) + | (CustomCommand.last_used < cutoff_date) + ) + ) + @staticmethod def get_commands_needing_warning(): """Get commands that need deletion warning (60+ days unused, no warning sent).""" from datetime import datetime, timedelta + cutoff_date = datetime.now() - timedelta(days=60) - return (CustomCommand - .select() - .where((CustomCommand.is_active == True) & - (CustomCommand.warning_sent == False) & - ((CustomCommand.last_used.is_null()) | - (CustomCommand.last_used < cutoff_date)))) - + return CustomCommand.select().where( + (CustomCommand.is_active == True) + & (CustomCommand.warning_sent == False) + & ( + (CustomCommand.last_used.is_null()) + | (CustomCommand.last_used < cutoff_date) + ) + ) + @staticmethod def get_commands_eligible_for_deletion(): """Get commands eligible for deletion (90+ days unused).""" from datetime import datetime, timedelta + cutoff_date = datetime.now() - timedelta(days=90) - return (CustomCommand - .select() - .where((CustomCommand.is_active == True) & - ((CustomCommand.last_used.is_null()) | - (CustomCommand.last_used < cutoff_date)))) - + return CustomCommand.select().where( + (CustomCommand.is_active == True) + & ( + (CustomCommand.last_used.is_null()) + | (CustomCommand.last_used < cutoff_date) + ) + ) + def execute(self): """Execute the command and update usage statistics.""" from datetime import datetime + self.last_used = datetime.now() self.use_count += 1 self.warning_sent = False # Reset warning on use self.save() - + def mark_warning_sent(self): """Mark that a deletion warning has been sent.""" self.warning_sent = True self.save() - + def get_tags_list(self): """Parse tags JSON string into a list.""" if not self.tags: return [] try: import json + return json.loads(self.tags) except Exception: return [] - + def set_tags_list(self, tags_list): """Set tags from a list to JSON string.""" if tags_list: import json + self.tags = json.dumps(tags_list) else: self.tags = None @@ -2359,6 +2622,7 @@ class CustomCommand(BaseModel): class HelpCommand(BaseModel): """Model for admin-created help topics.""" + name = CharField(max_length=32, unique=True) title = CharField(max_length=200) content = TextField() @@ -2368,7 +2632,9 @@ class HelpCommand(BaseModel): created_by_discord_id = CharField(max_length=20) # Discord snowflake ID as string created_at = DateTimeField() updated_at = DateTimeField(null=True) - last_modified_by = CharField(max_length=20, null=True) # Discord snowflake ID as string + last_modified_by = CharField( + max_length=20, null=True + ) # Discord snowflake ID as string # Status and metrics is_active = BooleanField(default=True) @@ -2376,7 +2642,7 @@ class HelpCommand(BaseModel): display_order = IntegerField(default=0) class Meta: - table_name = 'help_commands' + table_name = "help_commands" @staticmethod def get_by_name(name: str, include_inactive: bool = False): @@ -2389,17 +2655,22 @@ class HelpCommand(BaseModel): @staticmethod def search_by_name(partial_name: str, limit: int = 25): """Search help topics by partial name match.""" - return (HelpCommand - .select() - .where((HelpCommand.is_active == True) & - (fn.Lower(HelpCommand.name).contains(partial_name.lower()))) - .order_by(HelpCommand.display_order, HelpCommand.name) - .limit(limit)) + return ( + HelpCommand.select() + .where( + (HelpCommand.is_active == True) + & (fn.Lower(HelpCommand.name).contains(partial_name.lower())) + ) + .order_by(HelpCommand.display_order, HelpCommand.name) + .limit(limit) + ) @staticmethod def get_by_category(category: str, include_inactive: bool = False): """Get help commands by category.""" - query = HelpCommand.select().where(fn.Lower(HelpCommand.category) == category.lower()) + query = HelpCommand.select().where( + fn.Lower(HelpCommand.category) == category.lower() + ) if not include_inactive: query = query.where(HelpCommand.is_active == True) return query.order_by(HelpCommand.display_order, HelpCommand.name) @@ -2407,19 +2678,21 @@ class HelpCommand(BaseModel): @staticmethod def get_all_active(): """Get all active help topics.""" - return (HelpCommand - .select() - .where(HelpCommand.is_active == True) - .order_by(HelpCommand.display_order, HelpCommand.name)) + return ( + HelpCommand.select() + .where(HelpCommand.is_active == True) + .order_by(HelpCommand.display_order, HelpCommand.name) + ) @staticmethod def get_most_viewed(limit: int = 10): """Get most viewed help topics.""" - return (HelpCommand - .select() - .where(HelpCommand.is_active == True) - .order_by(HelpCommand.view_count.desc()) - .limit(limit)) + return ( + HelpCommand.select() + .where(HelpCommand.is_active == True) + .order_by(HelpCommand.view_count.desc()) + .limit(limit) + ) def increment_view_count(self): """Increment view count for this help topic.""" @@ -2469,30 +2742,35 @@ class SeasonBattingStatsView(BaseModel): hbp = IntegerField() sac = IntegerField() ibb = IntegerField() - + class Meta: - table_name = 'season_batting_stats_view' + table_name = "season_batting_stats_view" primary_key = False - + @staticmethod def get_by_season(season): - return SeasonBattingStatsView.select().where(SeasonBattingStatsView.season == season) - + return SeasonBattingStatsView.select().where( + SeasonBattingStatsView.season == season + ) + @staticmethod def get_team_stats(season, team_id): - return (SeasonBattingStatsView.select() - .where(SeasonBattingStatsView.season == season, - SeasonBattingStatsView.player_team_id == team_id)) - + return SeasonBattingStatsView.select().where( + SeasonBattingStatsView.season == season, + SeasonBattingStatsView.player_team_id == team_id, + ) + @staticmethod - def get_top_hitters(season, stat='avg', limit=10, desc=True): + def get_top_hitters(season, stat="avg", limit=10, desc=True): """Get top hitters by specified stat (avg, hr, rbi, ops, etc.)""" stat_field = getattr(SeasonBattingStatsView, stat, SeasonBattingStatsView.avg) order_field = stat_field.desc() if desc else stat_field.asc() - return (SeasonBattingStatsView.select() - .where(SeasonBattingStatsView.season == season) - .order_by(order_field) - .limit(limit)) + return ( + SeasonBattingStatsView.select() + .where(SeasonBattingStatsView.season == season) + .order_by(order_field) + .limit(limit) + ) class SeasonPitchingStats(BaseModel): @@ -2558,24 +2836,33 @@ class SeasonPitchingStats(BaseModel): lob_2outs = FloatField() rbipercent = FloatField() re24 = FloatField() - + class Meta: - table_name = 'seasonpitchingstats' - primary_key = CompositeKey('player', 'season') - + table_name = "seasonpitchingstats" + primary_key = CompositeKey("player", "season") + @staticmethod def get_team_stats(season, team_id): - return (SeasonPitchingStats.select() - .where(SeasonPitchingStats.season == season, - SeasonPitchingStats.player_team_id == team_id)) - + return SeasonPitchingStats.select().where( + SeasonPitchingStats.season == season, + SeasonPitchingStats.player_team_id == team_id, + ) + @staticmethod - def get_top_pitchers(season: Optional[int] = None, stat: str = 'era', limit: Optional[int] = 200, - desc: bool = False, team_id: Optional[int] = None, player_id: Optional[int] = None, - sbaplayer_id: Optional[int] = None, min_outs: Optional[int] = None, offset: int = 0): + def get_top_pitchers( + season: Optional[int] = None, + stat: str = "era", + limit: Optional[int] = 200, + desc: bool = False, + team_id: Optional[int] = None, + player_id: Optional[int] = None, + sbaplayer_id: Optional[int] = None, + min_outs: Optional[int] = None, + offset: int = 0, + ): """ Get top pitchers by specified stat with optional filtering. - + Args: season: Season to filter by (None for all seasons) stat: Stat field to sort by (default: era) @@ -2589,9 +2876,9 @@ class SeasonPitchingStats(BaseModel): """ stat_field = getattr(SeasonPitchingStats, stat, SeasonPitchingStats.era) order_field = stat_field.desc() if desc else stat_field.asc() - + query = SeasonPitchingStats.select().order_by(order_field) - + # Apply filters if season is not None: query = query.where(SeasonPitchingStats.season == season) @@ -2603,13 +2890,13 @@ class SeasonPitchingStats(BaseModel): query = query.where(SeasonPitchingStats.sbaplayer_id == sbaplayer_id) if min_outs is not None: query = query.where(SeasonPitchingStats.outs >= min_outs) - + # Apply pagination if offset > 0: query = query.offset(offset) if limit is not None and limit > 0: query = query.limit(limit) - + return query @@ -2649,28 +2936,37 @@ class SeasonBattingStats(BaseModel): ops = FloatField() woba = FloatField() k_pct = FloatField() - + # Running stats sb = IntegerField() cs = IntegerField() - + class Meta: - table_name = 'seasonbattingstats' - primary_key = CompositeKey('player', 'season') - + table_name = "seasonbattingstats" + primary_key = CompositeKey("player", "season") + @staticmethod def get_team_stats(season, team_id): - return (SeasonBattingStats.select() - .where(SeasonBattingStats.season == season, - SeasonBattingStats.player_team_id == team_id)) - + return SeasonBattingStats.select().where( + SeasonBattingStats.season == season, + SeasonBattingStats.player_team_id == team_id, + ) + @staticmethod - def get_top_hitters(season: Optional[int] = None, stat: str = 'woba', limit: Optional[int] = 200, - desc: bool = True, team_id: Optional[int] = None, player_id: Optional[int] = None, - sbaplayer_id: Optional[int] = None, min_pa: Optional[int] = None, offset: int = 0): + def get_top_hitters( + season: Optional[int] = None, + stat: str = "woba", + limit: Optional[int] = 200, + desc: bool = True, + team_id: Optional[int] = None, + player_id: Optional[int] = None, + sbaplayer_id: Optional[int] = None, + min_pa: Optional[int] = None, + offset: int = 0, + ): """ Get top hitters by specified stat with optional filtering. - + Args: season: Season to filter by (None for all seasons) stat: Stat field to sort by (default: woba) @@ -2683,9 +2979,9 @@ class SeasonBattingStats(BaseModel): """ stat_field = getattr(SeasonBattingStats, stat, SeasonBattingStats.woba) order_field = stat_field.desc() if desc else stat_field.asc() - + query = SeasonBattingStats.select().order_by(order_field) - + # Apply filters if season is not None: query = query.where(SeasonBattingStats.season == season) @@ -2697,13 +2993,13 @@ class SeasonBattingStats(BaseModel): query = query.where(SeasonBattingStats.sbaplayer_id == sbaplayer_id) if min_pa is not None: query = query.where(SeasonBattingStats.pa >= min_pa) - + # Apply pagination if offset > 0: query = query.offset(offset) if limit is not None and limit > 0: query = query.limit(limit) - + return query From 3be4f71e22ee8561f677f07c389a3d8a40c962e5 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 26 Mar 2026 23:15:21 -0500 Subject: [PATCH 03/20] fix: move hardcoded Discord webhook URL to environment variable Replace inline webhook URL+token with DISCORD_WEBHOOK_URL env var. Logs a warning and returns False gracefully if the var is unset. The exposed webhook token should be rotated in Discord. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dependencies.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/dependencies.py b/app/dependencies.py index 6441155..fe78a00 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -503,6 +503,9 @@ def update_season_pitching_stats(player_ids, season, db_connection): raise +DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL") + + def send_webhook_message(message: str) -> bool: """ Send a message to Discord via webhook. @@ -513,7 +516,12 @@ def send_webhook_message(message: str) -> bool: Returns: bool: True if successful, False otherwise """ - webhook_url = "https://discord.com/api/webhooks/1408811717424840876/7RXG_D5IqovA3Jwa9YOobUjVcVMuLc6cQyezABcWuXaHo5Fvz1en10M7J43o3OJ3bzGW" + webhook_url = DISCORD_WEBHOOK_URL + if not webhook_url: + logger.warning( + "DISCORD_WEBHOOK_URL env var is not set — skipping webhook message" + ) + return False try: payload = {"content": message} From 1bcde424c6ecfd58781dadd4efecc2082e1d7c36 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 26 Mar 2026 23:23:26 -0500 Subject: [PATCH 04/20] Address PR review feedback for DISCORD_WEBHOOK_URL env var - Add DISCORD_WEBHOOK_URL to docker-compose.yml api service environment block - Add empty placeholder entry in .env for discoverability - Move DISCORD_WEBHOOK_URL constant to the env-var constants section at top of dependencies.py Co-Authored-By: Claude Sonnet 4.6 --- .env | 3 +++ app/dependencies.py | 6 +++--- docker-compose.yml | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 7e04bd4..30dde8f 100644 --- a/.env +++ b/.env @@ -6,6 +6,9 @@ SBA_DB_USER_PASSWORD=your_production_password # SBa API API_TOKEN=Tp3aO3jhYve5NJF1IqOmJTmk +# Integrations +DISCORD_WEBHOOK_URL= + # Universal TZ=America/Chicago LOG_LEVEL=INFO \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py index fe78a00..04ee702 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -22,6 +22,9 @@ logger = logging.getLogger("discord_app") # level=log_level # ) +# Discord integration +DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL") + # Redis configuration REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379")) @@ -503,9 +506,6 @@ def update_season_pitching_stats(player_ids, season, db_connection): raise -DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL") - - def send_webhook_message(message: str) -> bool: """ Send a message to Discord via webhook. diff --git a/docker-compose.yml b/docker-compose.yml index 84f0b68..05a6304 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: - REDIS_HOST=sba_redis - REDIS_PORT=6379 - REDIS_DB=0 + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} depends_on: - postgres - redis From dcaf184ad35b2728335336d6d87b34bffa203194 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 00:02:57 -0500 Subject: [PATCH 05/20] fix: add type annotations to untyped query parameters (#73) Closes #73 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v3/standings.py | 2 +- app/routers_v3/transactions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routers_v3/standings.py b/app/routers_v3/standings.py index f5ef37e..fefc8a4 100644 --- a/app/routers_v3/standings.py +++ b/app/routers_v3/standings.py @@ -81,7 +81,7 @@ async def get_team_standings(team_id: int): @router.patch("/{stan_id}", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def patch_standings( - stan_id, + stan_id: int, wins: Optional[int] = None, losses: Optional[int] = None, token: str = Depends(oauth2_scheme), diff --git a/app/routers_v3/transactions.py b/app/routers_v3/transactions.py index 1880dcc..d292258 100644 --- a/app/routers_v3/transactions.py +++ b/app/routers_v3/transactions.py @@ -36,7 +36,7 @@ class TransactionList(pydantic.BaseModel): @router.get("") @handle_db_errors async def get_transactions( - season, + season: int, team_abbrev: list = Query(default=None), week_start: Optional[int] = 0, week_end: Optional[int] = None, From 75a8fc8505379671e11f57756d7805d94228af39 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 02:02:49 -0500 Subject: [PATCH 06/20] fix: replace deprecated Pydantic .dict() with .model_dump() (#76) Closes #76 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v3/awards.py | 2 +- app/routers_v3/battingstats.py | 6 ++++-- app/routers_v3/current.py | 2 +- app/routers_v3/decisions.py | 2 +- app/routers_v3/divisions.py | 2 +- app/routers_v3/draftlist.py | 2 +- app/routers_v3/draftpicks.py | 4 ++-- app/routers_v3/injuries.py | 2 +- app/routers_v3/keepers.py | 2 +- app/routers_v3/managers.py | 2 +- app/routers_v3/pitchingstats.py | 6 ++++-- app/routers_v3/results.py | 2 +- app/routers_v3/sbaplayers.py | 2 +- app/routers_v3/schedules.py | 2 +- app/routers_v3/stratgame.py | 2 +- app/routers_v3/stratplay/crud.py | 4 ++-- app/routers_v3/transactions.py | 2 +- 17 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/routers_v3/awards.py b/app/routers_v3/awards.py index 575ef42..7f1b24c 100644 --- a/app/routers_v3/awards.py +++ b/app/routers_v3/awards.py @@ -171,7 +171,7 @@ async def post_award(award_list: AwardList, token: str = Depends(oauth2_scheme)) status_code=404, detail=f"Team ID {x.team_id} not found" ) - new_awards.append(x.dict()) + new_awards.append(x.model_dump()) with db.atomic(): for batch in chunked(new_awards, 15): diff --git a/app/routers_v3/battingstats.py b/app/routers_v3/battingstats.py index 49d3fa8..e32d441 100644 --- a/app/routers_v3/battingstats.py +++ b/app/routers_v3/battingstats.py @@ -366,7 +366,9 @@ async def patch_batstats( if BattingStat.get_or_none(BattingStat.id == stat_id) is None: raise HTTPException(status_code=404, detail=f"Stat ID {stat_id} not found") - BattingStat.update(**new_stats.dict()).where(BattingStat.id == stat_id).execute() + BattingStat.update(**new_stats.model_dump()).where( + BattingStat.id == stat_id + ).execute() r_stat = model_to_dict(BattingStat.get_by_id(stat_id)) db.close() return r_stat @@ -404,7 +406,7 @@ async def post_batstats(s_list: BatStatList, token: str = Depends(oauth2_scheme) status_code=404, detail=f"Player ID {x.player_id} not found" ) - all_stats.append(BattingStat(**x.dict())) + all_stats.append(BattingStat(**x.model_dump())) with db.atomic(): for batch in chunked(all_stats, 15): diff --git a/app/routers_v3/current.py b/app/routers_v3/current.py index ba4458f..cde9b9a 100644 --- a/app/routers_v3/current.py +++ b/app/routers_v3/current.py @@ -116,7 +116,7 @@ async def post_current(new_current: CurrentModel, token: str = Depends(oauth2_sc logger.warning(f"patch_current - Bad Token: {token}") raise HTTPException(status_code=401, detail="Unauthorized") - this_current = Current(**new_current.dict()) + this_current = Current(**new_current.model_dump()) if this_current.save(): r_curr = model_to_dict(this_current) diff --git a/app/routers_v3/decisions.py b/app/routers_v3/decisions.py index a59b16f..0bcdeb1 100644 --- a/app/routers_v3/decisions.py +++ b/app/routers_v3/decisions.py @@ -222,7 +222,7 @@ async def post_decisions(dec_list: DecisionList, token: str = Depends(oauth2_sch status_code=404, detail=f"Player ID {x.pitcher_id} not found" ) - new_dec.append(x.dict()) + new_dec.append(x.model_dump()) with db.atomic(): for batch in chunked(new_dec, 10): diff --git a/app/routers_v3/divisions.py b/app/routers_v3/divisions.py index 095662a..1f350f4 100644 --- a/app/routers_v3/divisions.py +++ b/app/routers_v3/divisions.py @@ -117,7 +117,7 @@ async def post_division( logger.warning(f"post_division - Bad Token: {token}") raise HTTPException(status_code=401, detail="Unauthorized") - this_division = Division(**new_division.dict()) + this_division = Division(**new_division.model_dump()) if this_division.save() == 1: r_division = model_to_dict(this_division) diff --git a/app/routers_v3/draftlist.py b/app/routers_v3/draftlist.py index 4de0d7c..909497d 100644 --- a/app/routers_v3/draftlist.py +++ b/app/routers_v3/draftlist.py @@ -93,7 +93,7 @@ async def post_draftlist( DraftList.delete().where(DraftList.team == this_team).execute() for x in draft_list.draft_list: - new_list.append(x.dict()) + new_list.append(x.model_dump()) with db.atomic(): for batch in chunked(new_list, 15): diff --git a/app/routers_v3/draftpicks.py b/app/routers_v3/draftpicks.py index 2214aa3..9e754bc 100644 --- a/app/routers_v3/draftpicks.py +++ b/app/routers_v3/draftpicks.py @@ -151,7 +151,7 @@ async def patch_pick( if DraftPick.get_or_none(DraftPick.id == pick_id) is None: raise HTTPException(status_code=404, detail=f"Pick ID {pick_id} not found") - DraftPick.update(**new_pick.dict()).where(DraftPick.id == pick_id).execute() + DraftPick.update(**new_pick.model_dump()).where(DraftPick.id == pick_id).execute() r_pick = model_to_dict(DraftPick.get_by_id(pick_id)) db.close() return r_pick @@ -176,7 +176,7 @@ async def post_picks(p_list: DraftPickList, token: str = Depends(oauth2_scheme)) detail=f"Pick # {pick.overall} already exists for season {pick.season}", ) - new_picks.append(pick.dict()) + new_picks.append(pick.model_dump()) with db.atomic(): for batch in chunked(new_picks, 15): diff --git a/app/routers_v3/injuries.py b/app/routers_v3/injuries.py index 77984eb..3e7129f 100644 --- a/app/routers_v3/injuries.py +++ b/app/routers_v3/injuries.py @@ -109,7 +109,7 @@ async def post_injury(new_injury: InjuryModel, token: str = Depends(oauth2_schem logger.warning(f"post_injury - Bad Token: {token}") raise HTTPException(status_code=401, detail="Unauthorized") - this_injury = Injury(**new_injury.dict()) + this_injury = Injury(**new_injury.model_dump()) if this_injury.save(): r_injury = model_to_dict(this_injury) diff --git a/app/routers_v3/keepers.py b/app/routers_v3/keepers.py index d0fafcf..ab17525 100644 --- a/app/routers_v3/keepers.py +++ b/app/routers_v3/keepers.py @@ -96,7 +96,7 @@ async def post_keepers(k_list: KeeperList, token: str = Depends(oauth2_scheme)): new_keepers = [] for keeper in k_list.keepers: - new_keepers.append(keeper.dict()) + new_keepers.append(keeper.model_dump()) with db.atomic(): for batch in chunked(new_keepers, 14): diff --git a/app/routers_v3/managers.py b/app/routers_v3/managers.py index 4c0de88..294685f 100644 --- a/app/routers_v3/managers.py +++ b/app/routers_v3/managers.py @@ -140,7 +140,7 @@ async def post_manager(new_manager: ManagerModel, token: str = Depends(oauth2_sc logger.warning(f"post_manager - Bad Token: {token}") raise HTTPException(status_code=401, detail="Unauthorized") - this_manager = Manager(**new_manager.dict()) + this_manager = Manager(**new_manager.model_dump()) if this_manager.save(): r_manager = model_to_dict(this_manager) diff --git a/app/routers_v3/pitchingstats.py b/app/routers_v3/pitchingstats.py index d318013..72ee364 100644 --- a/app/routers_v3/pitchingstats.py +++ b/app/routers_v3/pitchingstats.py @@ -317,7 +317,9 @@ async def patch_pitstats( if PitchingStat.get_or_none(PitchingStat.id == stat_id) is None: raise HTTPException(status_code=404, detail=f"Stat ID {stat_id} not found") - PitchingStat.update(**new_stats.dict()).where(PitchingStat.id == stat_id).execute() + PitchingStat.update(**new_stats.model_dump()).where( + PitchingStat.id == stat_id + ).execute() r_stat = model_to_dict(PitchingStat.get_by_id(stat_id)) db.close() return r_stat @@ -344,7 +346,7 @@ async def post_pitstats(s_list: PitStatList, token: str = Depends(oauth2_scheme) status_code=404, detail=f"Player ID {x.player_id} not found" ) - all_stats.append(PitchingStat(**x.dict())) + all_stats.append(PitchingStat(**x.model_dump())) with db.atomic(): for batch in chunked(all_stats, 15): diff --git a/app/routers_v3/results.py b/app/routers_v3/results.py index 7ba46b8..5441424 100644 --- a/app/routers_v3/results.py +++ b/app/routers_v3/results.py @@ -180,7 +180,7 @@ async def post_results(result_list: ResultList, token: str = Depends(oauth2_sche status_code=404, detail=f"Team ID {x.hometeam_id} not found" ) - new_results.append(x.dict()) + new_results.append(x.model_dump()) with db.atomic(): for batch in chunked(new_results, 15): diff --git a/app/routers_v3/sbaplayers.py b/app/routers_v3/sbaplayers.py index 296e21e..6cd30fa 100644 --- a/app/routers_v3/sbaplayers.py +++ b/app/routers_v3/sbaplayers.py @@ -242,7 +242,7 @@ async def post_one_player(player: SbaPlayerModel, token: str = Depends(oauth2_sc detail=f"{player.first_name} {player.last_name} has a key already in the database", ) - new_player = SbaPlayer(**player.dict()) + new_player = SbaPlayer(**player.model_dump()) saved = new_player.save() if saved == 1: return_val = model_to_dict(new_player) diff --git a/app/routers_v3/schedules.py b/app/routers_v3/schedules.py index afcaabf..b8dbdab 100644 --- a/app/routers_v3/schedules.py +++ b/app/routers_v3/schedules.py @@ -165,7 +165,7 @@ async def post_schedules(sched_list: ScheduleList, token: str = Depends(oauth2_s status_code=404, detail=f"Team ID {x.hometeam_id} not found" ) - new_sched.append(x.dict()) + new_sched.append(x.model_dump()) with db.atomic(): for batch in chunked(new_sched, 15): diff --git a/app/routers_v3/stratgame.py b/app/routers_v3/stratgame.py index ba750a8..b05806b 100644 --- a/app/routers_v3/stratgame.py +++ b/app/routers_v3/stratgame.py @@ -248,7 +248,7 @@ async def post_games(game_list: GameList, token: str = Depends(oauth2_scheme)) - status_code=404, detail=f"Team ID {x.home_team_id} not found" ) - new_games.append(x.dict()) + new_games.append(x.model_dump()) with db.atomic(): for batch in chunked(new_games, 16): diff --git a/app/routers_v3/stratplay/crud.py b/app/routers_v3/stratplay/crud.py index ee56f51..9d76da0 100644 --- a/app/routers_v3/stratplay/crud.py +++ b/app/routers_v3/stratplay/crud.py @@ -40,7 +40,7 @@ async def patch_play( db.close() raise HTTPException(status_code=404, detail=f"Play ID {play_id} not found") - StratPlay.update(**new_play.dict()).where(StratPlay.id == play_id).execute() + StratPlay.update(**new_play.model_dump()).where(StratPlay.id == play_id).execute() r_play = model_to_dict(StratPlay.get_by_id(play_id)) db.close() return r_play @@ -88,7 +88,7 @@ async def post_plays(p_list: PlayList, token: str = Depends(oauth2_scheme)): if this_play.pa == 0: this_play.batter_final = None - new_plays.append(this_play.dict()) + new_plays.append(this_play.model_dump()) with db.atomic(): for batch in chunked(new_plays, 20): diff --git a/app/routers_v3/transactions.py b/app/routers_v3/transactions.py index 1880dcc..de9d2a2 100644 --- a/app/routers_v3/transactions.py +++ b/app/routers_v3/transactions.py @@ -172,7 +172,7 @@ async def post_transactions( status_code=404, detail=f"Player ID {x.player_id} not found" ) - all_moves.append(x.dict()) + all_moves.append(x.model_dump()) with db.atomic(): for batch in chunked(all_moves, 15): From 665f27554619ab461e0efd7d38591bedf98a92f9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 02:32:01 -0500 Subject: [PATCH 07/20] fix: update Docker base image from Python 3.11 to 3.12 (#82) Closes #82 Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9736b26..f1e0f0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use specific version for reproducible builds -FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11 +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.12 # Set Python optimizations ENV PYTHONUNBUFFERED=1 From d8c6ce2a5ea1faa2439d71e6be0f2864df601ed8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 03:02:55 -0500 Subject: [PATCH 08/20] fix: replace row-by-row DELETE with bulk DELETE in career recalculation (#77) Closes #77 Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/db_engine.py b/app/db_engine.py index c1979cf..a88825f 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1372,9 +1372,7 @@ class BattingCareer(BaseModel): @staticmethod def recalculate(): # Wipe existing data - delete_lines = BattingCareer.select() - for line in delete_lines: - line.delete_instance() + BattingCareer.delete().execute() # For each seasonstat, find career or create new and increment for this_season in BattingSeason.select().where(BattingSeason.season_type == 'Regular'): @@ -1433,9 +1431,7 @@ class PitchingCareer(BaseModel): @staticmethod def recalculate(): # Wipe existing data - delete_lines = PitchingCareer.select() - for line in delete_lines: - line.delete_instance() + PitchingCareer.delete().execute() # For each seasonstat, find career or create new and increment for this_season in PitchingSeason.select().where(PitchingSeason.season_type == 'Regular'): @@ -1483,9 +1479,7 @@ class FieldingCareer(BaseModel): @staticmethod def recalculate(): # Wipe existing data - delete_lines = FieldingCareer.select() - for line in delete_lines: - line.delete_instance() + FieldingCareer.delete().execute() # For each seasonstat, find career or create new and increment for this_season in FieldingSeason.select().where(FieldingSeason.season_type == 'Regular'): From eccf4d1441a32be940697f397050a5999583b47f Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 05:34:13 -0500 Subject: [PATCH 09/20] feat: add migration tracking system (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds schema_versions table and migrations.py runner to prevent double-application and missed migrations across dev/prod environments. - migrations/2026-03-27_add_schema_versions_table.sql: creates tracking table - migrations.py: applies pending .sql files in sorted order, records each in schema_versions - .gitignore: untrack migrations.py (was incorrectly ignored as legacy root file) First run on an existing DB will apply all migrations (safe — all use IF NOT EXISTS). Closes #81 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 - migrations.py | 88 +++++++++++++++++++ .../2026-03-27_add_schema_versions_table.sql | 9 ++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 migrations.py create mode 100644 migrations/2026-03-27_add_schema_versions_table.sql diff --git a/.gitignore b/.gitignore index 78f68bb..4f6baa9 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ Include/ pyvenv.cfg db_engine.py main.py -migrations.py db_engine.py sba_master.db db_engine.py diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..b3d6840 --- /dev/null +++ b/migrations.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Apply pending SQL migrations and record them in schema_versions. + +Usage: + python migrations.py + +Connects to PostgreSQL using the same environment variables as the API: + POSTGRES_DB (default: sba_master) + POSTGRES_USER (default: sba_admin) + POSTGRES_PASSWORD (required) + POSTGRES_HOST (default: sba_postgres) + POSTGRES_PORT (default: 5432) + +On first run against an existing database, all migrations will be applied. +All migration files use IF NOT EXISTS guards so re-applying is safe. +""" + +import os +import sys +from pathlib import Path + +import psycopg2 + + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + +_CREATE_SCHEMA_VERSIONS = """ +CREATE TABLE IF NOT EXISTS schema_versions ( + filename VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT NOW() +); +""" + + +def _get_connection(): + password = os.environ.get("POSTGRES_PASSWORD") + if password is None: + raise RuntimeError("POSTGRES_PASSWORD environment variable is not set") + return psycopg2.connect( + dbname=os.environ.get("POSTGRES_DB", "sba_master"), + user=os.environ.get("POSTGRES_USER", "sba_admin"), + password=password, + host=os.environ.get("POSTGRES_HOST", "sba_postgres"), + port=int(os.environ.get("POSTGRES_PORT", "5432")), + ) + + +def main(): + conn = _get_connection() + try: + with conn: + with conn.cursor() as cur: + cur.execute(_CREATE_SCHEMA_VERSIONS) + + with conn.cursor() as cur: + cur.execute("SELECT filename FROM schema_versions") + applied = {row[0] for row in cur.fetchall()} + + migration_files = sorted(MIGRATIONS_DIR.glob("*.sql")) + pending = [f for f in migration_files if f.name not in applied] + + if not pending: + print("No pending migrations.") + return + + for migration_file in pending: + print(f"Applying {migration_file.name} ...", end=" ", flush=True) + sql = migration_file.read_text() + with conn: + with conn.cursor() as cur: + cur.execute(sql) + cur.execute( + "INSERT INTO schema_versions (filename) VALUES (%s)", + (migration_file.name,), + ) + print("done") + + print(f"\nApplied {len(pending)} migration(s).") + finally: + conn.close() + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/migrations/2026-03-27_add_schema_versions_table.sql b/migrations/2026-03-27_add_schema_versions_table.sql new file mode 100644 index 0000000..1fa01f1 --- /dev/null +++ b/migrations/2026-03-27_add_schema_versions_table.sql @@ -0,0 +1,9 @@ +-- Migration: Add schema_versions table for migration tracking +-- Date: 2026-03-27 +-- Description: Creates a table to record which SQL migrations have been applied, +-- preventing double-application and missed migrations across environments. + +CREATE TABLE IF NOT EXISTS schema_versions ( + filename VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT NOW() +); From 16f3f8d8de7cac0e9d6276667f7a723c013dd5dd Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 1 Apr 2026 17:23:25 -0500 Subject: [PATCH 10/20] Fix unbounded API queries causing Gunicorn worker timeouts Add MAX_LIMIT=500 cap across all list endpoints, empty string stripping middleware, and limit/offset to /transactions. Resolves #98. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dependencies.py | 3 + app/main.py | 14 ++ app/routers_v3/awards.py | 9 +- app/routers_v3/battingstats.py | 14 +- app/routers_v3/decisions.py | 9 +- app/routers_v3/divisions.py | 9 +- app/routers_v3/draftlist.py | 9 +- app/routers_v3/draftpicks.py | 7 +- app/routers_v3/fieldingstats.py | 264 +++++++++++++++++---------- app/routers_v3/injuries.py | 9 +- app/routers_v3/keepers.py | 9 +- app/routers_v3/managers.py | 12 +- app/routers_v3/pitchingstats.py | 14 +- app/routers_v3/players.py | 12 +- app/routers_v3/results.py | 9 +- app/routers_v3/sbaplayers.py | 9 +- app/routers_v3/schedules.py | 9 +- app/routers_v3/standings.py | 9 +- app/routers_v3/stratgame.py | 9 +- app/routers_v3/stratplay/batting.py | 12 +- app/routers_v3/stratplay/fielding.py | 12 +- app/routers_v3/stratplay/pitching.py | 12 +- app/routers_v3/stratplay/plays.py | 6 +- app/routers_v3/teams.py | 2 + app/routers_v3/transactions.py | 19 +- app/routers_v3/views.py | 6 +- tests/unit/test_query_limits.py | 154 ++++++++++++++++ 27 files changed, 504 insertions(+), 158 deletions(-) create mode 100644 tests/unit/test_query_limits.py diff --git a/app/dependencies.py b/app/dependencies.py index 6441155..bfab9f1 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -57,6 +57,9 @@ priv_help = ( ) PRIVATE_IN_SCHEMA = True if priv_help == "TRUE" else False +MAX_LIMIT = 500 +DEFAULT_LIMIT = 200 + def valid_token(token): return token == os.environ.get("API_TOKEN") diff --git a/app/main.py b/app/main.py index 3de0bd3..2a8bbff 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ import datetime import logging from logging.handlers import RotatingFileHandler import os +from urllib.parse import parse_qsl, urlencode from fastapi import Depends, FastAPI, Request from fastapi.openapi.docs import get_swagger_ui_html @@ -70,6 +71,19 @@ app = FastAPI( logger.info(f"Starting up now...") +@app.middleware("http") +async def strip_empty_query_params(request: Request, call_next): + qs = request.scope.get("query_string", b"") + if qs: + pairs = parse_qsl(qs.decode(), keep_blank_values=True) + filtered = [(k, v) for k, v in pairs if v != ""] + new_qs = urlencode(filtered).encode() + request.scope["query_string"] = new_qs + if hasattr(request, "_query_params"): + del request._query_params + return await call_next(request) + + app.include_router(current.router) app.include_router(players.router) app.include_router(results.router) diff --git a/app/routers_v3/awards.py b/app/routers_v3/awards.py index 575ef42..01583ab 100644 --- a/app/routers_v3/awards.py +++ b/app/routers_v3/awards.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -43,6 +45,8 @@ async def get_awards( team_id: list = Query(default=None), short_output: Optional[bool] = False, player_name: list = Query(default=None), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_awards = Award.select() @@ -67,8 +71,11 @@ async def get_awards( all_players = Player.select().where(fn.Lower(Player.name) << pname_list) all_awards = all_awards.where(Award.player << all_players) + total_count = all_awards.count() + all_awards = all_awards.offset(offset).limit(limit) + return_awards = { - "count": all_awards.count(), + "count": total_count, "awards": [model_to_dict(x, recurse=not short_output) for x in all_awards], } db.close() diff --git a/app/routers_v3/battingstats.py b/app/routers_v3/battingstats.py index 49d3fa8..11bd14d 100644 --- a/app/routers_v3/battingstats.py +++ b/app/routers_v3/battingstats.py @@ -19,6 +19,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -84,7 +86,7 @@ async def get_batstats( week_end: Optional[int] = None, game_num: list = Query(default=None), position: list = Query(default=None), - limit: Optional[int] = None, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), sort: Optional[str] = None, short_output: Optional[bool] = True, ): @@ -134,8 +136,7 @@ async def get_batstats( ) all_stats = all_stats.where((BattingStat.week >= start) & (BattingStat.week <= end)) - if limit: - all_stats = all_stats.limit(limit) + all_stats = all_stats.limit(limit) if sort: if sort == "newest": all_stats = all_stats.order_by(-BattingStat.week, -BattingStat.game) @@ -168,6 +169,8 @@ async def get_totalstats( short_output: Optional[bool] = False, min_pa: Optional[int] = 1, week: list = Query(default=None), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): if sum(1 for x in [s_type, (week_start or week_end), week] if x is not None) > 1: raise HTTPException( @@ -301,7 +304,10 @@ async def get_totalstats( all_players = Player.select().where(Player.id << player_id) all_stats = all_stats.where(BattingStat.player << all_players) - return_stats = {"count": all_stats.count(), "stats": []} + total_count = all_stats.count() + all_stats = all_stats.offset(offset).limit(limit) + + return_stats = {"count": total_count, "stats": []} for x in all_stats: # Handle player field based on grouping with safe access diff --git a/app/routers_v3/decisions.py b/app/routers_v3/decisions.py index a59b16f..667a902 100644 --- a/app/routers_v3/decisions.py +++ b/app/routers_v3/decisions.py @@ -19,6 +19,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -73,7 +75,7 @@ async def get_decisions( irunners_scored: list = Query(default=None), game_id: list = Query(default=None), player_id: list = Query(default=None), - limit: Optional[int] = None, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), short_output: Optional[bool] = False, ): all_dec = Decision.select().order_by( @@ -135,10 +137,7 @@ async def get_decisions( if irunners_scored is not None: all_dec = all_dec.where(Decision.irunners_scored << irunners_scored) - if limit is not None: - if limit < 1: - limit = 1 - all_dec = all_dec.limit(limit) + all_dec = all_dec.limit(limit) return_dec = { "count": all_dec.count(), diff --git a/app/routers_v3/divisions.py b/app/routers_v3/divisions.py index 095662a..03888d3 100644 --- a/app/routers_v3/divisions.py +++ b/app/routers_v3/divisions.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -32,6 +34,8 @@ async def get_divisions( div_abbrev: Optional[str] = None, lg_name: Optional[str] = None, lg_abbrev: Optional[str] = None, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_divisions = Division.select().where(Division.season == season) @@ -44,8 +48,11 @@ async def get_divisions( if lg_abbrev is not None: all_divisions = all_divisions.where(Division.league_abbrev == lg_abbrev) + total_count = all_divisions.count() + all_divisions = all_divisions.offset(offset).limit(limit) + return_div = { - "count": all_divisions.count(), + "count": total_count, "divisions": [model_to_dict(x) for x in all_divisions], } db.close() diff --git a/app/routers_v3/draftlist.py b/app/routers_v3/draftlist.py index 4de0d7c..de3ae6e 100644 --- a/app/routers_v3/draftlist.py +++ b/app/routers_v3/draftlist.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -34,6 +36,8 @@ async def get_draftlist( season: Optional[int], team_id: list = Query(default=None), token: str = Depends(oauth2_scheme), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): if not valid_token(token): logger.warning(f"get_draftlist - Bad Token: {token}") @@ -46,7 +50,10 @@ async def get_draftlist( if team_id is not None: all_list = all_list.where(DraftList.team_id << team_id) - r_list = {"count": all_list.count(), "picks": [model_to_dict(x) for x in all_list]} + total_count = all_list.count() + all_list = all_list.offset(offset).limit(limit) + + r_list = {"count": total_count, "picks": [model_to_dict(x) for x in all_list]} db.close() return r_list diff --git a/app/routers_v3/draftpicks.py b/app/routers_v3/draftpicks.py index 2214aa3..a2dba4e 100644 --- a/app/routers_v3/draftpicks.py +++ b/app/routers_v3/draftpicks.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -50,7 +52,7 @@ async def get_picks( overall_end: Optional[int] = None, short_output: Optional[bool] = False, sort: Optional[str] = None, - limit: Optional[int] = None, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), player_id: list = Query(default=None), player_taken: Optional[bool] = None, ): @@ -110,8 +112,7 @@ async def get_picks( all_picks = all_picks.where(DraftPick.overall <= overall_end) if player_taken is not None: all_picks = all_picks.where(DraftPick.player.is_null(not player_taken)) - if limit is not None: - all_picks = all_picks.limit(limit) + all_picks = all_picks.limit(limit) if sort is not None: if sort == "order-asc": diff --git a/app/routers_v3/fieldingstats.py b/app/routers_v3/fieldingstats.py index ade0239..849cfb2 100644 --- a/app/routers_v3/fieldingstats.py +++ b/app/routers_v3/fieldingstats.py @@ -3,40 +3,61 @@ from typing import List, Optional, Literal import logging import pydantic -from ..db_engine import db, BattingStat, Team, Player, Current, model_to_dict, chunked, fn, per_season_weeks -from ..dependencies import oauth2_scheme, valid_token, handle_db_errors - -logger = logging.getLogger('discord_app') - -router = APIRouter( - prefix='/api/v3/fieldingstats', - tags=['fieldingstats'] +from ..db_engine import ( + db, + BattingStat, + Team, + Player, + Current, + model_to_dict, + chunked, + fn, + per_season_weeks, +) +from ..dependencies import ( + oauth2_scheme, + valid_token, + handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) +logger = logging.getLogger("discord_app") -@router.get('') +router = APIRouter(prefix="/api/v3/fieldingstats", tags=["fieldingstats"]) + + +@router.get("") @handle_db_errors async def get_fieldingstats( - season: int, s_type: Optional[str] = 'regular', team_abbrev: list = Query(default=None), - player_name: list = Query(default=None), player_id: list = Query(default=None), - week_start: Optional[int] = None, week_end: Optional[int] = None, game_num: list = Query(default=None), - position: list = Query(default=None), limit: Optional[int] = None, sort: Optional[str] = None, - short_output: Optional[bool] = True): - if 'post' in s_type.lower(): + season: int, + s_type: Optional[str] = "regular", + team_abbrev: list = Query(default=None), + player_name: list = Query(default=None), + player_id: list = Query(default=None), + week_start: Optional[int] = None, + week_end: Optional[int] = None, + game_num: list = Query(default=None), + position: list = Query(default=None), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + sort: Optional[str] = None, + short_output: Optional[bool] = True, +): + if "post" in s_type.lower(): all_stats = BattingStat.post_season(season) if all_stats.count() == 0: db.close() - return {'count': 0, 'stats': []} - elif s_type.lower() in ['combined', 'total', 'all']: + return {"count": 0, "stats": []} + elif s_type.lower() in ["combined", "total", "all"]: all_stats = BattingStat.combined_season(season) if all_stats.count() == 0: db.close() - return {'count': 0, 'stats': []} + return {"count": 0, "stats": []} else: all_stats = BattingStat.regular_season(season) if all_stats.count() == 0: db.close() - return {'count': 0, 'stats': []} + return {"count": 0, "stats": []} all_stats = all_stats.where( (BattingStat.xch > 0) | (BattingStat.pb > 0) | (BattingStat.sbc > 0) @@ -51,7 +72,9 @@ async def get_fieldingstats( if player_id: all_stats = all_stats.where(BattingStat.player_id << player_id) else: - p_query = Player.select_season(season).where(fn.Lower(Player.name) << [x.lower() for x in player_name]) + p_query = Player.select_season(season).where( + fn.Lower(Player.name) << [x.lower() for x in player_name] + ) all_stats = all_stats.where(BattingStat.player << p_query) if game_num: all_stats = all_stats.where(BattingStat.game == game_num) @@ -66,72 +89,91 @@ async def get_fieldingstats( db.close() raise HTTPException( status_code=404, - detail=f'Start week {start} is after end week {end} - cannot pull stats' + detail=f"Start week {start} is after end week {end} - cannot pull stats", ) - all_stats = all_stats.where( - (BattingStat.week >= start) & (BattingStat.week <= end) - ) + all_stats = all_stats.where((BattingStat.week >= start) & (BattingStat.week <= end)) - if limit: - all_stats = all_stats.limit(limit) + all_stats = all_stats.limit(limit) if sort: - if sort == 'newest': + if sort == "newest": all_stats = all_stats.order_by(-BattingStat.week, -BattingStat.game) return_stats = { - 'count': all_stats.count(), - 'stats': [{ - 'player': x.player_id if short_output else model_to_dict(x.player, recurse=False), - 'team': x.team_id if short_output else model_to_dict(x.team, recurse=False), - 'pos': x.pos, - 'xch': x.xch, - 'xhit': x.xhit, - 'error': x.error, - 'pb': x.pb, - 'sbc': x.sbc, - 'csc': x.csc, - 'week': x.week, - 'game': x.game, - 'season': x.season - } for x in all_stats] + "count": all_stats.count(), + "stats": [ + { + "player": x.player_id + if short_output + else model_to_dict(x.player, recurse=False), + "team": x.team_id + if short_output + else model_to_dict(x.team, recurse=False), + "pos": x.pos, + "xch": x.xch, + "xhit": x.xhit, + "error": x.error, + "pb": x.pb, + "sbc": x.sbc, + "csc": x.csc, + "week": x.week, + "game": x.game, + "season": x.season, + } + for x in all_stats + ], } db.close() return return_stats -@router.get('/totals') +@router.get("/totals") @handle_db_errors async def get_totalstats( - season: int, s_type: Literal['regular', 'post', 'total', None] = None, team_abbrev: list = Query(default=None), - team_id: list = Query(default=None), player_name: list = Query(default=None), - week_start: Optional[int] = None, week_end: Optional[int] = None, game_num: list = Query(default=None), - position: list = Query(default=None), sort: Optional[str] = None, player_id: list = Query(default=None), - group_by: Literal['team', 'player', 'playerteam'] = 'player', short_output: Optional[bool] = False, - min_ch: Optional[int] = 1, week: list = Query(default=None)): + season: int, + s_type: Literal["regular", "post", "total", None] = None, + team_abbrev: list = Query(default=None), + team_id: list = Query(default=None), + player_name: list = Query(default=None), + week_start: Optional[int] = None, + week_end: Optional[int] = None, + game_num: list = Query(default=None), + position: list = Query(default=None), + sort: Optional[str] = None, + player_id: list = Query(default=None), + group_by: Literal["team", "player", "playerteam"] = "player", + short_output: Optional[bool] = False, + min_ch: Optional[int] = 1, + week: list = Query(default=None), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), +): # Build SELECT fields conditionally based on group_by to match GROUP BY exactly select_fields = [] - - if group_by == 'player': + + if group_by == "player": select_fields = [BattingStat.player, BattingStat.pos] - elif group_by == 'team': + elif group_by == "team": select_fields = [BattingStat.team, BattingStat.pos] - elif group_by == 'playerteam': + elif group_by == "playerteam": select_fields = [BattingStat.player, BattingStat.team, BattingStat.pos] else: # Default case select_fields = [BattingStat.player, BattingStat.pos] all_stats = ( - BattingStat - .select(*select_fields, - fn.SUM(BattingStat.xch).alias('sum_xch'), - fn.SUM(BattingStat.xhit).alias('sum_xhit'), fn.SUM(BattingStat.error).alias('sum_error'), - fn.SUM(BattingStat.pb).alias('sum_pb'), fn.SUM(BattingStat.sbc).alias('sum_sbc'), - fn.SUM(BattingStat.csc).alias('sum_csc')) - .where(BattingStat.season == season) - .having(fn.SUM(BattingStat.xch) >= min_ch) + BattingStat.select( + *select_fields, + fn.SUM(BattingStat.xch).alias("sum_xch"), + fn.SUM(BattingStat.xhit).alias("sum_xhit"), + fn.SUM(BattingStat.error).alias("sum_error"), + fn.SUM(BattingStat.pb).alias("sum_pb"), + fn.SUM(BattingStat.sbc).alias("sum_sbc"), + fn.SUM(BattingStat.csc).alias("sum_csc"), + ) + .where(BattingStat.season == season) + .having(fn.SUM(BattingStat.xch) >= min_ch) ) if True in [s_type is not None, week_start is not None, week_end is not None]: @@ -141,16 +183,20 @@ async def get_totalstats( elif week_start is not None or week_end is not None: if week_start is None or week_end is None: raise HTTPException( - status_code=400, detail='Both week_start and week_end must be included if either is used.' + status_code=400, + detail="Both week_start and week_end must be included if either is used.", + ) + weeks["start"] = week_start + if week_end < weeks["start"]: + raise HTTPException( + status_code=400, + detail="week_end must be greater than or equal to week_start", ) - weeks['start'] = week_start - if week_end < weeks['start']: - raise HTTPException(status_code=400, detail='week_end must be greater than or equal to week_start') else: - weeks['end'] = week_end + weeks["end"] = week_end all_stats = all_stats.where( - (BattingStat.week >= weeks['start']) & (BattingStat.week <= weeks['end']) + (BattingStat.week >= weeks["start"]) & (BattingStat.week <= weeks["end"]) ) elif week is not None: @@ -161,14 +207,20 @@ async def get_totalstats( if position is not None: p_list = [x.upper() for x in position] all_players = Player.select().where( - (Player.pos_1 << p_list) | (Player.pos_2 << p_list) | (Player.pos_3 << p_list) | (Player.pos_4 << p_list) | - (Player.pos_5 << p_list) | (Player.pos_6 << p_list) | (Player.pos_7 << p_list) | (Player.pos_8 << p_list) + (Player.pos_1 << p_list) + | (Player.pos_2 << p_list) + | (Player.pos_3 << p_list) + | (Player.pos_4 << p_list) + | (Player.pos_5 << p_list) + | (Player.pos_6 << p_list) + | (Player.pos_7 << p_list) + | (Player.pos_8 << p_list) ) all_stats = all_stats.where(BattingStat.player << all_players) if sort is not None: - if sort == 'player': + if sort == "player": all_stats = all_stats.order_by(BattingStat.player) - elif sort == 'team': + elif sort == "team": all_stats = all_stats.order_by(BattingStat.team) if group_by is not None: # Use the same fields for GROUP BY as we used for SELECT @@ -177,47 +229,57 @@ async def get_totalstats( all_teams = Team.select().where(Team.id << team_id) all_stats = all_stats.where(BattingStat.team << all_teams) elif team_abbrev is not None: - all_teams = Team.select().where(fn.Lower(Team.abbrev) << [x.lower() for x in team_abbrev]) + all_teams = Team.select().where( + fn.Lower(Team.abbrev) << [x.lower() for x in team_abbrev] + ) all_stats = all_stats.where(BattingStat.team << all_teams) if player_name is not None: - all_players = Player.select().where(fn.Lower(Player.name) << [x.lower() for x in player_name]) + all_players = Player.select().where( + fn.Lower(Player.name) << [x.lower() for x in player_name] + ) all_stats = all_stats.where(BattingStat.player << all_players) elif player_id is not None: all_players = Player.select().where(Player.id << player_id) all_stats = all_stats.where(BattingStat.player << all_players) - return_stats = { - 'count': 0, - 'stats': [] - } - + total_count = all_stats.count() + all_stats = all_stats.offset(offset).limit(limit) + + return_stats = {"count": total_count, "stats": []} + for x in all_stats: if x.sum_xch + x.sum_sbc <= 0: continue - - # Handle player field based on grouping with safe access - this_player = 'TOT' - if 'player' in group_by and hasattr(x, 'player'): - this_player = x.player_id if short_output else model_to_dict(x.player, recurse=False) - # Handle team field based on grouping with safe access - this_team = 'TOT' - if 'team' in group_by and hasattr(x, 'team'): - this_team = x.team_id if short_output else model_to_dict(x.team, recurse=False) - - return_stats['stats'].append({ - 'player': this_player, - 'team': this_team, - 'pos': x.pos, - 'xch': x.sum_xch, - 'xhit': x.sum_xhit, - 'error': x.sum_error, - 'pb': x.sum_pb, - 'sbc': x.sum_sbc, - 'csc': x.sum_csc - }) - - return_stats['count'] = len(return_stats['stats']) + # Handle player field based on grouping with safe access + this_player = "TOT" + if "player" in group_by and hasattr(x, "player"): + this_player = ( + x.player_id if short_output else model_to_dict(x.player, recurse=False) + ) + + # Handle team field based on grouping with safe access + this_team = "TOT" + if "team" in group_by and hasattr(x, "team"): + this_team = ( + x.team_id if short_output else model_to_dict(x.team, recurse=False) + ) + + return_stats["stats"].append( + { + "player": this_player, + "team": this_team, + "pos": x.pos, + "xch": x.sum_xch, + "xhit": x.sum_xhit, + "error": x.sum_error, + "pb": x.sum_pb, + "sbc": x.sum_sbc, + "csc": x.sum_csc, + } + ) + + return_stats["count"] = len(return_stats["stats"]) db.close() return return_stats diff --git a/app/routers_v3/injuries.py b/app/routers_v3/injuries.py index 77984eb..e568878 100644 --- a/app/routers_v3/injuries.py +++ b/app/routers_v3/injuries.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -38,6 +40,8 @@ async def get_injuries( is_active: bool = None, short_output: bool = False, sort: Optional[str] = "start-asc", + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_injuries = Injury.select() @@ -64,8 +68,11 @@ async def get_injuries( elif sort == "start-desc": all_injuries = all_injuries.order_by(-Injury.start_week, -Injury.start_game) + total_count = all_injuries.count() + all_injuries = all_injuries.offset(offset).limit(limit) + return_injuries = { - "count": all_injuries.count(), + "count": total_count, "injuries": [model_to_dict(x, recurse=not short_output) for x in all_injuries], } db.close() diff --git a/app/routers_v3/keepers.py b/app/routers_v3/keepers.py index d0fafcf..36a8f26 100644 --- a/app/routers_v3/keepers.py +++ b/app/routers_v3/keepers.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -34,6 +36,8 @@ async def get_keepers( team_id: list = Query(default=None), player_id: list = Query(default=None), short_output: bool = False, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_keepers = Keeper.select() @@ -44,8 +48,11 @@ async def get_keepers( if player_id is not None: all_keepers = all_keepers.where(Keeper.player_id << player_id) + total_count = all_keepers.count() + all_keepers = all_keepers.offset(offset).limit(limit) + return_keepers = { - "count": all_keepers.count(), + "count": total_count, "keepers": [model_to_dict(x, recurse=not short_output) for x in all_keepers], } db.close() diff --git a/app/routers_v3/managers.py b/app/routers_v3/managers.py index 4c0de88..2cd01c3 100644 --- a/app/routers_v3/managers.py +++ b/app/routers_v3/managers.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -29,6 +31,8 @@ async def get_managers( name: list = Query(default=None), active: Optional[bool] = None, short_output: Optional[bool] = False, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): if active is not None: current = Current.latest() @@ -61,7 +65,9 @@ async def get_managers( i_mgr.append(z) final_mgrs = [model_to_dict(y, recurse=not short_output) for y in i_mgr] - return_managers = {"count": len(final_mgrs), "managers": final_mgrs} + total_count = len(final_mgrs) + final_mgrs = final_mgrs[offset : offset + limit] + return_managers = {"count": total_count, "managers": final_mgrs} else: all_managers = Manager.select() @@ -69,8 +75,10 @@ async def get_managers( name_list = [x.lower() for x in name] all_managers = all_managers.where(fn.Lower(Manager.name) << name_list) + total_count = all_managers.count() + all_managers = all_managers.offset(offset).limit(limit) return_managers = { - "count": all_managers.count(), + "count": total_count, "managers": [ model_to_dict(x, recurse=not short_output) for x in all_managers ], diff --git a/app/routers_v3/pitchingstats.py b/app/routers_v3/pitchingstats.py index d318013..f9073f8 100644 --- a/app/routers_v3/pitchingstats.py +++ b/app/routers_v3/pitchingstats.py @@ -19,6 +19,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -68,7 +70,7 @@ async def get_pitstats( week_start: Optional[int] = None, week_end: Optional[int] = None, game_num: list = Query(default=None), - limit: Optional[int] = None, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), ip_min: Optional[float] = None, sort: Optional[str] = None, short_output: Optional[bool] = True, @@ -121,8 +123,7 @@ async def get_pitstats( (PitchingStat.week >= start) & (PitchingStat.week <= end) ) - if limit: - all_stats = all_stats.limit(limit) + all_stats = all_stats.limit(limit) if sort: if sort == "newest": all_stats = all_stats.order_by(-PitchingStat.week, -PitchingStat.game) @@ -154,6 +155,8 @@ async def get_totalstats( short_output: Optional[bool] = False, group_by: Literal["team", "player", "playerteam"] = "player", week: list = Query(default=None), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): if sum(1 for x in [s_type, (week_start or week_end), week] if x is not None) > 1: raise HTTPException( @@ -259,7 +262,10 @@ async def get_totalstats( all_players = Player.select().where(Player.id << player_id) all_stats = all_stats.where(PitchingStat.player << all_players) - return_stats = {"count": all_stats.count(), "stats": []} + total_count = all_stats.count() + all_stats = all_stats.offset(offset).limit(limit) + + return_stats = {"count": total_count, "stats": []} for x in all_stats: # Handle player field based on grouping with safe access diff --git a/app/routers_v3/players.py b/app/routers_v3/players.py index c43e0c2..13ac6f1 100644 --- a/app/routers_v3/players.py +++ b/app/routers_v3/players.py @@ -6,7 +6,13 @@ Thin HTTP layer using PlayerService for business logic. from fastapi import APIRouter, Query, Response, Depends from typing import Optional, List -from ..dependencies import oauth2_scheme, cache_result, handle_db_errors +from ..dependencies import ( + oauth2_scheme, + cache_result, + handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, +) from ..services.base import BaseService from ..services.player_service import PlayerService @@ -24,9 +30,7 @@ async def get_players( strat_code: list = Query(default=None), is_injured: Optional[bool] = None, sort: Optional[str] = None, - limit: Optional[int] = Query( - default=None, ge=1, description="Maximum number of results to return" - ), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), offset: Optional[int] = Query( default=None, ge=0, description="Number of results to skip for pagination" ), diff --git a/app/routers_v3/results.py b/app/routers_v3/results.py index 7ba46b8..f8936e8 100644 --- a/app/routers_v3/results.py +++ b/app/routers_v3/results.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -42,6 +44,8 @@ async def get_results( away_abbrev: list = Query(default=None), home_abbrev: list = Query(default=None), short_output: Optional[bool] = False, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_results = Result.select_season(season) @@ -74,8 +78,11 @@ async def get_results( if week_end is not None: all_results = all_results.where(Result.week <= week_end) + total_count = all_results.count() + all_results = all_results.offset(offset).limit(limit) + return_results = { - "count": all_results.count(), + "count": total_count, "results": [model_to_dict(x, recurse=not short_output) for x in all_results], } db.close() diff --git a/app/routers_v3/sbaplayers.py b/app/routers_v3/sbaplayers.py index 296e21e..0810784 100644 --- a/app/routers_v3/sbaplayers.py +++ b/app/routers_v3/sbaplayers.py @@ -12,6 +12,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -44,6 +46,8 @@ async def get_players( key_mlbam: list = Query(default=None), sort: Optional[str] = None, csv: Optional[bool] = False, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_players = SbaPlayer.select() @@ -101,8 +105,11 @@ async def get_players( db.close() return Response(content=return_val, media_type="text/csv") + total_count = all_players.count() + all_players = all_players.offset(offset).limit(limit) + return_val = { - "count": all_players.count(), + "count": total_count, "players": [model_to_dict(x) for x in all_players], } db.close() diff --git a/app/routers_v3/schedules.py b/app/routers_v3/schedules.py index afcaabf..03fcac9 100644 --- a/app/routers_v3/schedules.py +++ b/app/routers_v3/schedules.py @@ -9,6 +9,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -38,6 +40,8 @@ async def get_schedules( week_start: Optional[int] = None, week_end: Optional[int] = None, short_output: Optional[bool] = True, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): all_sched = Schedule.select_season(season) @@ -69,8 +73,11 @@ async def get_schedules( all_sched = all_sched.order_by(Schedule.id) + total_count = all_sched.count() + all_sched = all_sched.offset(offset).limit(limit) + return_sched = { - "count": all_sched.count(), + "count": total_count, "schedules": [model_to_dict(x, recurse=not short_output) for x in all_sched], } db.close() diff --git a/app/routers_v3/standings.py b/app/routers_v3/standings.py index f5ef37e..aa06ece 100644 --- a/app/routers_v3/standings.py +++ b/app/routers_v3/standings.py @@ -8,6 +8,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -23,6 +25,8 @@ async def get_standings( league_abbrev: Optional[str] = None, division_abbrev: Optional[str] = None, short_output: Optional[bool] = False, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): standings = Standings.select_season(season) @@ -57,8 +61,11 @@ async def get_standings( div_teams = [x for x in standings] div_teams.sort(key=lambda team: win_pct(team), reverse=True) + total_count = len(div_teams) + div_teams = div_teams[offset : offset + limit] + return_standings = { - "count": len(div_teams), + "count": total_count, "standings": [model_to_dict(x, recurse=not short_output) for x in div_teams], } diff --git a/app/routers_v3/stratgame.py b/app/routers_v3/stratgame.py index ba750a8..b4027f8 100644 --- a/app/routers_v3/stratgame.py +++ b/app/routers_v3/stratgame.py @@ -13,6 +13,8 @@ from ..dependencies import ( PRIVATE_IN_SCHEMA, handle_db_errors, update_season_batting_stats, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -59,6 +61,8 @@ async def get_games( division_id: Optional[int] = None, short_output: Optional[bool] = False, sort: Optional[str] = None, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ) -> Any: all_games = StratGame.select() @@ -119,8 +123,11 @@ async def get_games( StratGame.season, StratGame.week, StratGame.game_num ) + total_count = all_games.count() + all_games = all_games.offset(offset).limit(limit) + return_games = { - "count": all_games.count(), + "count": total_count, "games": [model_to_dict(x, recurse=not short_output) for x in all_games], } db.close() diff --git a/app/routers_v3/stratplay/batting.py b/app/routers_v3/stratplay/batting.py index 7151aae..9a7fa2c 100644 --- a/app/routers_v3/stratplay/batting.py +++ b/app/routers_v3/stratplay/batting.py @@ -13,7 +13,13 @@ from ...db_engine import ( fn, model_to_dict, ) -from ...dependencies import add_cache_headers, cache_result, handle_db_errors +from ...dependencies import ( + add_cache_headers, + cache_result, + handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, +) from .common import build_season_games router = APIRouter() @@ -52,7 +58,7 @@ async def get_batting_totals( risp: Optional[bool] = None, inning: list = Query(default=None), sort: Optional[str] = None, - limit: Optional[int] = 200, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), short_output: Optional[bool] = False, page_num: Optional[int] = 1, week_start: Optional[int] = None, @@ -423,8 +429,6 @@ async def get_batting_totals( run_plays = run_plays.order_by(StratPlay.game.asc()) # For other group_by values, skip game_id/play_num sorting since they're not in GROUP BY - if limit < 1: - limit = 1 bat_plays = bat_plays.paginate(page_num, limit) logger.info(f"bat_plays query: {bat_plays}") diff --git a/app/routers_v3/stratplay/fielding.py b/app/routers_v3/stratplay/fielding.py index 3eed444..69ea587 100644 --- a/app/routers_v3/stratplay/fielding.py +++ b/app/routers_v3/stratplay/fielding.py @@ -13,7 +13,13 @@ from ...db_engine import ( fn, SQL, ) -from ...dependencies import handle_db_errors, add_cache_headers, cache_result +from ...dependencies import ( + handle_db_errors, + add_cache_headers, + cache_result, + MAX_LIMIT, + DEFAULT_LIMIT, +) from .common import build_season_games logger = logging.getLogger("discord_app") @@ -51,7 +57,7 @@ async def get_fielding_totals( team_id: list = Query(default=None), manager_id: list = Query(default=None), sort: Optional[str] = None, - limit: Optional[int] = 200, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), short_output: Optional[bool] = False, page_num: Optional[int] = 1, ): @@ -237,8 +243,6 @@ async def get_fielding_totals( def_plays = def_plays.order_by(StratPlay.game.asc()) # For other group_by values, skip game_id/play_num sorting since they're not in GROUP BY - if limit < 1: - limit = 1 def_plays = def_plays.paginate(page_num, limit) logger.info(f"def_plays query: {def_plays}") diff --git a/app/routers_v3/stratplay/pitching.py b/app/routers_v3/stratplay/pitching.py index 92226cf..c588ae5 100644 --- a/app/routers_v3/stratplay/pitching.py +++ b/app/routers_v3/stratplay/pitching.py @@ -16,7 +16,13 @@ from ...db_engine import ( SQL, complex_data_to_csv, ) -from ...dependencies import handle_db_errors, add_cache_headers, cache_result +from ...dependencies import ( + handle_db_errors, + add_cache_headers, + cache_result, + MAX_LIMIT, + DEFAULT_LIMIT, +) from .common import build_season_games router = APIRouter() @@ -51,7 +57,7 @@ async def get_pitching_totals( risp: Optional[bool] = None, inning: list = Query(default=None), sort: Optional[str] = None, - limit: Optional[int] = 200, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), short_output: Optional[bool] = False, csv: Optional[bool] = False, page_num: Optional[int] = 1, @@ -164,8 +170,6 @@ async def get_pitching_totals( if group_by in ["playergame", "teamgame"]: pitch_plays = pitch_plays.order_by(StratPlay.game.asc()) - if limit < 1: - limit = 1 pitch_plays = pitch_plays.paginate(page_num, limit) # Execute the Peewee query diff --git a/app/routers_v3/stratplay/plays.py b/app/routers_v3/stratplay/plays.py index 7cb53ea..37e9943 100644 --- a/app/routers_v3/stratplay/plays.py +++ b/app/routers_v3/stratplay/plays.py @@ -16,6 +16,8 @@ from ...dependencies import ( handle_db_errors, add_cache_headers, cache_result, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -70,7 +72,7 @@ async def get_plays( pitcher_team_id: list = Query(default=None), short_output: Optional[bool] = False, sort: Optional[str] = None, - limit: Optional[int] = 200, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), page_num: Optional[int] = 1, s_type: Literal["regular", "post", "total", None] = None, ): @@ -185,8 +187,6 @@ async def get_plays( season_games = season_games.where(StratGame.week > 18) all_plays = all_plays.where(StratPlay.game << season_games) - if limit < 1: - limit = 1 bat_plays = all_plays.paginate(page_num, limit) if sort == "wpa-desc": diff --git a/app/routers_v3/teams.py b/app/routers_v3/teams.py index b245653..8983878 100644 --- a/app/routers_v3/teams.py +++ b/app/routers_v3/teams.py @@ -11,6 +11,8 @@ from ..dependencies import ( PRIVATE_IN_SCHEMA, handle_db_errors, cache_result, + MAX_LIMIT, + DEFAULT_LIMIT, ) from ..services.base import BaseService from ..services.team_service import TeamService diff --git a/app/routers_v3/transactions.py b/app/routers_v3/transactions.py index 1880dcc..21a3c9b 100644 --- a/app/routers_v3/transactions.py +++ b/app/routers_v3/transactions.py @@ -10,6 +10,8 @@ from ..dependencies import ( valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -36,7 +38,7 @@ class TransactionList(pydantic.BaseModel): @router.get("") @handle_db_errors async def get_transactions( - season, + season: int, team_abbrev: list = Query(default=None), week_start: Optional[int] = 0, week_end: Optional[int] = None, @@ -45,8 +47,9 @@ async def get_transactions( player_name: list = Query(default=None), player_id: list = Query(default=None), move_id: Optional[str] = None, - is_trade: Optional[bool] = None, short_output: Optional[bool] = False, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + offset: int = Query(default=0, ge=0), ): if season: transactions = Transaction.select_season(season) @@ -84,15 +87,15 @@ async def get_transactions( else: transactions = transactions.where(Transaction.frozen == 0) - if is_trade is not None: - raise HTTPException( - status_code=501, detail="The is_trade parameter is not implemented, yet" - ) - transactions = transactions.order_by(-Transaction.week, Transaction.moveid) + total_count = transactions.count() + transactions = transactions.offset(offset).limit(limit) + return_trans = { - "count": transactions.count(), + "count": total_count, + "limit": limit, + "offset": offset, "transactions": [ model_to_dict(x, recurse=not short_output) for x in transactions ], diff --git a/app/routers_v3/views.py b/app/routers_v3/views.py index c658262..add802f 100644 --- a/app/routers_v3/views.py +++ b/app/routers_v3/views.py @@ -26,6 +26,8 @@ from ..dependencies import ( update_season_batting_stats, update_season_pitching_stats, get_cache_stats, + MAX_LIMIT, + DEFAULT_LIMIT, ) logger = logging.getLogger("discord_app") @@ -72,7 +74,7 @@ async def get_season_batting_stats( "cs", ] = "woba", # Sort field sort_order: Literal["asc", "desc"] = "desc", # asc or desc - limit: Optional[int] = 200, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), offset: int = 0, csv: Optional[bool] = False, ): @@ -218,7 +220,7 @@ async def get_season_pitching_stats( "re24", ] = "era", # Sort field sort_order: Literal["asc", "desc"] = "asc", # asc or desc (asc default for ERA) - limit: Optional[int] = 200, + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), offset: int = 0, csv: Optional[bool] = False, ): diff --git a/tests/unit/test_query_limits.py b/tests/unit/test_query_limits.py new file mode 100644 index 0000000..1403e7c --- /dev/null +++ b/tests/unit/test_query_limits.py @@ -0,0 +1,154 @@ +""" +Tests for query limit/offset parameter validation and middleware behavior. + +Verifies that: +- FastAPI enforces MAX_LIMIT cap (returns 422 for limit > 500) +- FastAPI enforces ge=1 on limit (returns 422 for limit=0 or limit=-1) +- Transactions endpoint returns limit/offset keys in the response +- strip_empty_query_params middleware treats ?param= as absent + +These tests exercise FastAPI parameter validation which fires before any +handler code runs, so most tests don't require a live DB connection. + +The app imports redis and psycopg2 at module level, so we mock those +system-level packages before importing app.main. +""" + +import sys +import pytest +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Stub out C-extension / system packages that aren't installed in the test +# environment before any app code is imported. +# --------------------------------------------------------------------------- + +_redis_stub = MagicMock() +_redis_stub.Redis = MagicMock(return_value=MagicMock(ping=MagicMock(return_value=True))) +sys.modules.setdefault("redis", _redis_stub) + +_psycopg2_stub = MagicMock() +sys.modules.setdefault("psycopg2", _psycopg2_stub) + +_playhouse_pool_stub = MagicMock() +sys.modules.setdefault("playhouse.pool", _playhouse_pool_stub) +_playhouse_pool_stub.PooledPostgresqlDatabase = MagicMock() + +_pandas_stub = MagicMock() +sys.modules.setdefault("pandas", _pandas_stub) +_pandas_stub.DataFrame = MagicMock() + + +@pytest.fixture(scope="module") +def client(): + """ + TestClient with the Peewee db object mocked so the app can be imported + without a running PostgreSQL instance. FastAPI validates query params + before calling handler code, so 422 responses don't need a real DB. + """ + mock_db = MagicMock() + mock_db.is_closed.return_value = False + mock_db.connect.return_value = None + mock_db.close.return_value = None + + with patch("app.db_engine.db", mock_db): + from fastapi.testclient import TestClient + from app.main import app + + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +def test_limit_exceeds_max_returns_422(client): + """ + GET /api/v3/decisions with limit=1000 should return 422. + + MAX_LIMIT is 500; the decisions endpoint declares + limit: int = Query(ge=1, le=MAX_LIMIT), so FastAPI rejects values > 500 + before any handler code runs. + """ + response = client.get("/api/v3/decisions?limit=1000") + assert response.status_code == 422 + + +def test_limit_zero_returns_422(client): + """ + GET /api/v3/decisions with limit=0 should return 422. + + Query(ge=1) rejects zero values. + """ + response = client.get("/api/v3/decisions?limit=0") + assert response.status_code == 422 + + +def test_limit_negative_returns_422(client): + """ + GET /api/v3/decisions with limit=-1 should return 422. + + Query(ge=1) rejects negative values. + """ + response = client.get("/api/v3/decisions?limit=-1") + assert response.status_code == 422 + + +def test_transactions_has_limit_in_response(client): + """ + GET /api/v3/transactions?season=12 should include 'limit' and 'offset' + keys in the JSON response body. + + The transactions endpoint was updated to return pagination metadata + alongside results so callers know the applied page size. + """ + mock_qs = MagicMock() + mock_qs.count.return_value = 0 + mock_qs.where.return_value = mock_qs + mock_qs.order_by.return_value = mock_qs + mock_qs.offset.return_value = mock_qs + mock_qs.limit.return_value = mock_qs + mock_qs.__iter__ = MagicMock(return_value=iter([])) + + with ( + patch("app.routers_v3.transactions.Transaction") as mock_txn, + patch("app.routers_v3.transactions.Team") as mock_team, + patch("app.routers_v3.transactions.Player") as mock_player, + ): + mock_txn.select_season.return_value = mock_qs + mock_txn.select.return_value = mock_qs + mock_team.select.return_value = mock_qs + mock_player.select.return_value = mock_qs + + response = client.get("/api/v3/transactions?season=12") + + # If the mock is sufficient the response is 200 with pagination keys; + # if some DB path still fires we at least confirm limit param is accepted. + assert response.status_code != 422 + if response.status_code == 200: + data = response.json() + assert "limit" in data, "Response missing 'limit' key" + assert "offset" in data, "Response missing 'offset' key" + + +def test_empty_string_param_stripped(client): + """ + Query params with an empty string value should be treated as absent. + + The strip_empty_query_params middleware rewrites the query string before + FastAPI parses it, so ?league_abbrev= is removed entirely rather than + forwarded as an empty string to the handler. + + Expected: the request is accepted (not 422) and the empty param is ignored. + """ + mock_qs = MagicMock() + mock_qs.count.return_value = 0 + mock_qs.where.return_value = mock_qs + mock_qs.__iter__ = MagicMock(return_value=iter([])) + + with patch("app.routers_v3.standings.Standings") as mock_standings: + mock_standings.select_season.return_value = mock_qs + + # ?league_abbrev= should be stripped → treated as absent (None), not "" + response = client.get("/api/v3/standings?season=12&league_abbrev=") + + assert response.status_code != 422, ( + "Empty string query param caused a 422 — middleware may not be stripping it" + ) From 67e87a893a63d6167bb232d5206c09071e78c246 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 1 Apr 2026 17:40:02 -0500 Subject: [PATCH 11/20] Fix fieldingstats count computed after limit applied Capture total_count before .limit() so the response count reflects all matching rows, not just the capped page size. Resolves #100. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routers_v3/fieldingstats.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/routers_v3/fieldingstats.py b/app/routers_v3/fieldingstats.py index 849cfb2..e892eab 100644 --- a/app/routers_v3/fieldingstats.py +++ b/app/routers_v3/fieldingstats.py @@ -93,13 +93,14 @@ async def get_fieldingstats( ) all_stats = all_stats.where((BattingStat.week >= start) & (BattingStat.week <= end)) + total_count = all_stats.count() all_stats = all_stats.limit(limit) if sort: if sort == "newest": all_stats = all_stats.order_by(-BattingStat.week, -BattingStat.game) return_stats = { - "count": all_stats.count(), + "count": total_count, "stats": [ { "player": x.player_id From d92f571960938ec6a581e75e4da5410fb70602a3 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 1 Apr 2026 20:14:35 -0500 Subject: [PATCH 12/20] hotfix: remove output caps from GET /players endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MAX_LIMIT/DEFAULT_LIMIT caps added in 16f3f8d are too restrictive for the /players endpoint — bot and website consumers need full player lists without pagination. Reverts limit param to Optional[int] with no ceiling while keeping caps on all other endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routers_v3/players.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/routers_v3/players.py b/app/routers_v3/players.py index 13ac6f1..1801833 100644 --- a/app/routers_v3/players.py +++ b/app/routers_v3/players.py @@ -10,8 +10,6 @@ from ..dependencies import ( oauth2_scheme, cache_result, handle_db_errors, - MAX_LIMIT, - DEFAULT_LIMIT, ) from ..services.base import BaseService from ..services.player_service import PlayerService @@ -30,7 +28,7 @@ async def get_players( strat_code: list = Query(default=None), is_injured: Optional[bool] = None, sort: Optional[str] = None, - limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + limit: Optional[int] = Query(default=None, ge=1), offset: Optional[int] = Query( default=None, ge=0, description="Number of results to skip for pagination" ), From 215085b3262a7d3c49698d04154bea1f59b17966 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 2 Apr 2026 08:30:22 -0500 Subject: [PATCH 13/20] fix: let HTTPException pass through @handle_db_errors unchanged The decorator was catching all exceptions including intentional HTTPException (401, 404, etc.) and re-wrapping them as 500 "Database error". This masked auth failures and other deliberate HTTP errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dependencies.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/dependencies.py b/app/dependencies.py index bfab9f1..a6b25fe 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -807,6 +807,10 @@ def handle_db_errors(func): return result + except HTTPException: + # Let intentional HTTP errors (401, 404, etc.) pass through unchanged + raise + except Exception as e: elapsed_time = time.time() - start_time error_trace = traceback.format_exc() From c49f91cc1955f7b5ad51e1dd11c0066c7db1f2e8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 2 Apr 2026 09:30:39 -0500 Subject: [PATCH 14/20] test: update test_get_nonexistent_play to expect 404 after HTTPException fix After handle_db_errors no longer catches HTTPException, GET /plays/999999999 correctly returns 404 instead of 500. Update the assertion and docstring to reflect the fixed behavior. Co-Authored-By: Claude Sonnet 4.6 --- tests/integration/test_stratplay_routes.py | 29 +++++++++++----------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/integration/test_stratplay_routes.py b/tests/integration/test_stratplay_routes.py index bcc9abf..9a0dfea 100644 --- a/tests/integration/test_stratplay_routes.py +++ b/tests/integration/test_stratplay_routes.py @@ -81,9 +81,9 @@ class TestRouteRegistration: for route, methods in EXPECTED_PLAY_ROUTES.items(): assert route in paths, f"Route {route} missing from OpenAPI schema" for method in methods: - assert ( - method in paths[route] - ), f"Method {method.upper()} missing for {route}" + assert method in paths[route], ( + f"Method {method.upper()} missing for {route}" + ) def test_play_routes_have_plays_tag(self, api): """All play routes should be tagged with 'plays'.""" @@ -96,9 +96,9 @@ class TestRouteRegistration: for method, spec in paths[route].items(): if method in ("get", "post", "patch", "delete"): tags = spec.get("tags", []) - assert ( - "plays" in tags - ), f"{method.upper()} {route} missing 'plays' tag, has {tags}" + assert "plays" in tags, ( + f"{method.upper()} {route} missing 'plays' tag, has {tags}" + ) @pytest.mark.post_deploy @pytest.mark.skip( @@ -124,9 +124,9 @@ class TestRouteRegistration: ]: params = paths[route]["get"].get("parameters", []) param_names = [p["name"] for p in params] - assert ( - "sbaplayer_id" in param_names - ), f"sbaplayer_id parameter missing from {route}" + assert "sbaplayer_id" in param_names, ( + f"sbaplayer_id parameter missing from {route}" + ) # --------------------------------------------------------------------------- @@ -493,10 +493,9 @@ class TestPlayCrud: assert result["id"] == play_id def test_get_nonexistent_play(self, api): - """GET /plays/999999999 returns an error (wrapped by handle_db_errors).""" + """GET /plays/999999999 returns 404 Not Found.""" r = requests.get(f"{api}/api/v3/plays/999999999", timeout=10) - # handle_db_errors wraps HTTPException as 500 with detail message - assert r.status_code == 500 + assert r.status_code == 404 assert "not found" in r.json().get("detail", "").lower() @@ -575,9 +574,9 @@ class TestGroupBySbaPlayer: ) assert r_seasons.status_code == 200 season_pas = [s["pa"] for s in r_seasons.json()["stats"]] - assert career_pa >= max( - season_pas - ), f"Career PA ({career_pa}) should be >= max season PA ({max(season_pas)})" + assert career_pa >= max(season_pas), ( + f"Career PA ({career_pa}) should be >= max season PA ({max(season_pas)})" + ) @pytest.mark.post_deploy def test_batting_sbaplayer_short_output(self, api): From bd19b7d913d973cb042ac2b1cc036eedaa77df60 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 2 Apr 2026 11:54:56 -0500 Subject: [PATCH 15/20] fix: correct column references in season pitching stats view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sp.on_first/on_second/on_third don't exist — the actual columns are on_first_id/on_second_id/on_third_id. This caused failures when updating season pitching stats after games. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dependencies.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index bfab9f1..78ebc58 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -379,14 +379,14 @@ def update_season_pitching_stats(player_ids, season, db_connection): -- RBI allowed (excluding HR) per runner opportunity CASE - WHEN (SUM(CASE WHEN sp.on_first IS NOT NULL THEN 1 ELSE 0 END) + - SUM(CASE WHEN sp.on_second IS NOT NULL THEN 1 ELSE 0 END) + - SUM(CASE WHEN sp.on_third IS NOT NULL THEN 1 ELSE 0 END)) > 0 + WHEN (SUM(CASE WHEN sp.on_first_id IS NOT NULL THEN 1 ELSE 0 END) + + SUM(CASE WHEN sp.on_second_id IS NOT NULL THEN 1 ELSE 0 END) + + SUM(CASE WHEN sp.on_third_id IS NOT NULL THEN 1 ELSE 0 END)) > 0 THEN ROUND( (SUM(sp.rbi) - SUM(sp.homerun))::DECIMAL / - (SUM(CASE WHEN sp.on_first IS NOT NULL THEN 1 ELSE 0 END) + - SUM(CASE WHEN sp.on_second IS NOT NULL THEN 1 ELSE 0 END) + - SUM(CASE WHEN sp.on_third IS NOT NULL THEN 1 ELSE 0 END)), + (SUM(CASE WHEN sp.on_first_id IS NOT NULL THEN 1 ELSE 0 END) + + SUM(CASE WHEN sp.on_second_id IS NOT NULL THEN 1 ELSE 0 END) + + SUM(CASE WHEN sp.on_third_id IS NOT NULL THEN 1 ELSE 0 END)), 3 ) ELSE 0.000 From c95459fa5ded2a0ecc6791dad2826cfaf2aa6729 Mon Sep 17 00:00:00 2001 From: cal Date: Mon, 6 Apr 2026 14:58:36 +0000 Subject: [PATCH 16/20] Update app/routers_v3/stratgame.py --- app/routers_v3/stratgame.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/routers_v3/stratgame.py b/app/routers_v3/stratgame.py index b4027f8..9246c97 100644 --- a/app/routers_v3/stratgame.py +++ b/app/routers_v3/stratgame.py @@ -13,7 +13,6 @@ from ..dependencies import ( PRIVATE_IN_SCHEMA, handle_db_errors, update_season_batting_stats, - MAX_LIMIT, DEFAULT_LIMIT, ) @@ -61,7 +60,7 @@ async def get_games( division_id: Optional[int] = None, short_output: Optional[bool] = False, sort: Optional[str] = None, - limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + limit: int = Query(default=DEFAULT_LIMIT, ge=1, le=1000), offset: int = Query(default=0, ge=0), ) -> Any: all_games = StratGame.select() From 4ad445b0da76eccc4b8cd3974b9ffead724d417d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 6 Apr 2026 11:44:10 -0500 Subject: [PATCH 17/20] chore: switch CI to tag-triggered builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the discord bot's CI pattern — trigger on CalVer tag push instead of branch push/PR. Removes auto-CalVer generation and simplifies to a single build step. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/docker-build.yml | 86 +++++++++++-------------------- CLAUDE.md | 2 +- 2 files changed, 31 insertions(+), 57 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 712c4af..6bfb9cc 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -1,20 +1,18 @@ # Gitea Actions: Docker Build, Push, and Notify # # CI/CD pipeline for Major Domo Database API: -# - Builds Docker images on every push/PR -# - Auto-generates CalVer version (YYYY.MM.BUILD) on main branch merges -# - Pushes to Docker Hub and creates git tag on main +# - Triggered by pushing a CalVer tag (e.g., 2026.4.5) +# - Builds Docker image and pushes to Docker Hub with version + latest tags # - Sends Discord notifications on success/failure +# +# To release: git tag -a 2026.4.5 -m "description" && git push origin 2026.4.5 name: Build Docker Image on: push: - branches: - - main - pull_request: - branches: - - main + tags: + - '20*' # matches CalVer tags like 2026.4.5 jobs: build: @@ -24,7 +22,16 @@ jobs: - name: Checkout code uses: https://github.com/actions/checkout@v4 with: - fetch-depth: 0 # Full history for tag counting + fetch-depth: 0 + + - name: Extract version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/} + SHA_SHORT=$(git rev-parse --short HEAD) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "sha_short=$SHA_SHORT" >> $GITHUB_OUTPUT + echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT - name: Set up Docker Buildx uses: https://github.com/docker/setup-buildx-action@v3 @@ -35,80 +42,47 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Generate CalVer version - id: calver - uses: cal/gitea-actions/calver@main - - # Dev build: push with dev + dev-SHA tags (PR/feature branches) - - name: Build Docker image (dev) - if: github.ref != 'refs/heads/main' - uses: https://github.com/docker/build-push-action@v5 - with: - context: . - push: true - tags: | - manticorum67/major-domo-database:dev - manticorum67/major-domo-database:dev-${{ steps.calver.outputs.sha_short }} - cache-from: type=registry,ref=manticorum67/major-domo-database:buildcache - cache-to: type=registry,ref=manticorum67/major-domo-database:buildcache,mode=max - - # Production build: push with latest + CalVer tags (main only) - - name: Build Docker image (production) - if: github.ref == 'refs/heads/main' + - name: Build and push Docker image uses: https://github.com/docker/build-push-action@v5 with: context: . push: true tags: | + manticorum67/major-domo-database:${{ steps.version.outputs.version }} manticorum67/major-domo-database:latest - manticorum67/major-domo-database:${{ steps.calver.outputs.version }} - manticorum67/major-domo-database:${{ steps.calver.outputs.version_sha }} cache-from: type=registry,ref=manticorum67/major-domo-database:buildcache cache-to: type=registry,ref=manticorum67/major-domo-database:buildcache,mode=max - - name: Tag release - if: success() && github.ref == 'refs/heads/main' - uses: cal/gitea-actions/gitea-tag@main - with: - version: ${{ steps.calver.outputs.version }} - token: ${{ github.token }} - - name: Build Summary run: | echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY + echo "- \`manticorum67/major-domo-database:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY echo "- \`manticorum67/major-domo-database:latest\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/major-domo-database:${{ steps.calver.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/major-domo-database:${{ steps.calver.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY - echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY - echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY - echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY + echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY + echo "- Timestamp: \`${{ steps.version.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ github.ref }}" == "refs/heads/main" ]; then - echo "Pushed to Docker Hub!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Pull with: \`docker pull manticorum67/major-domo-database:latest\`" >> $GITHUB_STEP_SUMMARY - else - echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY - fi + echo "Pull with: \`docker pull manticorum67/major-domo-database:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - name: Discord Notification - Success - if: success() && github.ref == 'refs/heads/main' + if: success() uses: cal/gitea-actions/discord-notify@main with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} title: "Major Domo Database" status: success - version: ${{ steps.calver.outputs.version }} - image_tag: ${{ steps.calver.outputs.version_sha }} - commit_sha: ${{ steps.calver.outputs.sha_short }} - timestamp: ${{ steps.calver.outputs.timestamp }} + version: ${{ steps.version.outputs.version }} + image_tag: ${{ steps.version.outputs.version }} + commit_sha: ${{ steps.version.outputs.sha_short }} + timestamp: ${{ steps.version.outputs.timestamp }} - name: Discord Notification - Failure - if: failure() && github.ref == 'refs/heads/main' + if: failure() uses: cal/gitea-actions/discord-notify@main with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/CLAUDE.md b/CLAUDE.md index bd9be58..39f725a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ python migrations.py # Run migrations (SQL files in migrat - **Bot container**: `dev_sba_postgres` (PostgreSQL) + `dev_sba_db_api` (API) — check with `docker ps` - **Image**: `manticorum67/major-domo-database:dev` (Docker Hub) -- **CI/CD**: Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version (`YYYY.MM.BUILD`) on merge +- **CI/CD**: Gitea Actions — tag-triggered Docker builds. Push a CalVer tag to release: `git tag -a 2026.4.5 -m "description" && git push origin 2026.4.5` ## Important From cfa6da06b732418b38ba27197e72181b29010368 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 06:04:56 -0500 Subject: [PATCH 18/20] fix: replace manual db.close() calls with middleware-based connection management (#71) Closes #71 Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 13 +++++++++++++ app/routers_v3/awards.py | 9 --------- app/routers_v3/battingstats.py | 8 -------- app/routers_v3/current.py | 5 ----- app/routers_v3/custom_commands.py | 12 ------------ app/routers_v3/decisions.py | 9 --------- app/routers_v3/divisions.py | 10 ---------- app/routers_v3/draftdata.py | 3 --- app/routers_v3/draftlist.py | 4 ---- app/routers_v3/draftpicks.py | 6 ------ app/routers_v3/fieldingstats.py | 6 ------ app/routers_v3/help_commands.py | 10 ---------- app/routers_v3/injuries.py | 8 -------- app/routers_v3/keepers.py | 5 ----- app/routers_v3/managers.py | 9 --------- app/routers_v3/pitchingstats.py | 8 -------- app/routers_v3/results.py | 7 ------- app/routers_v3/sbaplayers.py | 18 ------------------ app/routers_v3/schedules.py | 6 ------ app/routers_v3/standings.py | 5 ----- app/routers_v3/stratgame.py | 10 ---------- app/routers_v3/stratplay/batting.py | 1 - app/routers_v3/stratplay/crud.py | 10 ---------- app/routers_v3/stratplay/fielding.py | 1 - app/routers_v3/stratplay/pitching.py | 1 - app/routers_v3/stratplay/plays.py | 1 - app/routers_v3/transactions.py | 5 ----- 27 files changed, 13 insertions(+), 177 deletions(-) diff --git a/app/main.py b/app/main.py index 2a8bbff..58a5ffe 100644 --- a/app/main.py +++ b/app/main.py @@ -71,6 +71,19 @@ app = FastAPI( logger.info(f"Starting up now...") +@app.middleware("http") +async def db_connection_middleware(request: Request, call_next): + from .db_engine import db + + db.connect(reuse_if_open=True) + try: + response = await call_next(request) + return response + finally: + if not db.is_closed(): + db.close() + + @app.middleware("http") async def strip_empty_query_params(request: Request, call_next): qs = request.scope.get("query_string", b"") diff --git a/app/routers_v3/awards.py b/app/routers_v3/awards.py index 01583ab..fe1d3f4 100644 --- a/app/routers_v3/awards.py +++ b/app/routers_v3/awards.py @@ -78,7 +78,6 @@ async def get_awards( "count": total_count, "awards": [model_to_dict(x, recurse=not short_output) for x in all_awards], } - db.close() return return_awards @@ -87,10 +86,8 @@ async def get_awards( async def get_one_award(award_id: int, short_output: Optional[bool] = False): this_award = Award.get_or_none(Award.id == award_id) if this_award is None: - db.close() raise HTTPException(status_code=404, detail=f"Award ID {award_id} not found") - db.close() return model_to_dict(this_award, recurse=not short_output) @@ -114,7 +111,6 @@ async def patch_award( this_award = Award.get_or_none(Award.id == award_id) if this_award is None: - db.close() raise HTTPException(status_code=404, detail=f"Award ID {award_id} not found") if name is not None: @@ -136,10 +132,8 @@ async def patch_award( if this_award.save() == 1: r_award = model_to_dict(this_award) - db.close() return r_award else: - db.close() raise HTTPException(status_code=500, detail=f"Unable to patch award {award_id}") @@ -183,7 +177,6 @@ async def post_award(award_list: AwardList, token: str = Depends(oauth2_scheme)) with db.atomic(): for batch in chunked(new_awards, 15): Award.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_awards)} awards" @@ -197,11 +190,9 @@ async def delete_award(award_id: int, token: str = Depends(oauth2_scheme)): this_award = Award.get_or_none(Award.id == award_id) if this_award is None: - db.close() raise HTTPException(status_code=404, detail=f"Award ID {award_id} not found") count = this_award.delete_instance() - db.close() if count == 1: return f"Award {award_id} has been deleted" diff --git a/app/routers_v3/battingstats.py b/app/routers_v3/battingstats.py index 11bd14d..df88a3f 100644 --- a/app/routers_v3/battingstats.py +++ b/app/routers_v3/battingstats.py @@ -93,17 +93,14 @@ async def get_batstats( if "post" in s_type.lower(): all_stats = BattingStat.post_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} elif s_type.lower() in ["combined", "total", "all"]: all_stats = BattingStat.combined_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} else: all_stats = BattingStat.regular_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} if position is not None: @@ -129,7 +126,6 @@ async def get_batstats( if week_end is not None: end = min(week_end, end) if start > end: - db.close() raise HTTPException( status_code=404, detail=f"Start week {start} is after end week {end} - cannot pull stats", @@ -147,7 +143,6 @@ async def get_batstats( # 'stats': [{'id': x.id} for x in all_stats] } - db.close() return return_stats @@ -350,7 +345,6 @@ async def get_totalstats( "bplo": x.sum_bplo, } ) - db.close() return return_stats @@ -374,7 +368,6 @@ async def patch_batstats( BattingStat.update(**new_stats.dict()).where(BattingStat.id == stat_id).execute() r_stat = model_to_dict(BattingStat.get_by_id(stat_id)) - db.close() return r_stat @@ -418,5 +411,4 @@ async def post_batstats(s_list: BatStatList, token: str = Depends(oauth2_scheme) # Update career stats - db.close() return f"Added {len(all_stats)} batting lines" diff --git a/app/routers_v3/current.py b/app/routers_v3/current.py index ba4458f..889c38a 100644 --- a/app/routers_v3/current.py +++ b/app/routers_v3/current.py @@ -41,7 +41,6 @@ async def get_current(season: Optional[int] = None): if current is not None: r_curr = model_to_dict(current) - db.close() return r_curr else: return None @@ -100,10 +99,8 @@ async def patch_current( if current.save(): r_curr = model_to_dict(current) - db.close() return r_curr else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch current {current_id}" ) @@ -120,10 +117,8 @@ async def post_current(new_current: CurrentModel, token: str = Depends(oauth2_sc if this_current.save(): r_curr = model_to_dict(this_current) - db.close() return r_curr else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to post season {new_current.season} current", diff --git a/app/routers_v3/custom_commands.py b/app/routers_v3/custom_commands.py index 577e78d..bcd0c18 100644 --- a/app/routers_v3/custom_commands.py +++ b/app/routers_v3/custom_commands.py @@ -364,7 +364,6 @@ async def get_custom_commands( logger.error(f"Error getting custom commands: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() # Move this route to after the specific string routes @@ -430,7 +429,6 @@ async def create_custom_command_endpoint( logger.error(f"Error creating custom command: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.put("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -491,7 +489,6 @@ async def update_custom_command_endpoint( logger.error(f"Error updating custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.patch("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -576,7 +573,6 @@ async def patch_custom_command( logger.error(f"Error patching custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.delete("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -613,7 +609,6 @@ async def delete_custom_command_endpoint( logger.error(f"Error deleting custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() # Creator endpoints @@ -684,7 +679,6 @@ async def get_creators( logger.error(f"Error getting creators: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.post("/creators", include_in_schema=PRIVATE_IN_SCHEMA) @@ -729,7 +723,6 @@ async def create_creator_endpoint( logger.error(f"Error creating creator: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.get("/stats") @@ -855,7 +848,6 @@ async def get_custom_command_stats(): logger.error(f"Error getting custom command stats: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() # Special endpoints for Discord bot integration @@ -922,7 +914,6 @@ async def get_custom_command_by_name_endpoint(command_name: str): logger.error(f"Error getting custom command by name '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.patch("/by_name/{command_name}/execute", include_in_schema=PRIVATE_IN_SCHEMA) @@ -991,7 +982,6 @@ async def execute_custom_command( logger.error(f"Error executing custom command '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.get("/autocomplete") @@ -1028,7 +1018,6 @@ async def get_command_names_for_autocomplete( logger.error(f"Error getting command names for autocomplete: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.get("/{command_id}") @@ -1078,4 +1067,3 @@ async def get_custom_command(command_id: int): logger.error(f"Error getting custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() diff --git a/app/routers_v3/decisions.py b/app/routers_v3/decisions.py index 667a902..98b52c9 100644 --- a/app/routers_v3/decisions.py +++ b/app/routers_v3/decisions.py @@ -143,7 +143,6 @@ async def get_decisions( "count": all_dec.count(), "decisions": [model_to_dict(x, recurse=not short_output) for x in all_dec], } - db.close() return return_dec @@ -168,7 +167,6 @@ async def patch_decision( this_dec = Decision.get_or_none(Decision.id == decision_id) if this_dec is None: - db.close() raise HTTPException( status_code=404, detail=f"Decision ID {decision_id} not found" ) @@ -194,10 +192,8 @@ async def patch_decision( if this_dec.save() == 1: d_result = model_to_dict(this_dec) - db.close() return d_result else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch decision {decision_id}" ) @@ -226,7 +222,6 @@ async def post_decisions(dec_list: DecisionList, token: str = Depends(oauth2_sch with db.atomic(): for batch in chunked(new_dec, 10): Decision.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_dec)} decisions" @@ -240,13 +235,11 @@ async def delete_decision(decision_id: int, token: str = Depends(oauth2_scheme)) this_dec = Decision.get_or_none(Decision.id == decision_id) if this_dec is None: - db.close() raise HTTPException( status_code=404, detail=f"Decision ID {decision_id} not found" ) count = this_dec.delete_instance() - db.close() if count == 1: return f"Decision {decision_id} has been deleted" @@ -265,11 +258,9 @@ async def delete_decisions_game(game_id: int, token: str = Depends(oauth2_scheme this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - db.close() raise HTTPException(status_code=404, detail=f"Game ID {game_id} not found") count = Decision.delete().where(Decision.game == this_game).execute() - db.close() if count > 0: return f"Deleted {count} decisions matching Game ID {game_id}" diff --git a/app/routers_v3/divisions.py b/app/routers_v3/divisions.py index 03888d3..39f721f 100644 --- a/app/routers_v3/divisions.py +++ b/app/routers_v3/divisions.py @@ -55,7 +55,6 @@ async def get_divisions( "count": total_count, "divisions": [model_to_dict(x) for x in all_divisions], } - db.close() return return_div @@ -64,13 +63,11 @@ async def get_divisions( async def get_one_division(division_id: int): this_div = Division.get_or_none(Division.id == division_id) if this_div is None: - db.close() raise HTTPException( status_code=404, detail=f"Division ID {division_id} not found" ) r_div = model_to_dict(this_div) - db.close() return r_div @@ -90,7 +87,6 @@ async def patch_division( this_div = Division.get_or_none(Division.id == division_id) if this_div is None: - db.close() raise HTTPException( status_code=404, detail=f"Division ID {division_id} not found" ) @@ -106,10 +102,8 @@ async def patch_division( if this_div.save() == 1: r_division = model_to_dict(this_div) - db.close() return r_division else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch division {division_id}" ) @@ -128,10 +122,8 @@ async def post_division( if this_division.save() == 1: r_division = model_to_dict(this_division) - db.close() return r_division else: - db.close() raise HTTPException(status_code=500, detail=f"Unable to post division") @@ -144,13 +136,11 @@ async def delete_division(division_id: int, token: str = Depends(oauth2_scheme)) this_div = Division.get_or_none(Division.id == division_id) if this_div is None: - db.close() raise HTTPException( status_code=404, detail=f"Division ID {division_id} not found" ) count = this_div.delete_instance() - db.close() if count == 1: return f"Division {division_id} has been deleted" diff --git a/app/routers_v3/draftdata.py b/app/routers_v3/draftdata.py index 1329fb9..f7478c6 100644 --- a/app/routers_v3/draftdata.py +++ b/app/routers_v3/draftdata.py @@ -32,7 +32,6 @@ async def get_draftdata(): if draft_data is not None: r_data = model_to_dict(draft_data) - db.close() return r_data raise HTTPException(status_code=404, detail=f'No draft data found') @@ -50,7 +49,6 @@ async def patch_draftdata( draft_data = DraftData.get_or_none(DraftData.id == data_id) if draft_data is None: - db.close() raise HTTPException(status_code=404, detail=f'No draft data found') if currentpick is not None: @@ -68,7 +66,6 @@ async def patch_draftdata( saved = draft_data.save() r_data = model_to_dict(draft_data) - db.close() if saved == 1: return r_data diff --git a/app/routers_v3/draftlist.py b/app/routers_v3/draftlist.py index de3ae6e..8d544d7 100644 --- a/app/routers_v3/draftlist.py +++ b/app/routers_v3/draftlist.py @@ -55,7 +55,6 @@ async def get_draftlist( r_list = {"count": total_count, "picks": [model_to_dict(x) for x in all_list]} - db.close() return r_list @@ -76,7 +75,6 @@ async def get_team_draftlist(team_id: int, token: str = Depends(oauth2_scheme)): "picks": [model_to_dict(x) for x in this_list], } - db.close() return r_list @@ -106,7 +104,6 @@ async def post_draftlist( for batch in chunked(new_list, 15): DraftList.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_list)} list values" @@ -118,5 +115,4 @@ async def delete_draftlist(team_id: int, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=401, detail="Unauthorized") count = DraftList.delete().where(DraftList.team_id == team_id).execute() - db.close() return f"Deleted {count} list values" diff --git a/app/routers_v3/draftpicks.py b/app/routers_v3/draftpicks.py index a2dba4e..3524404 100644 --- a/app/routers_v3/draftpicks.py +++ b/app/routers_v3/draftpicks.py @@ -124,7 +124,6 @@ async def get_picks( for line in all_picks: return_picks["picks"].append(model_to_dict(line, recurse=not short_output)) - db.close() return return_picks @@ -136,7 +135,6 @@ async def get_one_pick(pick_id: int, short_output: Optional[bool] = False): r_pick = model_to_dict(this_pick, recurse=not short_output) else: raise HTTPException(status_code=404, detail=f"Pick ID {pick_id} not found") - db.close() return r_pick @@ -154,7 +152,6 @@ async def patch_pick( DraftPick.update(**new_pick.dict()).where(DraftPick.id == pick_id).execute() r_pick = model_to_dict(DraftPick.get_by_id(pick_id)) - db.close() return r_pick @@ -171,7 +168,6 @@ async def post_picks(p_list: DraftPickList, token: str = Depends(oauth2_scheme)) DraftPick.season == pick.season, DraftPick.overall == pick.overall ) if dupe: - db.close() raise HTTPException( status_code=500, detail=f"Pick # {pick.overall} already exists for season {pick.season}", @@ -182,7 +178,6 @@ async def post_picks(p_list: DraftPickList, token: str = Depends(oauth2_scheme)) with db.atomic(): for batch in chunked(new_picks, 15): DraftPick.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_picks)} picks" @@ -199,7 +194,6 @@ async def delete_pick(pick_id: int, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=404, detail=f"Pick ID {pick_id} not found") count = this_pick.delete_instance() - db.close() if count == 1: return f"Draft pick {pick_id} has been deleted" diff --git a/app/routers_v3/fieldingstats.py b/app/routers_v3/fieldingstats.py index e892eab..4c5de82 100644 --- a/app/routers_v3/fieldingstats.py +++ b/app/routers_v3/fieldingstats.py @@ -46,17 +46,14 @@ async def get_fieldingstats( if "post" in s_type.lower(): all_stats = BattingStat.post_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} elif s_type.lower() in ["combined", "total", "all"]: all_stats = BattingStat.combined_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} else: all_stats = BattingStat.regular_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} all_stats = all_stats.where( @@ -86,7 +83,6 @@ async def get_fieldingstats( if week_end is not None: end = min(week_end, end) if start > end: - db.close() raise HTTPException( status_code=404, detail=f"Start week {start} is after end week {end} - cannot pull stats", @@ -124,7 +120,6 @@ async def get_fieldingstats( ], } - db.close() return return_stats @@ -282,5 +277,4 @@ async def get_totalstats( ) return_stats["count"] = len(return_stats["stats"]) - db.close() return return_stats diff --git a/app/routers_v3/help_commands.py b/app/routers_v3/help_commands.py index 6d757c7..f056971 100644 --- a/app/routers_v3/help_commands.py +++ b/app/routers_v3/help_commands.py @@ -139,7 +139,6 @@ async def get_help_commands( logger.error(f"Error getting help commands: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.post("/", include_in_schema=PRIVATE_IN_SCHEMA) @@ -188,7 +187,6 @@ async def create_help_command_endpoint( logger.error(f"Error creating help command: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.put("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -239,7 +237,6 @@ async def update_help_command_endpoint( logger.error(f"Error updating help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.patch("/{command_id}/restore", include_in_schema=PRIVATE_IN_SCHEMA) @@ -278,7 +275,6 @@ async def restore_help_command_endpoint( logger.error(f"Error restoring help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.delete("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -310,7 +306,6 @@ async def delete_help_command_endpoint( logger.error(f"Error deleting help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.get("/stats") @@ -369,7 +364,6 @@ async def get_help_command_stats(): logger.error(f"Error getting help command stats: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() # Special endpoints for Discord bot integration @@ -403,7 +397,6 @@ async def get_help_command_by_name_endpoint( logger.error(f"Error getting help command by name '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.patch("/by_name/{command_name}/view", include_in_schema=PRIVATE_IN_SCHEMA) @@ -440,7 +433,6 @@ async def increment_view_count(command_name: str, token: str = Depends(oauth2_sc logger.error(f"Error incrementing view count for '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.get("/autocomplete") @@ -471,7 +463,6 @@ async def get_help_names_for_autocomplete( logger.error(f"Error getting help names for autocomplete: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() @router.get("/{command_id}") @@ -500,4 +491,3 @@ async def get_help_command(command_id: int): logger.error(f"Error getting help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() diff --git a/app/routers_v3/injuries.py b/app/routers_v3/injuries.py index e568878..730378c 100644 --- a/app/routers_v3/injuries.py +++ b/app/routers_v3/injuries.py @@ -75,7 +75,6 @@ async def get_injuries( "count": total_count, "injuries": [model_to_dict(x, recurse=not short_output) for x in all_injuries], } - db.close() return return_injuries @@ -92,7 +91,6 @@ async def patch_injury( this_injury = Injury.get_or_none(Injury.id == injury_id) if this_injury is None: - db.close() raise HTTPException(status_code=404, detail=f"Injury ID {injury_id} not found") if is_active is not None: @@ -100,10 +98,8 @@ async def patch_injury( if this_injury.save() == 1: r_injury = model_to_dict(this_injury) - db.close() return r_injury else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch injury {injury_id}" ) @@ -120,10 +116,8 @@ async def post_injury(new_injury: InjuryModel, token: str = Depends(oauth2_schem if this_injury.save(): r_injury = model_to_dict(this_injury) - db.close() return r_injury else: - db.close() raise HTTPException(status_code=500, detail=f"Unable to post injury") @@ -136,11 +130,9 @@ async def delete_injury(injury_id: int, token: str = Depends(oauth2_scheme)): this_injury = Injury.get_or_none(Injury.id == injury_id) if this_injury is None: - db.close() raise HTTPException(status_code=404, detail=f"Injury ID {injury_id} not found") count = this_injury.delete_instance() - db.close() if count == 1: return f"Injury {injury_id} has been deleted" diff --git a/app/routers_v3/keepers.py b/app/routers_v3/keepers.py index 36a8f26..ad8d4e7 100644 --- a/app/routers_v3/keepers.py +++ b/app/routers_v3/keepers.py @@ -55,7 +55,6 @@ async def get_keepers( "count": total_count, "keepers": [model_to_dict(x, recurse=not short_output) for x in all_keepers], } - db.close() return return_keepers @@ -85,10 +84,8 @@ async def patch_keeper( if this_keeper.save(): r_keeper = model_to_dict(this_keeper) - db.close() return r_keeper else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch keeper {keeper_id}" ) @@ -108,7 +105,6 @@ async def post_keepers(k_list: KeeperList, token: str = Depends(oauth2_scheme)): with db.atomic(): for batch in chunked(new_keepers, 14): Keeper.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_keepers)} keepers" @@ -125,7 +121,6 @@ async def delete_keeper(keeper_id: int, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=404, detail=f"Keeper ID {keeper_id} not found") count = this_keeper.delete_instance() - db.close() if count == 1: return f"Keeper ID {keeper_id} has been deleted" diff --git a/app/routers_v3/managers.py b/app/routers_v3/managers.py index 2cd01c3..0e24c5f 100644 --- a/app/routers_v3/managers.py +++ b/app/routers_v3/managers.py @@ -84,7 +84,6 @@ async def get_managers( ], } - db.close() return return_managers @@ -94,7 +93,6 @@ async def get_one_manager(manager_id: int, short_output: Optional[bool] = False) this_manager = Manager.get_or_none(Manager.id == manager_id) if this_manager is not None: r_manager = model_to_dict(this_manager, recurse=not short_output) - db.close() return r_manager else: raise HTTPException(status_code=404, detail=f"Manager {manager_id} not found") @@ -116,7 +114,6 @@ async def patch_manager( this_manager = Manager.get_or_none(Manager.id == manager_id) if this_manager is None: - db.close() raise HTTPException( status_code=404, detail=f"Manager ID {manager_id} not found" ) @@ -132,10 +129,8 @@ async def patch_manager( if this_manager.save() == 1: r_manager = model_to_dict(this_manager) - db.close() return r_manager else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch manager {this_manager}" ) @@ -152,10 +147,8 @@ async def post_manager(new_manager: ManagerModel, token: str = Depends(oauth2_sc if this_manager.save(): r_manager = model_to_dict(this_manager) - db.close() return r_manager else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to post manager {this_manager.name}" ) @@ -170,13 +163,11 @@ async def delete_manager(manager_id: int, token: str = Depends(oauth2_scheme)): this_manager = Manager.get_or_none(Manager.id == manager_id) if this_manager is None: - db.close() raise HTTPException( status_code=404, detail=f"Manager ID {manager_id} not found" ) count = this_manager.delete_instance() - db.close() if count == 1: return f"Manager {manager_id} has been deleted" diff --git a/app/routers_v3/pitchingstats.py b/app/routers_v3/pitchingstats.py index f9073f8..a61003a 100644 --- a/app/routers_v3/pitchingstats.py +++ b/app/routers_v3/pitchingstats.py @@ -78,17 +78,14 @@ async def get_pitstats( if "post" in s_type.lower(): all_stats = PitchingStat.post_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} elif s_type.lower() in ["combined", "total", "all"]: all_stats = PitchingStat.combined_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} else: all_stats = PitchingStat.regular_season(season) if all_stats.count() == 0: - db.close() return {"count": 0, "stats": []} if team_abbrev is not None: @@ -114,7 +111,6 @@ async def get_pitstats( if week_end is not None: end = min(week_end, end) if start > end: - db.close() raise HTTPException( status_code=404, detail=f"Start week {start} is after end week {end} - cannot pull stats", @@ -133,7 +129,6 @@ async def get_pitstats( "stats": [model_to_dict(x, recurse=not short_output) for x in all_stats], } - db.close() return return_stats @@ -307,7 +302,6 @@ async def get_totalstats( "bsv": x.sum_bsv, } ) - db.close() return return_stats @@ -325,7 +319,6 @@ async def patch_pitstats( PitchingStat.update(**new_stats.dict()).where(PitchingStat.id == stat_id).execute() r_stat = model_to_dict(PitchingStat.get_by_id(stat_id)) - db.close() return r_stat @@ -356,5 +349,4 @@ async def post_pitstats(s_list: PitStatList, token: str = Depends(oauth2_scheme) for batch in chunked(all_stats, 15): PitchingStat.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Added {len(all_stats)} batting lines" diff --git a/app/routers_v3/results.py b/app/routers_v3/results.py index f8936e8..8b7298b 100644 --- a/app/routers_v3/results.py +++ b/app/routers_v3/results.py @@ -85,7 +85,6 @@ async def get_results( "count": total_count, "results": [model_to_dict(x, recurse=not short_output) for x in all_results], } - db.close() return return_results @@ -97,7 +96,6 @@ async def get_one_result(result_id: int, short_output: Optional[bool] = False): r_result = model_to_dict(this_result, recurse=not short_output) else: r_result = None - db.close() return r_result @@ -149,10 +147,8 @@ async def patch_result( if this_result.save() == 1: r_result = model_to_dict(this_result) - db.close() return r_result else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch result {result_id}" ) @@ -192,7 +188,6 @@ async def post_results(result_list: ResultList, token: str = Depends(oauth2_sche with db.atomic(): for batch in chunked(new_results, 15): Result.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_results)} results" @@ -206,11 +201,9 @@ async def delete_result(result_id: int, token: str = Depends(oauth2_scheme)): this_result = Result.get_or_none(Result.id == result_id) if not this_result: - db.close() raise HTTPException(status_code=404, detail=f"Result ID {result_id} not found") count = this_result.delete_instance() - db.close() if count == 1: return f"Result {result_id} has been deleted" diff --git a/app/routers_v3/sbaplayers.py b/app/routers_v3/sbaplayers.py index 0810784..255894f 100644 --- a/app/routers_v3/sbaplayers.py +++ b/app/routers_v3/sbaplayers.py @@ -102,7 +102,6 @@ async def get_players( if csv: return_val = query_to_csv(all_players) - db.close() return Response(content=return_val, media_type="text/csv") total_count = all_players.count() @@ -112,7 +111,6 @@ async def get_players( "count": total_count, "players": [model_to_dict(x) for x in all_players], } - db.close() return return_val @@ -121,13 +119,11 @@ async def get_players( async def get_one_player(player_id: int): this_player = SbaPlayer.get_or_none(SbaPlayer.id == player_id) if this_player is None: - db.close() raise HTTPException( status_code=404, detail=f"SbaPlayer id {player_id} not found" ) r_data = model_to_dict(this_player) - db.close() return r_data @@ -145,7 +141,6 @@ async def patch_player( ): if not valid_token(token): logging.warning(f"Bad Token: {token}") - db.close() raise HTTPException( status_code=401, detail="You are not authorized to patch mlb players. This event has been logged.", @@ -153,7 +148,6 @@ async def patch_player( this_player = SbaPlayer.get_or_none(SbaPlayer.id == player_id) if this_player is None: - db.close() raise HTTPException( status_code=404, detail=f"SbaPlayer id {player_id} not found" ) @@ -173,10 +167,8 @@ async def patch_player( if this_player.save() == 1: return_val = model_to_dict(this_player) - db.close() return return_val else: - db.close() raise HTTPException( status_code=418, detail="Well slap my ass and call me a teapot; I could not save that player", @@ -188,7 +180,6 @@ async def patch_player( async def post_players(players: PlayerList, token: str = Depends(oauth2_scheme)): if not valid_token(token): logging.warning(f"Bad Token: {token}") - db.close() raise HTTPException( status_code=401, detail="You are not authorized to post mlb players. This event has been logged.", @@ -207,7 +198,6 @@ async def post_players(players: PlayerList, token: str = Depends(oauth2_scheme)) ) if dupes.count() > 0: logger.error(f"Found a dupe for {x}") - db.close() raise HTTPException( status_code=400, detail=f"{x.first_name} {x.last_name} has a key already in the database", @@ -218,7 +208,6 @@ async def post_players(players: PlayerList, token: str = Depends(oauth2_scheme)) with db.atomic(): for batch in chunked(new_players, 15): SbaPlayer.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_players)} new MLB players" @@ -228,7 +217,6 @@ async def post_players(players: PlayerList, token: str = Depends(oauth2_scheme)) async def post_one_player(player: SbaPlayerModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): logging.warning(f"Bad Token: {token}") - db.close() raise HTTPException( status_code=401, detail="You are not authorized to post mlb players. This event has been logged.", @@ -243,7 +231,6 @@ async def post_one_player(player: SbaPlayerModel, token: str = Depends(oauth2_sc logging.info(f"POST /SbaPlayers/one - dupes found:") for x in dupes: logging.info(f"{x}") - db.close() raise HTTPException( status_code=400, detail=f"{player.first_name} {player.last_name} has a key already in the database", @@ -253,10 +240,8 @@ async def post_one_player(player: SbaPlayerModel, token: str = Depends(oauth2_sc saved = new_player.save() if saved == 1: return_val = model_to_dict(new_player) - db.close() return return_val else: - db.close() raise HTTPException( status_code=418, detail="Well slap my ass and call me a teapot; I could not save that player", @@ -268,7 +253,6 @@ async def post_one_player(player: SbaPlayerModel, token: str = Depends(oauth2_sc async def delete_player(player_id: int, token: str = Depends(oauth2_scheme)): if not valid_token(token): logging.warning(f"Bad Token: {token}") - db.close() raise HTTPException( status_code=401, detail="You are not authorized to delete mlb players. This event has been logged.", @@ -276,13 +260,11 @@ async def delete_player(player_id: int, token: str = Depends(oauth2_scheme)): this_player = SbaPlayer.get_or_none(SbaPlayer.id == player_id) if this_player is None: - db.close() raise HTTPException( status_code=404, detail=f"SbaPlayer id {player_id} not found" ) count = this_player.delete_instance() - db.close() if count == 1: return f"Player {player_id} has been deleted" diff --git a/app/routers_v3/schedules.py b/app/routers_v3/schedules.py index 03fcac9..59924fe 100644 --- a/app/routers_v3/schedules.py +++ b/app/routers_v3/schedules.py @@ -80,7 +80,6 @@ async def get_schedules( "count": total_count, "schedules": [model_to_dict(x, recurse=not short_output) for x in all_sched], } - db.close() return return_sched @@ -92,7 +91,6 @@ async def get_one_schedule(schedule_id: int): r_sched = model_to_dict(this_sched) else: r_sched = None - db.close() return r_sched @@ -134,10 +132,8 @@ async def patch_schedule( if this_sched.save() == 1: r_sched = model_to_dict(this_sched) - db.close() return r_sched else: - db.close() raise HTTPException( status_code=500, detail=f"Unable to patch schedule {schedule_id}" ) @@ -177,7 +173,6 @@ async def post_schedules(sched_list: ScheduleList, token: str = Depends(oauth2_s with db.atomic(): for batch in chunked(new_sched, 15): Schedule.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_sched)} schedules" @@ -196,7 +191,6 @@ async def delete_schedule(schedule_id: int, token: str = Depends(oauth2_scheme)) ) count = this_sched.delete_instance() - db.close() if count == 1: return f"Schedule {this_sched} has been deleted" diff --git a/app/routers_v3/standings.py b/app/routers_v3/standings.py index aa06ece..dbe0c32 100644 --- a/app/routers_v3/standings.py +++ b/app/routers_v3/standings.py @@ -69,7 +69,6 @@ async def get_standings( "standings": [model_to_dict(x, recurse=not short_output) for x in div_teams], } - db.close() return return_standings @@ -100,7 +99,6 @@ async def patch_standings( try: this_stan = Standings.get_by_id(stan_id) except Exception as e: - db.close() raise HTTPException(status_code=404, detail=f"No team found with id {stan_id}") if wins: @@ -109,7 +107,6 @@ async def patch_standings( this_stan.losses = losses this_stan.save() - db.close() return model_to_dict(this_stan) @@ -129,7 +126,6 @@ async def post_standings(season: int, token: str = Depends(oauth2_scheme)): with db.atomic(): for batch in chunked(new_teams, 16): Standings.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_teams)} standings" @@ -142,7 +138,6 @@ async def recalculate_standings(season: int, token: str = Depends(oauth2_scheme) raise HTTPException(status_code=401, detail="Unauthorized") code = Standings.recalculate(season) - db.close() if code == 69: raise HTTPException(status_code=500, detail=f"Error recreating Standings rows") return f"Just recalculated standings for season {season}" diff --git a/app/routers_v3/stratgame.py b/app/routers_v3/stratgame.py index 9246c97..7f42773 100644 --- a/app/routers_v3/stratgame.py +++ b/app/routers_v3/stratgame.py @@ -129,7 +129,6 @@ async def get_games( "count": total_count, "games": [model_to_dict(x, recurse=not short_output) for x in all_games], } - db.close() return return_games @@ -138,11 +137,9 @@ async def get_games( async def get_one_game(game_id: int) -> Any: this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - db.close() raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") g_result = model_to_dict(this_game) - db.close() return g_result @@ -164,7 +161,6 @@ async def patch_game( this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - db.close() raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") if game_num is not None: @@ -259,7 +255,6 @@ async def post_games(game_list: GameList, token: str = Depends(oauth2_scheme)) - with db.atomic(): for batch in chunked(new_games, 16): StratGame.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_games)} games" @@ -273,7 +268,6 @@ async def wipe_game(game_id: int, token: str = Depends(oauth2_scheme)) -> Any: this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - db.close() raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") this_game.away_score = None @@ -284,10 +278,8 @@ async def wipe_game(game_id: int, token: str = Depends(oauth2_scheme)) -> Any: if this_game.save() == 1: g_result = model_to_dict(this_game) - db.close() return g_result else: - db.close() raise HTTPException(status_code=500, detail=f"Unable to wipe game {game_id}") @@ -300,11 +292,9 @@ async def delete_game(game_id: int, token: str = Depends(oauth2_scheme)) -> Any: this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - db.close() raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") count = this_game.delete_instance() - db.close() if count == 1: return f"StratGame {game_id} has been deleted" diff --git a/app/routers_v3/stratplay/batting.py b/app/routers_v3/stratplay/batting.py index 9a7fa2c..150585b 100644 --- a/app/routers_v3/stratplay/batting.py +++ b/app/routers_v3/stratplay/batting.py @@ -598,5 +598,4 @@ async def get_batting_totals( } ) - db.close() return return_stats diff --git a/app/routers_v3/stratplay/crud.py b/app/routers_v3/stratplay/crud.py index ee56f51..dde61f7 100644 --- a/app/routers_v3/stratplay/crud.py +++ b/app/routers_v3/stratplay/crud.py @@ -20,10 +20,8 @@ logger = logging.getLogger("discord_app") @handle_db_errors async def get_one_play(play_id: int): if StratPlay.get_or_none(StratPlay.id == play_id) is None: - db.close() raise HTTPException(status_code=404, detail=f"Play ID {play_id} not found") r_play = model_to_dict(StratPlay.get_by_id(play_id)) - db.close() return r_play @@ -37,12 +35,10 @@ async def patch_play( raise HTTPException(status_code=401, detail="Unauthorized") if StratPlay.get_or_none(StratPlay.id == play_id) is None: - db.close() raise HTTPException(status_code=404, detail=f"Play ID {play_id} not found") StratPlay.update(**new_play.dict()).where(StratPlay.id == play_id).execute() r_play = model_to_dict(StratPlay.get_by_id(play_id)) - db.close() return r_play @@ -93,7 +89,6 @@ async def post_plays(p_list: PlayList, token: str = Depends(oauth2_scheme)): with db.atomic(): for batch in chunked(new_plays, 20): StratPlay.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"Inserted {len(new_plays)} plays" @@ -107,11 +102,9 @@ async def delete_play(play_id: int, token: str = Depends(oauth2_scheme)): this_play = StratPlay.get_or_none(StratPlay.id == play_id) if not this_play: - db.close() raise HTTPException(status_code=404, detail=f"Play ID {play_id} not found") count = this_play.delete_instance() - db.close() if count == 1: return f"Play {play_id} has been deleted" @@ -130,11 +123,9 @@ async def delete_plays_game(game_id: int, token: str = Depends(oauth2_scheme)): this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - db.close() raise HTTPException(status_code=404, detail=f"Game ID {game_id} not found") count = StratPlay.delete().where(StratPlay.game == this_game).execute() - db.close() if count > 0: return f"Deleted {count} plays matching Game ID {game_id}" @@ -155,5 +146,4 @@ async def post_erun_check(token: str = Depends(oauth2_scheme)): (StratPlay.e_run == 1) & (StratPlay.run == 0) ) count = all_plays.execute() - db.close() return count diff --git a/app/routers_v3/stratplay/fielding.py b/app/routers_v3/stratplay/fielding.py index 69ea587..26fb1cb 100644 --- a/app/routers_v3/stratplay/fielding.py +++ b/app/routers_v3/stratplay/fielding.py @@ -365,5 +365,4 @@ async def get_fielding_totals( "week": this_week, } ) - db.close() return return_stats diff --git a/app/routers_v3/stratplay/pitching.py b/app/routers_v3/stratplay/pitching.py index c588ae5..ac0207b 100644 --- a/app/routers_v3/stratplay/pitching.py +++ b/app/routers_v3/stratplay/pitching.py @@ -352,7 +352,6 @@ async def get_pitching_totals( ) return_stats["count"] = len(return_stats["stats"]) - db.close() if csv: return Response( content=complex_data_to_csv(return_stats["stats"]), media_type="text/csv" diff --git a/app/routers_v3/stratplay/plays.py b/app/routers_v3/stratplay/plays.py index 37e9943..e6a609b 100644 --- a/app/routers_v3/stratplay/plays.py +++ b/app/routers_v3/stratplay/plays.py @@ -210,5 +210,4 @@ async def get_plays( "count": all_plays.count(), "plays": [model_to_dict(x, recurse=not short_output) for x in all_plays], } - db.close() return return_plays diff --git a/app/routers_v3/transactions.py b/app/routers_v3/transactions.py index 21a3c9b..ee02de8 100644 --- a/app/routers_v3/transactions.py +++ b/app/routers_v3/transactions.py @@ -101,7 +101,6 @@ async def get_transactions( ], } - db.close() return return_trans @@ -119,7 +118,6 @@ async def patch_transactions( these_moves = Transaction.select().where(Transaction.moveid == move_id) if these_moves.count() == 0: - db.close() raise HTTPException(status_code=404, detail=f"Move ID {move_id} not found") if frozen is not None: @@ -131,7 +129,6 @@ async def patch_transactions( x.cancelled = cancelled x.save() - db.close() return f"Updated {these_moves.count()} transactions" @@ -181,7 +178,6 @@ async def post_transactions( for batch in chunked(all_moves, 15): Transaction.insert_many(batch).on_conflict_ignore().execute() - db.close() return f"{len(all_moves)} transactions have been added" @@ -195,7 +191,6 @@ async def delete_transactions(move_id, token: str = Depends(oauth2_scheme)): delete_query = Transaction.delete().where(Transaction.moveid == move_id) count = delete_query.execute() - db.close() if count > 0: return f"Removed {count} transactions" else: From b46d8d33ef7b41703923f0016268d7f059ac061a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 27 Mar 2026 06:34:37 -0500 Subject: [PATCH 19/20] fix: remove empty finally clauses in custom_commands and help_commands After removing db.close() calls, 22 finally: blocks were left empty (12 in custom_commands.py, 10 in help_commands.py), causing IndentationError at import time. Removed the finally: clause entirely since connection lifecycle is now handled by the middleware. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v3/custom_commands.py | 12 ------------ app/routers_v3/help_commands.py | 10 ---------- 2 files changed, 22 deletions(-) diff --git a/app/routers_v3/custom_commands.py b/app/routers_v3/custom_commands.py index bcd0c18..56bc53b 100644 --- a/app/routers_v3/custom_commands.py +++ b/app/routers_v3/custom_commands.py @@ -363,7 +363,6 @@ async def get_custom_commands( except Exception as e: logger.error(f"Error getting custom commands: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: # Move this route to after the specific string routes @@ -428,7 +427,6 @@ async def create_custom_command_endpoint( except Exception as e: logger.error(f"Error creating custom command: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.put("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -488,7 +486,6 @@ async def update_custom_command_endpoint( except Exception as e: logger.error(f"Error updating custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.patch("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -572,7 +569,6 @@ async def patch_custom_command( except Exception as e: logger.error(f"Error patching custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.delete("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -608,7 +604,6 @@ async def delete_custom_command_endpoint( except Exception as e: logger.error(f"Error deleting custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: # Creator endpoints @@ -678,7 +673,6 @@ async def get_creators( except Exception as e: logger.error(f"Error getting creators: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.post("/creators", include_in_schema=PRIVATE_IN_SCHEMA) @@ -722,7 +716,6 @@ async def create_creator_endpoint( except Exception as e: logger.error(f"Error creating creator: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.get("/stats") @@ -847,7 +840,6 @@ async def get_custom_command_stats(): except Exception as e: logger.error(f"Error getting custom command stats: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: # Special endpoints for Discord bot integration @@ -913,7 +905,6 @@ async def get_custom_command_by_name_endpoint(command_name: str): except Exception as e: logger.error(f"Error getting custom command by name '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.patch("/by_name/{command_name}/execute", include_in_schema=PRIVATE_IN_SCHEMA) @@ -981,7 +972,6 @@ async def execute_custom_command( except Exception as e: logger.error(f"Error executing custom command '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.get("/autocomplete") @@ -1017,7 +1007,6 @@ async def get_command_names_for_autocomplete( except Exception as e: logger.error(f"Error getting command names for autocomplete: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.get("/{command_id}") @@ -1066,4 +1055,3 @@ async def get_custom_command(command_id: int): except Exception as e: logger.error(f"Error getting custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: diff --git a/app/routers_v3/help_commands.py b/app/routers_v3/help_commands.py index f056971..5b6df18 100644 --- a/app/routers_v3/help_commands.py +++ b/app/routers_v3/help_commands.py @@ -138,7 +138,6 @@ async def get_help_commands( except Exception as e: logger.error(f"Error getting help commands: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.post("/", include_in_schema=PRIVATE_IN_SCHEMA) @@ -186,7 +185,6 @@ async def create_help_command_endpoint( except Exception as e: logger.error(f"Error creating help command: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.put("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -236,7 +234,6 @@ async def update_help_command_endpoint( except Exception as e: logger.error(f"Error updating help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.patch("/{command_id}/restore", include_in_schema=PRIVATE_IN_SCHEMA) @@ -274,7 +271,6 @@ async def restore_help_command_endpoint( except Exception as e: logger.error(f"Error restoring help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.delete("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @@ -305,7 +301,6 @@ async def delete_help_command_endpoint( except Exception as e: logger.error(f"Error deleting help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.get("/stats") @@ -363,7 +358,6 @@ async def get_help_command_stats(): except Exception as e: logger.error(f"Error getting help command stats: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: # Special endpoints for Discord bot integration @@ -396,7 +390,6 @@ async def get_help_command_by_name_endpoint( except Exception as e: logger.error(f"Error getting help command by name '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.patch("/by_name/{command_name}/view", include_in_schema=PRIVATE_IN_SCHEMA) @@ -432,7 +425,6 @@ async def increment_view_count(command_name: str, token: str = Depends(oauth2_sc except Exception as e: logger.error(f"Error incrementing view count for '{command_name}': {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.get("/autocomplete") @@ -462,7 +454,6 @@ async def get_help_names_for_autocomplete( except Exception as e: logger.error(f"Error getting help names for autocomplete: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: @router.get("/{command_id}") @@ -490,4 +481,3 @@ async def get_help_command(command_id: int): except Exception as e: logger.error(f"Error getting help command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) - finally: