From 207ed518555182c9b27db9d448fb1b4a7ad68305 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 21:32:00 -0600 Subject: [PATCH] feat: add scout_opportunities and scout_claims tables and API endpoints (#44) Support the Discord bot's new scouting feature where players can scout cards from other teams' opened packs. Stores opportunities with expiry timestamps and tracks which teams claim which cards. Co-Authored-By: Claude Opus 4.6 --- app/db_engine.py | 35 ++++++++ app/main.py | 4 + app/routers_v2/scout_claims.py | 91 +++++++++++++++++++ app/routers_v2/scout_opportunities.py | 123 ++++++++++++++++++++++++++ 4 files changed, 253 insertions(+) create mode 100644 app/routers_v2/scout_claims.py create mode 100644 app/routers_v2/scout_opportunities.py diff --git a/app/db_engine.py b/app/db_engine.py index 290b160..06492f9 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1070,6 +1070,41 @@ if not SKIP_TABLE_CREATION: db.create_tables([StratGame, StratPlay, Decision], safe=True) +class ScoutOpportunity(BaseModel): + pack = ForeignKeyField(Pack, null=True) + opener_team = ForeignKeyField(Team) + card_ids = CharField() # JSON array of card IDs + expires_at = BigIntegerField() + created = BigIntegerField() + + class Meta: + database = db + table_name = "scout_opportunity" + + +class ScoutClaim(BaseModel): + scout_opportunity = ForeignKeyField(ScoutOpportunity) + card = ForeignKeyField(Card) + claimed_by_team = ForeignKeyField(Team) + created = BigIntegerField() + + class Meta: + database = db + table_name = "scout_claim" + + +scout_claim_index = ModelIndex( + ScoutClaim, + (ScoutClaim.scout_opportunity, ScoutClaim.claimed_by_team), + unique=True, +) +ScoutClaim.add_index(scout_claim_index) + + +if not SKIP_TABLE_CREATION: + db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) + + db.close() # scout_db = SqliteDatabase( diff --git a/app/main.py b/app/main.py index 55d7233..64cbfc2 100644 --- a/app/main.py +++ b/app/main.py @@ -47,6 +47,8 @@ from .routers_v2 import ( mlbplayers, stratgame, stratplays, + scout_opportunities, + scout_claims, ) app = FastAPI( @@ -88,6 +90,8 @@ app.include_router(mlbplayers.router) app.include_router(stratgame.router) app.include_router(stratplays.router) app.include_router(decisions.router) +app.include_router(scout_opportunities.router) +app.include_router(scout_claims.router) @app.middleware("http") diff --git a/app/routers_v2/scout_claims.py b/app/routers_v2/scout_claims.py new file mode 100644 index 0000000..501a1b4 --- /dev/null +++ b/app/routers_v2/scout_claims.py @@ -0,0 +1,91 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from typing import Optional +import logging +import pydantic + +from ..db_engine import ScoutClaim, ScoutOpportunity, model_to_dict +from ..dependencies import oauth2_scheme, valid_token + +router = APIRouter(prefix="/api/v2/scout_claims", tags=["scout_claims"]) + + +class ScoutClaimModel(pydantic.BaseModel): + scout_opportunity_id: int + card_id: int + claimed_by_team_id: int + + +@router.get("") +async def get_scout_claims( + scout_opportunity_id: Optional[int] = None, claimed_by_team_id: Optional[int] = None +): + + query = ScoutClaim.select().order_by(ScoutClaim.id) + + if scout_opportunity_id is not None: + query = query.where(ScoutClaim.scout_opportunity_id == scout_opportunity_id) + if claimed_by_team_id is not None: + query = query.where(ScoutClaim.claimed_by_team_id == claimed_by_team_id) + + results = [model_to_dict(x, recurse=False) for x in query] + return {"count": len(results), "results": results} + + +@router.get("/{claim_id}") +async def get_one_scout_claim(claim_id: int): + try: + claim = ScoutClaim.get_by_id(claim_id) + except Exception: + raise HTTPException( + status_code=404, detail=f"No scout claim found with id {claim_id}" + ) + + return model_to_dict(claim) + + +@router.post("") +async def post_scout_claim(claim: ScoutClaimModel, 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 claims. This event has been logged.", + ) + + claim_data = claim.dict() + claim_data["created"] = int(datetime.timestamp(datetime.now()) * 1000) + + this_claim = ScoutClaim(**claim_data) + saved = this_claim.save() + + if saved == 1: + return model_to_dict(this_claim) + else: + raise HTTPException(status_code=418, detail="Could not save scout claim") + + +@router.delete("/{claim_id}") +async def delete_scout_claim(claim_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 claims. This event has been logged.", + ) + try: + claim = ScoutClaim.get_by_id(claim_id) + except Exception: + raise HTTPException( + status_code=404, detail=f"No scout claim found with id {claim_id}" + ) + + count = claim.delete_instance() + if count == 1: + raise HTTPException( + status_code=200, detail=f"Scout claim {claim_id} has been deleted" + ) + else: + raise HTTPException( + status_code=500, detail=f"Scout claim {claim_id} was not deleted" + ) diff --git a/app/routers_v2/scout_opportunities.py b/app/routers_v2/scout_opportunities.py new file mode 100644 index 0000000..25a3a75 --- /dev/null +++ b/app/routers_v2/scout_opportunities.py @@ -0,0 +1,123 @@ +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, fn +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, +): + + 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)) + + 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", + )