paper-dynasty-database/app/routers_v2/cardpositions.py
Cal Corum 0cba52cea5 PostgreSQL migration: Complete code preparation phase
- Add db_helpers.py with cross-database upsert functions for SQLite/PostgreSQL
- Replace 12 on_conflict_replace() calls with PostgreSQL-compatible upserts
- Add unique indexes: StratPlay(game, play_num), Decision(game, pitcher)
- Add max_length to Team model fields (abbrev, sname, lname)
- Fix boolean comparison in teams.py (== 0/1 to == False/True)
- Create migrate_to_postgres.py with ID-preserving migration logic
- Create audit_sqlite.py for pre-migration data integrity checks
- Add PROJECT_PLAN.json for migration tracking
- Add .secrets/ to .gitignore for credentials

Audit results: 658,963 records across 29 tables, 2,390 orphaned stats (expected)

Based on Major Domo migration lessons learned (33 issues resolved there)
2026-01-25 23:05:54 -06:00

182 lines
5.9 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Literal, Optional, List
import logging
import pydantic
from pydantic import root_validator
from ..db_engine import db, CardPosition, model_to_dict, chunked, Player, fn
from ..db_helpers import upsert_card_positions
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/cardpositions", tags=["cardpositions"])
class CardPositionModel(pydantic.BaseModel):
player_id: int
variant: int = 0
position: Literal["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
innings: int = 1
range: int = 5
error: int = 0
arm: Optional[int] = None
pb: Optional[int] = None
overthrow: Optional[int] = None
@root_validator(skip_on_failure=True)
def position_validator(cls, values):
if values["position"] in ["C", "LF", "CF", "RF"] and values["arm"] is None:
raise ValueError(f"{values['position']} must have an arm rating")
if values["position"] == "C" and (
values["pb"] is None or values["overthrow"] is None
):
raise ValueError("Catchers must have a pb and overthrow rating")
return values
class PositionList(pydantic.BaseModel):
positions: List[CardPositionModel]
@router.get("")
async def get_card_positions(
player_id: list = Query(default=None),
position: list = Query(default=None),
min_innings: Optional[int] = 1,
r: list = Query(default=None),
e: list = Query(default=None),
arm: list = Query(default=None),
pb: list = Query(default=None),
overthrow: list = Query(default=None),
cardset_id: list = Query(default=None),
short_output: Optional[bool] = False,
sort: Optional[str] = "innings-desc",
):
all_pos = (
CardPosition.select()
.where(CardPosition.innings >= min_innings)
.order_by(CardPosition.player, CardPosition.position, CardPosition.variant)
)
if player_id is not None:
all_pos = all_pos.where(CardPosition.player_id << player_id)
if position is not None:
p_list = [x.lower() for x in position]
all_pos = all_pos.where(fn.Lower(CardPosition.position) << p_list)
if r is not None:
all_pos = all_pos.where(CardPosition.range << r)
if e is not None:
all_pos = all_pos.where(CardPosition.error << e)
if arm is not None:
all_pos = all_pos.where(CardPosition.arm << arm)
if pb is not None:
all_pos = all_pos.where(CardPosition.pb << pb)
if overthrow is not None:
all_pos = all_pos.where(CardPosition.overthrow << overthrow)
if cardset_id is not None:
all_players = Player.select().where(Player.cardset_id << cardset_id)
all_pos = all_pos.where(CardPosition.player << all_players)
if sort == "innings-desc":
all_pos = all_pos.order_by(CardPosition.innings.desc())
elif sort == "innings-asc":
all_pos = all_pos.order_by(CardPosition.innings)
elif sort == "range-desc":
all_pos = all_pos.order_by(CardPosition.range.desc())
elif sort == "range-asc":
all_pos = all_pos.order_by(CardPosition.range)
return_val = {
"count": all_pos.count(),
"positions": [model_to_dict(x, recurse=not short_output) for x in all_pos],
}
db.close()
return return_val
@router.get("/{position_id}")
async def get_one_position(position_id: int):
this_pos = CardPosition.get_or_none(CardPosition.id == position_id)
if this_pos is None:
db.close()
raise HTTPException(
status_code=404, detail=f"CardPosition id {position_id} not found"
)
r_data = model_to_dict(this_pos)
db.close()
return r_data
@router.put("")
async def put_positions(positions: PositionList, 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 card positions. This event has been logged.",
)
new_cards = []
updates = 0
for x in positions.positions:
try:
CardPosition.get(
(CardPosition.player_id == x.player_id)
& (CardPosition.variant == x.variant)
& (CardPosition.position == x.position)
)
updates += (
CardPosition.update(x.dict())
.where(
(CardPosition.player_id == x.player_id)
& (CardPosition.variant == x.variant)
& (CardPosition.position == x.position)
)
.execute()
)
except CardPosition.DoesNotExist:
new_cards.append(x.dict())
with db.atomic():
# Use PostgreSQL-compatible upsert helper
upsert_card_positions(new_cards, batch_size=30)
db.close()
return f"Updated cards: {updates}; new cards: {len(new_cards)}"
@router.delete("/{position_id}")
async def delete_position(position_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 card positions. This event has been logged.",
)
this_pos = CardPosition.get_or_none(CardPosition.id == position_id)
if this_pos is None:
db.close()
raise HTTPException(
status_code=404, detail=f"CardPosition id {position_id} not found"
)
count = this_pos.delete_instance()
db.close()
if count == 1:
return f"Card Position {this_pos} has been deleted"
else:
raise HTTPException(
status_code=500, detail=f"Card Position {this_pos} could not be deleted"
)