paper-dynasty-database/app/routers_v2/mlbplayers.py
Cal Corum 40c512c665 Add PostgreSQL compatibility fixes for query ordering
- Add explicit ORDER BY id to all queries for consistent results across SQLite and PostgreSQL
- PostgreSQL does not guarantee row order without ORDER BY, unlike SQLite
- Skip table creation when DATABASE_TYPE=postgresql (production tables already exist)
- Fix datetime handling in notifications (PostgreSQL native datetime vs SQLite timestamp)
- Fix grouped query count() calls that don't work in PostgreSQL
- Update .gitignore to include storage/templates/ directory

This completes the PostgreSQL migration compatibility layer while maintaining
backwards compatibility with SQLite for local development.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:39:14 -06:00

382 lines
12 KiB
Python

import datetime
import random
from fastapi import APIRouter, Depends, HTTPException, Response, Query
from typing import Optional, List
import logging
import pydantic
from pandas import DataFrame
from ..db_engine import (
db,
MlbPlayer,
Player,
BattingCard,
PitchingCard,
model_to_dict,
fn,
chunked,
query_to_csv,
)
from ..db_helpers import upsert_mlb_players
from ..dependencies import oauth2_scheme, valid_token, LOG_DATA
logging.basicConfig(
filename=LOG_DATA["filename"],
format=LOG_DATA["format"],
level=LOG_DATA["log_level"],
)
router = APIRouter(prefix="/api/v2/mlbplayers", tags=["mlbplayers"])
class PlayerModel(pydantic.BaseModel):
first_name: str
last_name: str
key_mlbam: int
key_fangraphs: int = None
key_bbref: str = None
key_retro: str = None
offense_col: int = random.randint(1, 3)
class PlayerList(pydantic.BaseModel):
players: List[PlayerModel]
def update_card_urls(mlbplayer: MlbPlayer):
logging.info(
f"Updating cards for mlbplayer: {mlbplayer.first_name} {mlbplayer.last_name} ({mlbplayer.key_bbref})"
)
now = datetime.datetime.now()
c1_update = Player.update(
{
Player.image: Player.image.name.split("?d=")[0]
+ f"?d={now.year}-{now.month}-{now.day}"
}
).where(Player.mlbplayer_id == mlbplayer.id)
count = c1_update.execute()
logging.info(f"Updated {count} image1s")
c2_update = Player.update(
{
Player.image2: Player.image2.name.split("?d=")[0]
+ f"{now.year}-{now.month}-{now.day}"
}
).where((Player.mlbplayer_id == mlbplayer.id) & (Player.image2.is_null(False)))
count2 = c2_update.execute()
logging.info(f"Updated {count2} image2s")
return count + count2
@router.get("")
async def get_players(
full_name: list = Query(default=None),
first_name: list = Query(default=None),
last_name: list = Query(default=None),
key_fangraphs: list = Query(default=None),
key_bbref: list = Query(default=None),
key_retro: list = Query(default=None),
key_mlbam: list = Query(default=None),
offense_col: list = Query(default=None),
csv: Optional[bool] = False,
):
all_players = MlbPlayer.select().order_by(MlbPlayer.id)
if full_name is not None:
name_list = [x.lower() for x in full_name]
all_players = all_players.where(
fn.lower(MlbPlayer.first_name) + " " + fn.lower(MlbPlayer.last_name)
<< name_list
)
if first_name is not None:
name_list = [x.lower() for x in first_name]
all_players = all_players.where(fn.lower(MlbPlayer.first_name) << name_list)
if last_name is not None:
name_list = [x.lower() for x in last_name]
all_players = all_players.where(fn.lower(MlbPlayer.last_name) << name_list)
if key_fangraphs is not None:
all_players = all_players.where(MlbPlayer.key_fangraphs << key_fangraphs)
if key_bbref is not None:
name_list = [x.lower() for x in key_bbref]
all_players = all_players.where(fn.lower(MlbPlayer.key_bbref) << name_list)
if key_retro is not None:
name_list = [x.lower() for x in key_retro]
all_players = all_players.where(fn.lower(MlbPlayer.key_retro) << name_list)
if key_mlbam is not None:
all_players = all_players.where(MlbPlayer.key_mlbam << key_mlbam)
if offense_col is not None:
all_players = all_players.where(MlbPlayer.offense_col << offense_col)
if csv:
return_val = query_to_csv(all_players)
db.close()
return Response(content=return_val, media_type="text/csv")
return_val = {
"count": all_players.count(),
"players": [model_to_dict(x) for x in all_players],
}
db.close()
return return_val
@router.get("/{player_id}")
async def get_one_player(player_id: int):
this_player = MlbPlayer.get_or_none(MlbPlayer.id == player_id)
if this_player is None:
db.close()
raise HTTPException(
status_code=404, detail=f"MlbPlayer id {player_id} not found"
)
r_data = model_to_dict(this_player)
db.close()
return r_data
@router.patch("/{player_id}")
async def patch_player(
player_id: int,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
key_fangraphs: Optional[str] = None,
key_bbref: Optional[str] = None,
key_retro: Optional[str] = None,
key_mlbam: Optional[str] = None,
offense_col: Optional[str] = None,
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 patch mlb players. This event has been logged.",
)
this_player = MlbPlayer.get_or_none(MlbPlayer.id == player_id)
if this_player is None:
db.close()
raise HTTPException(
status_code=404, detail=f"MlbPlayer id {player_id} not found"
)
if first_name is not None:
this_player.first_name = first_name
if last_name is not None:
this_player.last_name = last_name
if key_fangraphs is not None:
this_player.key_fangraphs = key_fangraphs
if key_bbref is not None:
this_player.key_bbref = key_bbref
if key_retro is not None:
this_player.key_retro = key_retro
if key_mlbam is not None:
this_player.key_mlbam = key_mlbam
if offense_col is not None:
this_player.offense_col = offense_col
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",
)
@router.post("")
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.",
)
new_players = []
for x in players.players:
dupes = MlbPlayer.select().where(
(MlbPlayer.key_fangraphs == x.key_fangraphs)
| (MlbPlayer.key_mlbam == x.key_mlbam)
| (MlbPlayer.key_retro == x.key_retro)
| (MlbPlayer.key_bbref == x.key_bbref)
)
if dupes.count() > 0:
db.close()
raise HTTPException(
status_code=400,
detail=f"{x.first_name} {x.last_name} has a key already in the database",
)
new_players.append(x.dict())
with db.atomic():
# Use PostgreSQL-compatible upsert helper
# Note: Duplicate check is already done above, so this is effectively just insert
upsert_mlb_players(new_players, batch_size=15)
db.close()
return f"Inserted {len(new_players)} new MLB players"
@router.post("/one")
async def post_one_player(player: PlayerModel, 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.",
)
dupes = MlbPlayer.select().where(
(MlbPlayer.key_fangraphs == player.key_fangraphs)
| (MlbPlayer.key_mlbam == player.key_mlbam)
| (MlbPlayer.key_bbref == player.key_bbref)
)
if dupes.count() > 0:
logging.info(f"POST /mlbplayers/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",
)
new_player = MlbPlayer(**player.dict())
saved = new_player.save()
if saved == 1:
return_val = model_to_dict(new_player)
db.close()
return return_val
else:
raise HTTPException(
status_code=418,
detail="Well slap my ass and call me a teapot; I could not save that player",
)
@router.delete("/{player_id}")
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.",
)
this_player = MlbPlayer.get_or_none(MlbPlayer.id == player_id)
if this_player is None:
db.close()
raise HTTPException(
status_code=404, detail=f"MlbPlayer id {player_id} not found"
)
count = this_player.delete_instance()
db.close()
if count == 1:
raise HTTPException(
status_code=200, detail=f"Player {player_id} has been deleted"
)
else:
raise HTTPException(
status_code=500, detail=f"Player {player_id} was not deleted"
)
@router.post("/update-cols")
@router.post("/update-cols/{mlbplayer_id}")
async def update_columns(
mlbplayer_id: Optional[int] = None, 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 update mlb players. This event has been logged.",
)
p_query = MlbPlayer.select()
if mlbplayer_id is not None:
p_query = p_query.where(MlbPlayer.id == mlbplayer_id)
total_count = 0
for x in p_query:
all_players = Player.select().where(Player.mlbplayer == x)
bc_update = BattingCard.update(
{
BattingCard.offense_col: x.offense_col,
}
).where(
(BattingCard.player << all_players)
& (BattingCard.offense_col != x.offense_col)
)
count = bc_update.execute()
total_count += count
logging.info(f"Updated {count} batting cards for {x.first_name} {x.last_name}")
update_card_urls(x)
db.close()
return f"Updated {total_count} batting cards"
@router.post("/update-names")
@router.post("/update-names/{mlbplayer_id}")
async def update_names(
mlbplayer_id: Optional[int] = None, 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 update mlb players. This event has been logged.",
)
p_query = MlbPlayer.select()
if mlbplayer_id is not None:
p_query = p_query.where(MlbPlayer.id == mlbplayer_id)
total_count = 0
for x in p_query:
p_update = Player.update(
{Player.p_name: f"{x.first_name} {x.last_name}"}
).where(
(Player.mlbplayer == x) & (Player.p_name != f"{x.first_name} {x.last_name}")
)
count = p_update.execute()
total_count += count
logging.info(f"Update {count} player records for {x.first_name} {x.last_name}")
update_card_urls(x)
db.close()
return f"Updated {total_count} names"
# @router.post('/link-players')
# async def post_players(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 link mlb players. This event has been logged.'
# )
#
# for x in Player.select().where(Player.mlbplayer.is_null()):
# p_query = MlbPlayer.get_or_none(MlbPlayer.key_bbref == x.key_bbref)
# if p_query is not None:
# x.mlbplayer = p_query
# x.save()