From 5e182bedacd7e6c59da686414636c6f12983a9d1 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 21:32:00 -0600 Subject: [PATCH 1/2] 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", + ) From 37439626ed88da68f2183591fac7940b0f5c9cd9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 21:33:28 -0600 Subject: [PATCH 2/2] chore: add PostgreSQL migration for scout tables (#44) Creates scout_opportunity and scout_claim tables with foreign keys, unique constraint on (opportunity, team), and expires_at index. Already applied to dev database. Co-Authored-By: Claude Opus 4.6 --- ...6-03-04_add_scout_opportunities_claims.sql | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 migrations/2026-03-04_add_scout_opportunities_claims.sql diff --git a/migrations/2026-03-04_add_scout_opportunities_claims.sql b/migrations/2026-03-04_add_scout_opportunities_claims.sql new file mode 100644 index 0000000..aaa97e9 --- /dev/null +++ b/migrations/2026-03-04_add_scout_opportunities_claims.sql @@ -0,0 +1,57 @@ +-- Migration: Add scout_opportunity and scout_claim tables +-- Date: 2026-03-04 +-- Issue: #44 +-- Purpose: Support the scouting feature where players can scout cards +-- from other teams' opened packs within a 30-minute window. +-- +-- Run on dev first, verify with: +-- SELECT count(*) FROM scout_opportunity; +-- SELECT count(*) FROM scout_claim; +-- +-- Rollback: See DROP statements at bottom of file + +-- ============================================ +-- FORWARD MIGRATION +-- ============================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS scout_opportunity ( + id SERIAL PRIMARY KEY, + pack_id INTEGER REFERENCES pack(id) ON DELETE SET NULL, + opener_team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE, + card_ids VARCHAR(255) NOT NULL, -- JSON array of card IDs, e.g. "[10, 11, 12]" + expires_at BIGINT NOT NULL, -- Unix ms timestamp, 30 min after creation + created BIGINT NOT NULL -- Unix ms timestamp +); + +CREATE TABLE IF NOT EXISTS scout_claim ( + id SERIAL PRIMARY KEY, + scout_opportunity_id INTEGER NOT NULL REFERENCES scout_opportunity(id) ON DELETE CASCADE, + card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE, + claimed_by_team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE, + created BIGINT NOT NULL -- Unix ms timestamp, auto-set on creation +); + +-- Unique constraint: one claim per team per opportunity +CREATE UNIQUE INDEX IF NOT EXISTS scout_claim_opportunity_team_uniq + ON scout_claim (scout_opportunity_id, claimed_by_team_id); + +-- Index for the common query: find unclaimed, expired opportunities +CREATE INDEX IF NOT EXISTS scout_opportunity_expires_at_idx + ON scout_opportunity (expires_at); + +COMMIT; + +-- ============================================ +-- VERIFICATION QUERIES +-- ============================================ +-- \d scout_opportunity +-- \d scout_claim +-- SELECT indexname FROM pg_indexes WHERE tablename IN ('scout_opportunity', 'scout_claim'); + +-- ============================================ +-- ROLLBACK (if needed) +-- ============================================ +-- DROP TABLE IF EXISTS scout_claim CASCADE; +-- DROP TABLE IF EXISTS scout_opportunity CASCADE;