From f9817b3d042a200f35fb93f4279456123c8fbf41 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 00:32:27 -0500 Subject: [PATCH 1/7] feat: add limit/pagination to scout_opportunities endpoint (#148) Closes #148 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/scout_opportunities.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routers_v2/scout_opportunities.py b/app/routers_v2/scout_opportunities.py index 25a3a75..b838d4e 100644 --- a/app/routers_v2/scout_opportunities.py +++ b/app/routers_v2/scout_opportunities.py @@ -5,7 +5,7 @@ from typing import Optional, List import logging import pydantic -from ..db_engine import ScoutOpportunity, ScoutClaim, model_to_dict, fn +from ..db_engine import ScoutOpportunity, ScoutClaim, model_to_dict from ..dependencies import oauth2_scheme, valid_token router = APIRouter(prefix="/api/v2/scout_opportunities", tags=["scout_opportunities"]) @@ -32,8 +32,10 @@ async def get_scout_opportunities( claimed: Optional[bool] = None, expired_before: Optional[int] = None, opener_team_id: Optional[int] = None, + limit: Optional[int] = 100, ): + limit = max(0, min(limit, 500)) query = ScoutOpportunity.select().order_by(ScoutOpportunity.id) if opener_team_id is not None: @@ -50,6 +52,7 @@ async def get_scout_opportunities( else: query = query.where(ScoutOpportunity.id.not_in(claim_subquery)) + query = query.limit(limit) results = [opportunity_to_dict(x, recurse=False) for x in query] return {"count": len(results), "results": results} From e328ad639a00249271566ca96f317afbc7bd5130 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 02:01:50 -0500 Subject: [PATCH 2/7] feat: add limit/pagination to awards endpoint (#132) Add optional limit query param (default 100, max 500) to GET /api/v2/awards. Clamped via max(0, min(limit, 500)) to guard negative values and upper bound. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/awards.py | 97 +++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/app/routers_v2/awards.py b/app/routers_v2/awards.py index 3d79030..89ed4bc 100644 --- a/app/routers_v2/awards.py +++ b/app/routers_v2/awards.py @@ -8,16 +8,13 @@ from ..db_engine import Award, model_to_dict, DoesNotExist from ..dependencies import oauth2_scheme, valid_token, PRIVATE_IN_SCHEMA -router = APIRouter( - prefix='/api/v2/awards', - tags=['awards'] -) +router = APIRouter(prefix="/api/v2/awards", tags=["awards"]) class AwardModel(pydantic.BaseModel): name: str season: int - timing: str = 'In-Season' + timing: str = "In-Season" card_id: Optional[int] = None team_id: Optional[int] = None image: Optional[str] = None @@ -28,15 +25,21 @@ class AwardReturnList(pydantic.BaseModel): awards: list[AwardModel] -@router.get('') +@router.get("") async def get_awards( - name: Optional[str] = None, season: Optional[int] = None, timing: Optional[str] = None, - card_id: Optional[int] = None, team_id: Optional[int] = None, image: Optional[str] = None, - csv: Optional[bool] = None): + name: Optional[str] = None, + season: Optional[int] = None, + timing: Optional[str] = None, + card_id: Optional[int] = None, + team_id: Optional[int] = None, + image: Optional[str] = None, + csv: Optional[bool] = None, + limit: int = 100, +): all_awards = Award.select().order_by(Award.id) if all_awards.count() == 0: - raise HTTPException(status_code=404, detail=f'There are no awards to filter') + raise HTTPException(status_code=404, detail="There are no awards to filter") if name is not None: all_awards = all_awards.where(Award.name == name) @@ -51,53 +54,73 @@ async def get_awards( if image is not None: all_awards = all_awards.where(Award.image == image) + limit = max(0, min(limit, 500)) + all_awards = all_awards.limit(limit) + if csv: - data_list = [['id', 'name', 'season', 'timing', 'card', 'team', 'image']] + data_list = [["id", "name", "season", "timing", "card", "team", "image"]] for line in all_awards: - data_list.append([ - line.id, line.name, line.season, line.timing, line.card, line.team, line.image - ]) + data_list.append( + [ + line.id, + line.name, + line.season, + line.timing, + line.card, + line.team, + line.image, + ] + ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_awards.count(), 'awards': []} + return_val = {"count": all_awards.count(), "awards": []} for x in all_awards: - return_val['awards'].append(model_to_dict(x)) + return_val["awards"].append(model_to_dict(x)) return return_val -@router.get('/{award_id}') +@router.get("/{award_id}") async def get_one_award(award_id, csv: Optional[bool] = None): try: this_award = Award.get_by_id(award_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No award found with id {award_id}') + raise HTTPException( + status_code=404, detail=f"No award found with id {award_id}" + ) if csv: data_list = [ - ['id', 'name', 'season', 'timing', 'card', 'team', 'image'], - [this_award.id, this_award.name, this_award.season, this_award.timing, this_award.card, - this_award.team, this_award.image] + ["id", "name", "season", "timing", "card", "team", "image"], + [ + this_award.id, + this_award.name, + this_award.season, + this_award.timing, + this_award.card, + this_award.team, + this_award.image, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_award) return return_val -@router.post('', include_in_schema=PRIVATE_IN_SCHEMA) +@router.post("", include_in_schema=PRIVATE_IN_SCHEMA) async def post_awards(award: AwardModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post awards. This event has been logged.' + detail="You are not authorized to post awards. This event has been logged.", ) this_award = Award( @@ -106,7 +129,7 @@ async def post_awards(award: AwardModel, token: str = Depends(oauth2_scheme)): timing=award.season, card_id=award.card_id, team_id=award.team_id, - image=award.image + image=award.image, ) saved = this_award.save() @@ -116,28 +139,30 @@ async def post_awards(award: AwardModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that roster' + detail="Well slap my ass and call me a teapot; I could not save that roster", ) -@router.delete('/{award_id}', include_in_schema=PRIVATE_IN_SCHEMA) +@router.delete("/{award_id}", include_in_schema=PRIVATE_IN_SCHEMA) async def delete_award(award_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete awards. This event has been logged.' + detail="You are not authorized to delete awards. This event has been logged.", ) try: this_award = Award.get_by_id(award_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No award found with id {award_id}') + raise HTTPException( + status_code=404, detail=f"No award found with id {award_id}" + ) count = this_award.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Award {award_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Award {award_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Award {award_id} was not deleted') - - + raise HTTPException(status_code=500, detail=f"Award {award_id} was not deleted") From 2f5694272183bb22e0485c7c2c445ba4296b0d4c Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 04:02:46 -0500 Subject: [PATCH 3/7] feat: add limit/pagination to pitchingcardratings endpoint (#136) Closes #136 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/pitchingcardratings.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/routers_v2/pitchingcardratings.py b/app/routers_v2/pitchingcardratings.py index fcff541..294ef14 100644 --- a/app/routers_v2/pitchingcardratings.py +++ b/app/routers_v2/pitchingcardratings.py @@ -143,6 +143,7 @@ async def get_card_ratings( short_output: bool = False, csv: bool = False, cardset_id: list = Query(default=None), + limit: int = 100, token: str = Depends(oauth2_scheme), ): if not valid_token(token): @@ -168,6 +169,8 @@ async def get_card_ratings( ) all_ratings = all_ratings.where(PitchingCardRatings.pitchingcard << set_cards) + all_ratings = all_ratings.limit(max(0, min(limit, 500))) + if csv: return_val = query_to_csv(all_ratings) return Response(content=return_val, media_type="text/csv") @@ -231,10 +234,10 @@ def get_scouting_dfs(cardset_id: list = None): series_list = [ pd.Series( - dict([(x.player.player_id, x.range) for x in positions]), name=f"Range P" + dict([(x.player.player_id, x.range) for x in positions]), name="Range P" ), pd.Series( - dict([(x.player.player_id, x.error) for x in positions]), name=f"Error P" + dict([(x.player.player_id, x.error) for x in positions]), name="Error P" ), ] logging.debug(f"series_list: {series_list}") @@ -274,7 +277,7 @@ async def post_calc_scouting(token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to calculate card ratings." ) - logging.warning(f"Re-calculating pitching ratings\n\n") + logging.warning("Re-calculating pitching ratings\n\n") output = get_scouting_dfs() first = ["player_id", "player_name", "cardset_name", "rarity", "hand", "variant"] @@ -310,7 +313,7 @@ async def post_calc_basic(token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to calculate basic ratings." ) - logging.warning(f"Re-calculating basic pitching ratings\n\n") + logging.warning("Re-calculating basic pitching ratings\n\n") raw_data = get_scouting_dfs() logging.debug(f"output: {raw_data}") From 15aac6cb73ffc05d1694ed288ca28770143191d9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 05:02:35 -0500 Subject: [PATCH 4/7] feat: add limit/pagination to results endpoint (#137) Closes #137 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/results.py | 283 ++++++++++++++++++++++++++------------ 1 file changed, 198 insertions(+), 85 deletions(-) diff --git a/app/routers_v2/results.py b/app/routers_v2/results.py index cabfcc5..4e41f7b 100644 --- a/app/routers_v2/results.py +++ b/app/routers_v2/results.py @@ -8,10 +8,7 @@ from ..db_engine import Result, model_to_dict, Team, DataError, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/results', - tags=['results'] -) +router = APIRouter(prefix="/api/v2/results", tags=["results"]) class ResultModel(pydantic.BaseModel): @@ -31,15 +28,29 @@ class ResultModel(pydantic.BaseModel): game_type: str -@router.get('') +@router.get("") async def get_results( - away_team_id: Optional[int] = None, home_team_id: Optional[int] = None, team_one_id: Optional[int] = None, - team_two_id: Optional[int] = None, away_score_min: Optional[int] = None, away_score_max: Optional[int] = None, - home_score_min: Optional[int] = None, home_score_max: Optional[int] = None, bothscore_min: Optional[int] = None, - bothscore_max: Optional[int] = None, season: Optional[int] = None, week: Optional[int] = None, - week_start: Optional[int] = None, week_end: Optional[int] = None, ranked: Optional[bool] = None, - short_game: Optional[bool] = None, game_type: Optional[str] = None, vs_ai: Optional[bool] = None, - csv: Optional[bool] = None): + away_team_id: Optional[int] = None, + home_team_id: Optional[int] = None, + team_one_id: Optional[int] = None, + team_two_id: Optional[int] = None, + away_score_min: Optional[int] = None, + away_score_max: Optional[int] = None, + home_score_min: Optional[int] = None, + home_score_max: Optional[int] = None, + bothscore_min: Optional[int] = None, + bothscore_max: Optional[int] = None, + season: Optional[int] = None, + week: Optional[int] = None, + week_start: Optional[int] = None, + week_end: Optional[int] = None, + ranked: Optional[bool] = None, + short_game: Optional[bool] = None, + game_type: Optional[str] = None, + vs_ai: Optional[bool] = None, + csv: Optional[bool] = None, + limit: int = 100, +): all_results = Result.select() # if all_results.count() == 0: @@ -51,28 +62,40 @@ async def get_results( this_team = Team.get_by_id(away_team_id) all_results = all_results.where(Result.away_team == this_team) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {away_team_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {away_team_id}" + ) if home_team_id is not None: try: this_team = Team.get_by_id(home_team_id) all_results = all_results.where(Result.home_team == this_team) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {home_team_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {home_team_id}" + ) if team_one_id is not None: try: this_team = Team.get_by_id(team_one_id) - all_results = all_results.where((Result.home_team == this_team) | (Result.away_team == this_team)) + all_results = all_results.where( + (Result.home_team == this_team) | (Result.away_team == this_team) + ) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {team_one_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {team_one_id}" + ) if team_two_id is not None: try: this_team = Team.get_by_id(team_two_id) - all_results = all_results.where((Result.home_team == this_team) | (Result.away_team == this_team)) + all_results = all_results.where( + (Result.home_team == this_team) | (Result.away_team == this_team) + ) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {team_two_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {team_two_id}" + ) if away_score_min is not None: all_results = all_results.where(Result.away_score >= away_score_min) @@ -87,10 +110,14 @@ async def get_results( all_results = all_results.where(Result.home_score <= home_score_max) if bothscore_min is not None: - all_results = all_results.where((Result.home_score >= bothscore_min) & (Result.away_score >= bothscore_min)) + all_results = all_results.where( + (Result.home_score >= bothscore_min) & (Result.away_score >= bothscore_min) + ) if bothscore_max is not None: - all_results = all_results.where((Result.home_score <= bothscore_max) & (Result.away_score <= bothscore_max)) + all_results = all_results.where( + (Result.home_score <= bothscore_max) & (Result.away_score <= bothscore_max) + ) if season is not None: all_results = all_results.where(Result.season == season) @@ -114,6 +141,8 @@ async def get_results( all_results = all_results.where(Result.game_type == game_type) all_results = all_results.order_by(Result.id) + limit = max(0, min(limit, 500)) + all_results = all_results.limit(limit) # Not functional # if vs_ai is not None: # AwayTeam = Team.alias() @@ -134,60 +163,115 @@ async def get_results( # logging.info(f'Result Query:\n\n{all_results}') if csv: - data_list = [['id', 'away_abbrev', 'home_abbrev', 'away_score', 'home_score', 'away_tv', 'home_tv', - 'game_type', 'season', 'week', 'short_game', 'ranked']] + data_list = [ + [ + "id", + "away_abbrev", + "home_abbrev", + "away_score", + "home_score", + "away_tv", + "home_tv", + "game_type", + "season", + "week", + "short_game", + "ranked", + ] + ] for line in all_results: - data_list.append([ - line.id, line.away_team.abbrev, line.home_team.abbrev, line.away_score, line.home_score, - line.away_team_value, line.home_team_value, line.game_type if line.game_type else 'minor-league', - line.season, line.week, line.short_game, line.ranked - ]) + data_list.append( + [ + line.id, + line.away_team.abbrev, + line.home_team.abbrev, + line.away_score, + line.home_score, + line.away_team_value, + line.home_team_value, + line.game_type if line.game_type else "minor-league", + line.season, + line.week, + line.short_game, + line.ranked, + ] + ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_results.count(), 'results': []} + return_val = {"count": all_results.count(), "results": []} for x in all_results: - return_val['results'].append(model_to_dict(x)) + return_val["results"].append(model_to_dict(x)) return return_val -@router.get('/{result_id}') +@router.get("/{result_id}") async def get_one_results(result_id, csv: Optional[bool] = None): try: this_result = Result.get_by_id(result_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No result found with id {result_id}') + raise HTTPException( + status_code=404, detail=f"No result found with id {result_id}" + ) if csv: data_list = [ - ['id', 'away_abbrev', 'home_abbrev', 'away_score', 'home_score', 'away_tv', 'home_tv', 'game_type', - 'season', 'week', 'game_type'], - [this_result.id, this_result.away_team.abbrev, this_result.away_team.abbrev, this_result.away_score, - this_result.home_score, this_result.away_team_value, this_result.home_team_value, - this_result.game_type if this_result.game_type else 'minor-league', - this_result.season, this_result.week, this_result.game_type] + [ + "id", + "away_abbrev", + "home_abbrev", + "away_score", + "home_score", + "away_tv", + "home_tv", + "game_type", + "season", + "week", + "game_type", + ], + [ + this_result.id, + this_result.away_team.abbrev, + this_result.away_team.abbrev, + this_result.away_score, + this_result.home_score, + this_result.away_team_value, + this_result.home_team_value, + this_result.game_type if this_result.game_type else "minor-league", + this_result.season, + this_result.week, + this_result.game_type, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_result) return return_val -@router.get('/team/{team_id}') +@router.get("/team/{team_id}") async def get_team_results( - team_id: int, season: Optional[int] = None, week: Optional[int] = None, csv: Optional[bool] = False): - all_results = Result.select().where((Result.away_team_id == team_id) | (Result.home_team_id == team_id)).order_by(Result.id) + team_id: int, + season: Optional[int] = None, + week: Optional[int] = None, + csv: Optional[bool] = False, +): + all_results = ( + Result.select() + .where((Result.away_team_id == team_id) | (Result.home_team_id == team_id)) + .order_by(Result.id) + ) try: this_team = Team.get_by_id(team_id) - except DoesNotExist as e: - logging.error(f'Unknown team id {team_id} trying to pull team results') - raise HTTPException(404, f'Team id {team_id} not found') + except DoesNotExist: + logging.error(f"Unknown team id {team_id} trying to pull team results") + raise HTTPException(404, f"Team id {team_id} not found") if season is not None: all_results = all_results.where(Result.season == season) @@ -224,31 +308,38 @@ async def get_team_results( if csv: data_list = [ - ['team_id', 'ranked_wins', 'ranked_losses', 'casual_wins', 'casual_losses', 'team_ranking'], - [team_id, r_wins, r_loss, c_wins, c_loss, this_team.ranking] + [ + "team_id", + "ranked_wins", + "ranked_losses", + "casual_wins", + "casual_losses", + "team_ranking", + ], + [team_id, r_wins, r_loss, c_wins, c_loss, this_team.ranking], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = { - 'team': model_to_dict(this_team), - 'ranked_wins': r_wins, - 'ranked_losses': r_loss, - 'casual_wins': c_wins, - 'casual_losses': c_loss, + "team": model_to_dict(this_team), + "ranked_wins": r_wins, + "ranked_losses": r_loss, + "casual_wins": c_wins, + "casual_losses": c_loss, } return return_val -@router.post('') +@router.post("") async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post results. This event has been logged.' + detail="You are not authorized to post results. This event has been logged.", ) this_result = Result(**result.__dict__) @@ -256,24 +347,28 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): if result.ranked: if not result.away_team_ranking: - error = f'Ranked game did not include away team ({result.away_team_id}) ranking.' + error = f"Ranked game did not include away team ({result.away_team_id}) ranking." logging.error(error) raise DataError(error) if not result.home_team_ranking: - error = f'Ranked game did not include home team ({result.home_team_id}) ranking.' + error = f"Ranked game did not include home team ({result.home_team_id}) ranking." logging.error(error) raise DataError(error) k_value = 20 if result.short_game else 60 ratio = (result.home_team_ranking - result.away_team_ranking) / 400 - exp_score = 1 / (1 + (10 ** ratio)) + exp_score = 1 / (1 + (10**ratio)) away_win = True if result.away_score > result.home_score else False total_delta = k_value * exp_score - high_delta = total_delta * exp_score if exp_score > .5 else total_delta * (1 - exp_score) + high_delta = ( + total_delta * exp_score + if exp_score > 0.5 + else total_delta * (1 - exp_score) + ) low_delta = total_delta - high_delta # exp_score > .5 means away team is favorite - if exp_score > .5 and away_win: + if exp_score > 0.5 and away_win: final_delta = low_delta away_delta = low_delta * 3 home_delta = -low_delta @@ -281,7 +376,7 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): final_delta = high_delta away_delta = high_delta * 3 home_delta = -high_delta - elif exp_score <= .5 and not away_win: + elif exp_score <= 0.5 and not away_win: final_delta = low_delta away_delta = -low_delta home_delta = low_delta * 3 @@ -294,18 +389,20 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): away_delta = 0 home_delta = 0 - logging.debug(f'/results ranking deltas\n\nk_value: {k_value} / ratio: {ratio} / ' - f'exp_score: {exp_score} / away_win: {away_win} / total_delta: {total_delta} / ' - f'high_delta: {high_delta} / low_delta: {low_delta} / final_delta: {final_delta} / ') + logging.debug( + f"/results ranking deltas\n\nk_value: {k_value} / ratio: {ratio} / " + f"exp_score: {exp_score} / away_win: {away_win} / total_delta: {total_delta} / " + f"high_delta: {high_delta} / low_delta: {low_delta} / final_delta: {final_delta} / " + ) away_team = Team.get_by_id(result.away_team_id) away_team.ranking += away_delta away_team.save() - logging.info(f'Just updated {away_team.abbrev} ranking to {away_team.ranking}') + logging.info(f"Just updated {away_team.abbrev} ranking to {away_team.ranking}") home_team = Team.get_by_id(result.home_team_id) home_team.ranking += home_delta home_team.save() - logging.info(f'Just updated {home_team.abbrev} ranking to {home_team.ranking}') + logging.info(f"Just updated {home_team.abbrev} ranking to {home_team.ranking}") if saved == 1: return_val = model_to_dict(this_result) @@ -313,27 +410,38 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that roster' + detail="Well slap my ass and call me a teapot; I could not save that roster", ) -@router.patch('/{result_id}') +@router.patch("/{result_id}") async def patch_result( - result_id, away_team_id: Optional[int] = None, home_team_id: Optional[int] = None, - away_score: Optional[int] = None, home_score: Optional[int] = None, away_team_value: Optional[int] = None, - home_team_value: Optional[int] = None, scorecard: Optional[str] = None, week: Optional[int] = None, - season: Optional[int] = None, short_game: Optional[bool] = None, game_type: Optional[str] = None, - token: str = Depends(oauth2_scheme)): + result_id, + away_team_id: Optional[int] = None, + home_team_id: Optional[int] = None, + away_score: Optional[int] = None, + home_score: Optional[int] = None, + away_team_value: Optional[int] = None, + home_team_value: Optional[int] = None, + scorecard: Optional[str] = None, + week: Optional[int] = None, + season: Optional[int] = None, + short_game: Optional[bool] = None, + game_type: Optional[str] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch results. This event has been logged.' + detail="You are not authorized to patch results. This event has been logged.", ) try: this_result = Result.get_by_id(result_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No result found with id {result_id}') + raise HTTPException( + status_code=404, detail=f"No result found with id {result_id}" + ) if away_team_id is not None: this_result.away_team_id = away_team_id @@ -377,27 +485,32 @@ async def patch_result( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that event' + detail="Well slap my ass and call me a teapot; I could not save that event", ) -@router.delete('/{result_id}') +@router.delete("/{result_id}") async def delete_result(result_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post results. This event has been logged.' + detail="You are not authorized to post results. This event has been logged.", ) try: this_result = Result.get_by_id(result_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No result found with id {result_id}') + raise HTTPException( + status_code=404, detail=f"No result found with id {result_id}" + ) count = this_result.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Result {result_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Result {result_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Result {result_id} was not deleted') - + raise HTTPException( + status_code=500, detail=f"Result {result_id} was not deleted" + ) From 4f693b122810adf9d358fdb3b90fdcfc6bd82cbd Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 05:32:28 -0500 Subject: [PATCH 5/7] feat: add limit/pagination to stratgame (games) endpoint (#138) Closes #138 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/stratgame.py | 113 ++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/app/routers_v2/stratgame.py b/app/routers_v2/stratgame.py index 1a3fa7b..73f44cf 100644 --- a/app/routers_v2/stratgame.py +++ b/app/routers_v2/stratgame.py @@ -8,10 +8,7 @@ from ..db_engine import StratGame, model_to_dict, fn from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/games', - tags=['games'] -) +router = APIRouter(prefix="/api/v2/games", tags=["games"]) class GameModel(pydantic.BaseModel): @@ -35,13 +32,22 @@ class GameList(pydantic.BaseModel): games: List[GameModel] -@router.get('') +@router.get("") async def get_games( - season: list = Query(default=None), forfeit: Optional[bool] = None, away_team_id: list = Query(default=None), - home_team_id: list = Query(default=None), team1_id: list = Query(default=None), - team2_id: list = Query(default=None), game_type: list = Query(default=None), ranked: Optional[bool] = None, - short_game: Optional[bool] = None, csv: Optional[bool] = False, short_output: bool = False, - gauntlet_id: Optional[int] = None): + season: list = Query(default=None), + forfeit: Optional[bool] = None, + away_team_id: list = Query(default=None), + home_team_id: list = Query(default=None), + team1_id: list = Query(default=None), + team2_id: list = Query(default=None), + game_type: list = Query(default=None), + ranked: Optional[bool] = None, + short_game: Optional[bool] = None, + csv: Optional[bool] = False, + short_output: bool = False, + gauntlet_id: Optional[int] = None, + limit: int = 100, +): all_games = StratGame.select().order_by(StratGame.id) if season is not None: @@ -68,49 +74,70 @@ async def get_games( if short_game is not None: all_games = all_games.where(StratGame.short_game == short_game) if gauntlet_id is not None: - all_games = all_games.where(StratGame.game_type.contains(f'gauntlet-{gauntlet_id}')) + all_games = all_games.where( + StratGame.game_type.contains(f"gauntlet-{gauntlet_id}") + ) + + all_games = all_games.limit(max(0, min(limit, 500))) if csv: return_vals = [model_to_dict(x) for x in all_games] for x in return_vals: - x['away_abbrev'] = x['away_team']['abbrev'] - x['home_abbrev'] = x['home_team']['abbrev'] - del x['away_team'], x['home_team'] + x["away_abbrev"] = x["away_team"]["abbrev"] + x["home_abbrev"] = x["home_team"]["abbrev"] + del x["away_team"], x["home_team"] - output = pd.DataFrame(return_vals)[[ - 'id', 'away_abbrev', 'home_abbrev', 'away_score', 'home_score', 'away_team_value', 'home_team_value', - 'game_type', 'season', 'week', 'short_game', 'ranked' - ]] + output = pd.DataFrame(return_vals)[ + [ + "id", + "away_abbrev", + "home_abbrev", + "away_score", + "home_score", + "away_team_value", + "home_team_value", + "game_type", + "season", + "week", + "short_game", + "ranked", + ] + ] - return Response(content=output.to_csv(index=False), media_type='text/csv') + return Response(content=output.to_csv(index=False), media_type="text/csv") - return_val = {'count': all_games.count(), 'games': [ - model_to_dict(x, recurse=not short_output) for x in all_games - ]} + return_val = { + "count": all_games.count(), + "games": [model_to_dict(x, recurse=not short_output) for x in all_games], + } return return_val -@router.get('/{game_id}') +@router.get("/{game_id}") async def get_one_game(game_id: int): this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - raise HTTPException(status_code=404, detail=f'StratGame ID {game_id} not found') + raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") g_result = model_to_dict(this_game) return g_result -@router.patch('/{game_id}') +@router.patch("/{game_id}") async def patch_game( - game_id: int, game_type: Optional[str] = None, away_score: Optional[int] = None, - home_score: Optional[int] = None, token: str = Depends(oauth2_scheme)): + game_id: int, + game_type: Optional[str] = None, + away_score: Optional[int] = None, + home_score: Optional[int] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('patch_game - Bad Token: [REDACTED]') - raise HTTPException(status_code=401, detail='Unauthorized') + logging.warning("patch_game - Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - raise HTTPException(status_code=404, detail=f'StratGame ID {game_id} not found') + raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") if away_score is not None: this_game.away_score = away_score @@ -123,14 +150,14 @@ async def patch_game( g_result = model_to_dict(this_game) return g_result else: - raise HTTPException(status_code=500, detail=f'Unable to patch game {game_id}') + raise HTTPException(status_code=500, detail=f"Unable to patch game {game_id}") -@router.post('') +@router.post("") async def post_game(this_game: GameModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('post_games - Bad Token: [REDACTED]') - raise HTTPException(status_code=401, detail='Unauthorized') + logging.warning("post_games - Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") this_game = StratGame(**this_game.dict()) @@ -141,25 +168,25 @@ async def post_game(this_game: GameModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that game' + detail="Well slap my ass and call me a teapot; I could not save that game", ) -@router.delete('/{game_id}') +@router.delete("/{game_id}") async def delete_game(game_id: int, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('delete_game - Bad Token: [REDACTED]') - raise HTTPException(status_code=401, detail='Unauthorized') + logging.warning("delete_game - Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - raise HTTPException(status_code=404, detail=f'StratGame ID {game_id} not found') + raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") count = this_game.delete_instance() if count == 1: - return f'StratGame {game_id} has been deleted' + return f"StratGame {game_id} has been deleted" else: - raise HTTPException(status_code=500, detail=f'StratGame {game_id} could not be deleted') - - + raise HTTPException( + status_code=500, detail=f"StratGame {game_id} could not be deleted" + ) From 77179d3c9cb184f457c942fc198ca7528340e20a Mon Sep 17 00:00:00 2001 From: cal Date: Tue, 24 Mar 2026 12:06:37 +0000 Subject: [PATCH 6/7] fix: clamp limit lower bound to 1 to prevent silent empty responses Addresses reviewer feedback: max(0,...) admitted limit=0 which would silently return no results even when matching records exist. Changed to max(1,...) consistent with feedback on PRs #149 and #152. --- app/routers_v2/scout_opportunities.py | 127 +------------------------- 1 file changed, 1 insertion(+), 126 deletions(-) diff --git a/app/routers_v2/scout_opportunities.py b/app/routers_v2/scout_opportunities.py index b838d4e..0be0e63 100644 --- a/app/routers_v2/scout_opportunities.py +++ b/app/routers_v2/scout_opportunities.py @@ -1,126 +1 @@ -import json -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from typing import Optional, List -import logging -import pydantic - -from ..db_engine import ScoutOpportunity, ScoutClaim, model_to_dict -from ..dependencies import oauth2_scheme, valid_token - -router = APIRouter(prefix="/api/v2/scout_opportunities", tags=["scout_opportunities"]) - - -class ScoutOpportunityModel(pydantic.BaseModel): - pack_id: Optional[int] = None - opener_team_id: int - card_ids: List[int] - expires_at: int - created: Optional[int] = None - - -def opportunity_to_dict(opp, recurse=True): - """Convert a ScoutOpportunity to dict with card_ids deserialized.""" - result = model_to_dict(opp, recurse=recurse) - if isinstance(result.get("card_ids"), str): - result["card_ids"] = json.loads(result["card_ids"]) - return result - - -@router.get("") -async def get_scout_opportunities( - claimed: Optional[bool] = None, - expired_before: Optional[int] = None, - opener_team_id: Optional[int] = None, - limit: Optional[int] = 100, -): - - limit = max(0, min(limit, 500)) - query = ScoutOpportunity.select().order_by(ScoutOpportunity.id) - - if opener_team_id is not None: - query = query.where(ScoutOpportunity.opener_team_id == opener_team_id) - - if expired_before is not None: - query = query.where(ScoutOpportunity.expires_at < expired_before) - - if claimed is not None: - # Check whether any scout_claims exist for each opportunity - claim_subquery = ScoutClaim.select(ScoutClaim.scout_opportunity) - if claimed: - query = query.where(ScoutOpportunity.id.in_(claim_subquery)) - else: - query = query.where(ScoutOpportunity.id.not_in(claim_subquery)) - - query = query.limit(limit) - results = [opportunity_to_dict(x, recurse=False) for x in query] - return {"count": len(results), "results": results} - - -@router.get("/{opportunity_id}") -async def get_one_scout_opportunity(opportunity_id: int): - try: - opp = ScoutOpportunity.get_by_id(opportunity_id) - except Exception: - raise HTTPException( - status_code=404, - detail=f"No scout opportunity found with id {opportunity_id}", - ) - - return opportunity_to_dict(opp) - - -@router.post("") -async def post_scout_opportunity( - opportunity: ScoutOpportunityModel, token: str = Depends(oauth2_scheme) -): - if not valid_token(token): - logging.warning(f"Bad Token: {token}") - raise HTTPException( - status_code=401, - detail="You are not authorized to post scout opportunities. This event has been logged.", - ) - - opp_data = opportunity.dict() - opp_data["card_ids"] = json.dumps(opp_data["card_ids"]) - if opp_data["created"] is None: - opp_data["created"] = int(datetime.timestamp(datetime.now()) * 1000) - - this_opp = ScoutOpportunity(**opp_data) - saved = this_opp.save() - - if saved == 1: - return opportunity_to_dict(this_opp) - else: - raise HTTPException(status_code=418, detail="Could not save scout opportunity") - - -@router.delete("/{opportunity_id}") -async def delete_scout_opportunity( - opportunity_id: int, token: str = Depends(oauth2_scheme) -): - if not valid_token(token): - logging.warning(f"Bad Token: {token}") - raise HTTPException( - status_code=401, - detail="You are not authorized to delete scout opportunities. This event has been logged.", - ) - try: - opp = ScoutOpportunity.get_by_id(opportunity_id) - except Exception: - raise HTTPException( - status_code=404, - detail=f"No scout opportunity found with id {opportunity_id}", - ) - - count = opp.delete_instance() - if count == 1: - raise HTTPException( - status_code=200, - detail=f"Scout opportunity {opportunity_id} has been deleted", - ) - else: - raise HTTPException( - status_code=500, - detail=f"Scout opportunity {opportunity_id} was not deleted", - ) +aW1wb3J0IGpzb24KZnJvbSBkYXRldGltZSBpbXBvcnQgZGF0ZXRpbWUKZnJvbSBmYXN0YXBpIGltcG9ydCBBUElSb3V0ZXIsIERlcGVuZHMsIEhUVFBFeGNlcHRpb24KZnJvbSB0eXBpbmcgaW1wb3J0IE9wdGlvbmFsLCBMaXN0CmltcG9ydCBsb2dnaW5nCmltcG9ydCBweWRhbnRpYwoKZnJvbSAuLmRiX2VuZ2luZSBpbXBvcnQgU2NvdXRPcHBvcnR1bml0eSwgU2NvdXRDbGFpbSwgbW9kZWxfdG9fZGljdApmcm9tIC4uZGVwZW5kZW5jaWVzIGltcG9ydCBvYXV0aDJfc2NoZW1lLCB2YWxpZF90b2tlbgoKcm91dGVyID0gQVBJUm91dGVyKHByZWZpeD0iL2FwaS92Mi9zY291dF9vcHBvcnR1bml0aWVzIiwgdGFncz1bInNjb3V0X29wcG9ydHVuaXRpZXMiXSkKCgpjbGFzcyBTY291dE9wcG9ydHVuaXR5TW9kZWwocHlkYW50aWMuQmFzZU1vZGVsKToKICAgIHBhY2tfaWQ6IE9wdGlvbmFsW2ludF0gPSBOb25lCiAgICBvcGVuZXJfdGVhbV9pZDogaW50CiAgICBjYXJkX2lkczogTGlzdFtpbnRdCiAgICBleHBpcmVzX2F0OiBpbnQKICAgIGNyZWF0ZWQ6IE9wdGlvbmFsW2ludF0gPSBOb25lCgoKZGVmIG9wcG9ydHVuaXR5X3RvX2RpY3Qob3BwLCByZWN1cnNlPVRydWUpOgogICAgIiIiQ29udmVydCBhIFNjb3V0T3Bwb3J0dW5pdHkgdG8gZGljdCB3aXRoIGNhcmRfaWRzIGRlc2VyaWFsaXplZC4iIiIKICAgIHJlc3VsdCA9IG1vZGVsX3RvX2RpY3Qob3BwLCByZWN1cnNlPXJlY3Vyc2UpCiAgICBpZiBpc2luc3RhbmNlKHJlc3VsdC5nZXQoImNhcmRfaWRzIiksIHN0cik6CiAgICAgICAgcmVzdWx0WyJjYXJkX2lkcyJdID0ganNvbi5sb2FkcyhyZXN1bHRbImNhcmRfaWRzIl0pCiAgICByZXR1cm4gcmVzdWx0CgoKQHJvdXRlci5nZXQoIiIpCmFzeW5jIGRlZiBnZXRfc2NvdXRfb3Bwb3J0dW5pdGllcygKICAgIGNsYWltZWQ6IE9wdGlvbmFsW2Jvb2xdID0gTm9uZSwKICAgIGV4cGlyZWRfYmVmb3JlOiBPcHRpb25hbFtpbnRdID0gTm9uZSwKICAgIG9wZW5lcl90ZWFtX2lkOiBPcHRpb25hbFtpbnRdID0gTm9uZSwKICAgIGxpbWl0OiBPcHRpb25hbFtpbnRdID0gMTAwLAopOgoKICAgIGxpbWl0ID0gbWF4KDEsIG1pbihsaW1pdCwgNTAwKSkKICAgIHF1ZXJ5ID0gU2NvdXRPcHBvcnR1bml0eS5zZWxlY3QoKS5vcmRlcl9ieShTY291dE9wcG9ydHVuaXR5LmlkKQoKICAgIGlmIG9wZW5lcl90ZWFtX2lkIGlzIG5vdCBOb25lOgogICAgICAgIHF1ZXJ5ID0gcXVlcnkud2hlcmUoU2NvdXRPcHBvcnR1bml0eS5vcGVuZXJfdGVhbV9pZCA9PSBvcGVuZXJfdGVhbV9pZCkKCiAgICBpZiBleHBpcmVkX2JlZm9yZSBpcyBub3QgTm9uZToKICAgICAgICBxdWVyeSA9IHF1ZXJ5LndoZXJlKFNjb3V0T3Bwb3J0dW5pdHkuZXhwaXJlc19hdCA8IGV4cGlyZWRfYmVmb3JlKQoKICAgIGlmIGNsYWltZWQgaXMgbm90IE5vbmU6CiAgICAgICAgIyBDaGVjayB3aGV0aGVyIGFueSBzY291dF9jbGFpbXMgZXhpc3QgZm9yIGVhY2ggb3Bwb3J0dW5pdHkKICAgICAgICBjbGFpbV9zdWJxdWVyeSA9IFNjb3V0Q2xhaW0uc2VsZWN0KFNjb3V0Q2xhaW0uc2NvdXRfb3Bwb3J0dW5pdHkpCiAgICAgICAgaWYgY2xhaW1lZDoKICAgICAgICAgICAgcXVlcnkgPSBxdWVyeS53aGVyZShTY291dE9wcG9ydHVuaXR5LmlkLmluXyhjbGFpbV9zdWJxdWVyeSkpCiAgICAgICAgZWxzZToKICAgICAgICAgICAgcXVlcnkgPSBxdWVyeS53aGVyZShTY291dE9wcG9ydHVuaXR5LmlkLm5vdF9pbihjbGFpbV9zdWJxdWVyeSkpCgogICAgcXVlcnkgPSBxdWVyeS5saW1pdChsaW1pdCkKICAgIHJlc3VsdHMgPSBbb3Bwb3J0dW5pdHlfdG9fZGljdCh4LCByZWN1cnNlPUZhbHNlKSBmb3IgeCBpbiBxdWVyeV0KICAgIHJldHVybiB7ImNvdW50IjogbGVuKHJlc3VsdHMpLCAicmVzdWx0cyI6IHJlc3VsdHN9CgoKQHJvdXRlci5nZXQoIi97b3Bwb3J0dW5pdHlfaWR9IikKYXN5bmMgZGVmIGdldF9vbmVfc2NvdXRfb3Bwb3J0dW5pdHkob3Bwb3J0dW5pdHlfaWQ6IGludCk6CiAgICB0cnk6CiAgICAgICAgb3BwID0gU2NvdXRPcHBvcnR1bml0eS5nZXRfYnlfaWQob3Bwb3J0dW5pdHlfaWQpCiAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTQwNCwKICAgICAgICAgICAgZGV0YWlsPWYiTm8gc2NvdXQgb3Bwb3J0dW5pdHkgZm91bmQgd2l0aCBpZCB7b3Bwb3J0dW5pdHlfaWR9IiwKICAgICAgICApCgogICAgcmV0dXJuIG9wcG9ydHVuaXR5X3RvX2RpY3Qob3BwKQoKCkByb3V0ZXIucG9zdCgiIikKYXN5bmMgZGVmIHBvc3Rfc2NvdXRfb3Bwb3J0dW5pdHkoCiAgICBvcHBvcnR1bml0eTogU2NvdXRPcHBvcnR1bml0eU1vZGVsLCB0b2tlbjogc3RyID0gRGVwZW5kcyhvYXV0aDJfc2NoZW1lKQopOgogICAgaWYgbm90IHZhbGlkX3Rva2VuKHRva2VuKToKICAgICAgICBsb2dnaW5nLndhcm5pbmcoZiJCYWQgVG9rZW46IHt0b2tlbn0iKQogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTQwMSwKICAgICAgICAgICAgZGV0YWlsPSJZb3UgYXJlIG5vdCBhdXRob3JpemVkIHRvIHBvc3Qgc2NvdXQgb3Bwb3J0dW5pdGllcy4gVGhpcyBldmVudCBoYXMgYmVlbiBsb2dnZWQuIiwKICAgICAgICApCgogICAgb3BwX2RhdGEgPSBvcHBvcnR1bml0eS5kaWN0KCkKICAgIG9wcF9kYXRhWyJjYXJkX2lkcyJdID0ganNvbi5kdW1wcyhvcHBfZGF0YVsiY2FyZF9pZHMiXSkKICAgIGlmIG9wcF9kYXRhWyJjcmVhdGVkIl0gaXMgTm9uZToKICAgICAgICBvcHBfZGF0YVsiY3JlYXRlZCJdID0gaW50KGRhdGV0aW1lLnRpbWVzdGFtcChkYXRldGltZS5ub3coKSkgKiAxMDAwKQoKICAgIHRoaXNfb3BwID0gU2NvdXRPcHBvcnR1bml0eSgqKm9wcF9kYXRhKQogICAgc2F2ZWQgPSB0aGlzX29wcC5zYXZlKCkKCiAgICBpZiBzYXZlZCA9PSAxOgogICAgICAgIHJldHVybiBvcHBvcnR1bml0eV90b19kaWN0KHRoaXNfb3BwKQogICAgZWxzZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKHN0YXR1c19jb2RlPTQxOCwgZGV0YWlsPSJDb3VsZCBub3Qgc2F2ZSBzY291dCBvcHBvcnR1bml0eSIpCgoKQHJvdXRlci5kZWxldGUoIi97b3Bwb3J0dW5pdHlfaWR9IikKYXN5bmMgZGVmIGRlbGV0ZV9zY291dF9vcHBvcnR1bml0eSgKICAgIG9wcG9ydHVuaXR5X2lkOiBpbnQsIHRva2VuOiBzdHIgPSBEZXBlbmRzKG9hdXRoMl9zY2hlbWUpCik6CiAgICBpZiBub3QgdmFsaWRfdG9rZW4odG9rZW4pOgogICAgICAgIGxvZ2dpbmcud2FybmluZyhmIkJhZCBUb2tlbjoge3Rva2VufSIpCiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbigKICAgICAgICAgICAgc3RhdHVzX2NvZGU9NDAxLAogICAgICAgICAgICBkZXRhaWw9IllvdSBhcmUgbm90IGF1dGhvcml6ZWQgdG8gZGVsZXRlIHNjb3V0IG9wcG9ydHVuaXRpZXMuIFRoaXMgZXZlbnQgaGFzIGJlZW4gbG9nZ2VkLiIsCiAgICAgICAgKQogICAgdHJ5OgogICAgICAgIG9wcCA9IFNjb3V0T3Bwb3J0dW5pdHkuZ2V0X2J5X2lkKG9wcG9ydHVuaXR5X2lkKQogICAgZXhjZXB0IEV4Y2VwdGlvbjoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKAogICAgICAgICAgICBzdGF0dXNfY29kZT00MDQsCiAgICAgICAgICAgIGRldGFpbD1mIk5vIHNjb3V0IG9wcG9ydHVuaXR5IGZvdW5kIHdpdGggaWQge29wcG9ydHVuaXR5X2lkfSIsCiAgICAgICAgKQoKICAgIGNvdW50ID0gb3BwLmRlbGV0ZV9pbnN0YW5jZSgpCiAgICBpZiBjb3VudCA9PSAxOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTIwMCwKICAgICAgICAgICAgZGV0YWlsPWYiU2NvdXQgb3Bwb3J0dW5pdHkge29wcG9ydHVuaXR5X2lkfSBoYXMgYmVlbiBkZWxldGVkIiwKICAgICAgICApCiAgICBlbHNlOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTUwMCwKICAgICAgICAgICAgZGV0YWlsPWYiU2NvdXQgb3Bwb3J0dW5pdHkge29wcG9ydHVuaXR5X2lkfSB3YXMgbm90IGRlbGV0ZWQiLAogICAgICAgICkK \ No newline at end of file From e7fcf611da297be61b7df6cbfc22429bfcbec38d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 03:32:22 -0500 Subject: [PATCH 7/7] feat: add limit/pagination to gauntletruns endpoint (#146) Closes #146 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/gauntletruns.py | 95 ++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/app/routers_v2/gauntletruns.py b/app/routers_v2/gauntletruns.py index cc85eeb..cc196e9 100644 --- a/app/routers_v2/gauntletruns.py +++ b/app/routers_v2/gauntletruns.py @@ -8,10 +8,7 @@ from ..db_engine import GauntletRun, model_to_dict, DatabaseError, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/gauntletruns', - tags=['notifs'] -) +router = APIRouter(prefix="/api/v2/gauntletruns", tags=["notifs"]) class GauntletRunModel(pydantic.BaseModel): @@ -24,13 +21,25 @@ class GauntletRunModel(pydantic.BaseModel): ended: Optional[int] = None -@router.get('') +@router.get("") async def get_gauntletruns( - team_id: list = Query(default=None), wins: Optional[int] = None, wins_min: Optional[int] = None, - wins_max: Optional[int] = None, losses: Optional[int] = None, losses_min: Optional[int] = None, - losses_max: Optional[int] = None, gsheet: Optional[str] = None, created_after: Optional[int] = None, - created_before: Optional[int] = None, ended_after: Optional[int] = None, ended_before: Optional[int] = None, - is_active: Optional[bool] = None, gauntlet_id: list = Query(default=None), season: list = Query(default=None)): + team_id: list = Query(default=None), + wins: Optional[int] = None, + wins_min: Optional[int] = None, + wins_max: Optional[int] = None, + losses: Optional[int] = None, + losses_min: Optional[int] = None, + losses_max: Optional[int] = None, + gsheet: Optional[str] = None, + created_after: Optional[int] = None, + created_before: Optional[int] = None, + ended_after: Optional[int] = None, + ended_before: Optional[int] = None, + is_active: Optional[bool] = None, + gauntlet_id: list = Query(default=None), + season: list = Query(default=None), + limit: int = 100, +): all_gauntlets = GauntletRun.select().order_by(GauntletRun.id) if team_id is not None: @@ -73,39 +82,48 @@ async def get_gauntletruns( if season is not None: all_gauntlets = all_gauntlets.where(GauntletRun.team.season << season) - return_val = {'count': all_gauntlets.count(), 'runs': []} - for x in all_gauntlets: - return_val['runs'].append(model_to_dict(x)) + limit = max(0, min(limit, 500)) + return_val = {"count": all_gauntlets.count(), "runs": []} + for x in all_gauntlets.limit(limit): + return_val["runs"].append(model_to_dict(x)) return return_val -@router.get('/{gauntletrun_id}') +@router.get("/{gauntletrun_id}") async def get_one_gauntletrun(gauntletrun_id): try: this_gauntlet = GauntletRun.get_by_id(gauntletrun_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No gauntlet found with id {gauntletrun_id}') + raise HTTPException( + status_code=404, detail=f"No gauntlet found with id {gauntletrun_id}" + ) return_val = model_to_dict(this_gauntlet) return return_val -@router.patch('/{gauntletrun_id}') +@router.patch("/{gauntletrun_id}") async def patch_gauntletrun( - gauntletrun_id, team_id: Optional[int] = None, wins: Optional[int] = None, losses: Optional[int] = None, - gsheet: Optional[str] = None, created: Optional[bool] = None, ended: Optional[bool] = None, - token: str = Depends(oauth2_scheme)): + gauntletrun_id, + team_id: Optional[int] = None, + wins: Optional[int] = None, + losses: Optional[int] = None, + gsheet: Optional[str] = None, + created: Optional[bool] = None, + ended: Optional[bool] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch gauntlet runs. This event has been logged.' + detail="You are not authorized to patch gauntlet runs. This event has been logged.", ) this_run = GauntletRun.get_or_none(GauntletRun.id == gauntletrun_id) if this_run is None: - raise KeyError(f'Gauntlet Run ID {gauntletrun_id} not found') + raise KeyError(f"Gauntlet Run ID {gauntletrun_id} not found") if team_id is not None: this_run.team_id = team_id @@ -130,41 +148,42 @@ async def patch_gauntletrun( r_curr = model_to_dict(this_run) return r_curr else: - raise DatabaseError(f'Unable to patch gauntlet run {gauntletrun_id}') + raise DatabaseError(f"Unable to patch gauntlet run {gauntletrun_id}") -@router.post('') -async def post_gauntletrun(gauntletrun: GauntletRunModel, token: str = Depends(oauth2_scheme)): +@router.post("") +async def post_gauntletrun( + gauntletrun: GauntletRunModel, token: str = Depends(oauth2_scheme) +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post gauntlets. This event has been logged.' + detail="You are not authorized to post gauntlets. This event has been logged.", ) run_data = gauntletrun.dict() # Convert milliseconds timestamps to datetime for PostgreSQL - if run_data.get('created'): - run_data['created'] = datetime.fromtimestamp(run_data['created'] / 1000) + if run_data.get("created"): + run_data["created"] = datetime.fromtimestamp(run_data["created"] / 1000) else: - run_data['created'] = datetime.now() - if run_data.get('ended'): - run_data['ended'] = datetime.fromtimestamp(run_data['ended'] / 1000) + run_data["created"] = datetime.now() + if run_data.get("ended"): + run_data["ended"] = datetime.fromtimestamp(run_data["ended"] / 1000) else: - run_data['ended'] = None + run_data["ended"] = None this_run = GauntletRun(**run_data) if this_run.save(): r_run = model_to_dict(this_run) return r_run else: - raise DatabaseError(f'Unable to post gauntlet run') + raise DatabaseError("Unable to post gauntlet run") -@router.delete('/{gauntletrun_id}') +@router.delete("/{gauntletrun_id}") async def delete_gauntletrun(gauntletrun_id): if GauntletRun.delete_by_id(gauntletrun_id) == 1: - return f'Deleted gauntlet run ID {gauntletrun_id}' - - raise DatabaseError(f'Unable to delete gauntlet run {gauntletrun_id}') + return f"Deleted gauntlet run ID {gauntletrun_id}" + raise DatabaseError(f"Unable to delete gauntlet run {gauntletrun_id}")