Compare commits

...

10 Commits

Author SHA1 Message Date
cal
cd8db3f98e Merge pull request 'fix(api): raise 403 on scouting auth failure instead of returning 200 (#213)' (#214) from issue/213-fix-api-scouting-endpoints-return-200-on-auth-fail into main
Reviewed-on: #214
2026-04-11 01:49:12 +00:00
Cal Corum
6a986bf0f8 fix(api): raise 403 on scouting auth failure instead of returning 200 (#213)
Closes #213

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:31:47 -05:00
cal
5e85e27cda Merge pull request 'fix(packs): remove unfiltered pre-count in GET /packs (3 round-trips to 2)' (#211) from autonomous/packs-remove-prefilter-count into main
Reviewed-on: #211
2026-04-10 11:45:52 +00:00
Cal Corum
7f7d9ffe1f fix(packs): remove unfiltered pre-count in GET /packs (3 round-trips → 2)
Remove Pack.select().count() on the unfiltered table at the top of GET
/api/v2/packs. This check raised 404 if zero packs existed globally —
wrong for filtered queries where no match is the expected empty-list
result. The filtered count at the end of the handler already handles
the empty-result case. Endpoint now returns {count: 0, packs: []} on
empty filter matches (standard REST pattern) and saves one DB round-trip
per request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:45:34 +00:00
cal
d83a4bdbb7 Merge pull request 'feat: S3 upload pipeline for APNG animated cards (#198)' (#210) from issue/198-feat-s3-upload-pipeline-for-apng-animated-cards into main 2026-04-08 15:25:40 +00:00
Cal Corum
b29450e7d6 feat: S3 upload pipeline for APNG animated cards (#198)
Extends card_storage.py with build_apng_s3_key, upload_apng_to_s3, and
upload_variant_apng to handle animated card uploads. Wires get_animated_card
to trigger a background S3 upload on each new render (cache miss, non-preview).

Closes #198

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:04:21 -05:00
cal
5ff11759f9 Merge pull request 'feat: add REFRACTOR_START_SEASON floor to evaluator queries (#195)' (#209) from issue/195-docs-document-cross-season-stat-accumulation-decis into main 2026-04-08 13:25:48 +00:00
Cal Corum
fd2cc6534a feat: add REFRACTOR_START_SEASON floor to evaluator queries (#195)
Adds REFRACTOR_START_SEASON constant (default 11, overridable via env var)
to db_engine.py and applies it as a season filter in both BattingSeasonStats
and PitchingSeasonStats queries in refractor_evaluator.py, ensuring pre-Season
11 stats are excluded from refractor progress accumulation.

Closes #195

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:03:07 -05:00
cal
7701777273 Merge pull request 'feat: include animated_url in tier-up response for T3/T4 (#201)' (#208) from issue/201-feat-include-animated-url-in-tier-up-response-for into main 2026-04-08 10:25:53 +00:00
Cal Corum
4028a24ef9 feat: include animated_url in tier-up response for T3/T4 (#201)
Closes #201

Add animated_url to evaluate-game tier-up entries when new_tier >= 3.
URL is constructed from API_BASE_URL env var + the /animated endpoint
path, using today's date as the cache-bust segment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 04:35:55 -05:00
8 changed files with 237 additions and 71 deletions

View File

@ -44,6 +44,10 @@ else:
pragmas={"journal_mode": "wal", "cache_size": -1 * 64000, "synchronous": 0}, pragmas={"journal_mode": "wal", "cache_size": -1 * 64000, "synchronous": 0},
) )
# Refractor stat accumulation starts at this season — stats from earlier seasons
# are excluded from evaluation queries. Override via REFRACTOR_START_SEASON env var.
REFRACTOR_START_SEASON = int(os.environ.get("REFRACTOR_START_SEASON", "11"))
# 2025, 2005 # 2025, 2005
ranked_cardsets = [24, 25, 26, 27, 28, 29] ranked_cardsets = [24, 25, 26, 27, 28, 29]
LIVE_CARDSET_ID = 27 LIVE_CARDSET_ID = 27

View File

@ -332,10 +332,13 @@ async def get_card_scouting(team_id: int, ts: str):
logging.debug(f"Team: {this_team} / has_guide: {this_team.has_guide}") logging.debug(f"Team: {this_team} / has_guide: {this_team.has_guide}")
if this_team is None or ts != this_team.team_hash() or this_team.has_guide != 1: if this_team is None or ts != this_team.team_hash() or this_team.has_guide != 1:
logging.warning(f"Team_id {team_id} attempted to pull ratings") logging.warning(f"Team_id {team_id} attempted to pull ratings")
return ( raise HTTPException(
"Your team does not have the ratings guide enabled. If you have purchased a copy ping Cal to " status_code=403,
"make sure it is enabled on your team. If you are interested you can pick it up here (thank you!): " detail=(
"https://ko-fi.com/manticorum/shop" "Your team does not have the ratings guide enabled. If you have purchased a copy ping Cal to "
"make sure it is enabled on your team. If you are interested you can pick it up here (thank you!): "
"https://ko-fi.com/manticorum/shop"
),
) )
if os.path.isfile("storage/batting-ratings.csv"): if os.path.isfile("storage/batting-ratings.csv"):

View File

@ -10,10 +10,7 @@ from ..db_engine import db, Cardset, model_to_dict, Pack, Team, PackType, DoesNo
from ..dependencies import oauth2_scheme, valid_token from ..dependencies import oauth2_scheme, valid_token
router = APIRouter( router = APIRouter(prefix="/api/v2/packs", tags=["packs"])
prefix='/api/v2/packs',
tags=['packs']
)
class PackPydantic(pydantic.BaseModel): class PackPydantic(pydantic.BaseModel):
@ -28,46 +25,58 @@ class PackModel(pydantic.BaseModel):
packs: List[PackPydantic] packs: List[PackPydantic]
@router.get('') @router.get("")
async def get_packs( async def get_packs(
team_id: Optional[int] = None, pack_type_id: Optional[int] = None, opened: Optional[bool] = None, team_id: Optional[int] = None,
limit: Optional[int] = None, new_to_old: Optional[bool] = None, pack_team_id: Optional[int] = None, pack_type_id: Optional[int] = None,
pack_cardset_id: Optional[int] = None, exact_match: Optional[bool] = False, csv: Optional[bool] = None): opened: Optional[bool] = None,
limit: Optional[int] = None,
new_to_old: Optional[bool] = None,
pack_team_id: Optional[int] = None,
pack_cardset_id: Optional[int] = None,
exact_match: Optional[bool] = False,
csv: Optional[bool] = None,
):
all_packs = Pack.select() all_packs = Pack.select()
if all_packs.count() == 0:
raise HTTPException(status_code=404, detail=f'There are no packs to filter')
if team_id is not None: if team_id is not None:
try: try:
this_team = Team.get_by_id(team_id) this_team = Team.get_by_id(team_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No team found with id {team_id}') raise HTTPException(
status_code=404, detail=f"No team found with id {team_id}"
)
all_packs = all_packs.where(Pack.team == this_team) all_packs = all_packs.where(Pack.team == this_team)
if pack_type_id is not None: if pack_type_id is not None:
try: try:
this_pack_type = PackType.get_by_id(pack_type_id) this_pack_type = PackType.get_by_id(pack_type_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No pack type found with id {pack_type_id}') raise HTTPException(
status_code=404, detail=f"No pack type found with id {pack_type_id}"
)
all_packs = all_packs.where(Pack.pack_type == this_pack_type) all_packs = all_packs.where(Pack.pack_type == this_pack_type)
if pack_team_id is not None: if pack_team_id is not None:
try: try:
this_pack_team = Team.get_by_id(pack_team_id) this_pack_team = Team.get_by_id(pack_team_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No team found with id {pack_team_id}') raise HTTPException(
status_code=404, detail=f"No team found with id {pack_team_id}"
)
all_packs = all_packs.where(Pack.pack_team == this_pack_team) all_packs = all_packs.where(Pack.pack_team == this_pack_team)
elif exact_match: elif exact_match:
all_packs = all_packs.where(Pack.pack_team == None) all_packs = all_packs.where(Pack.pack_team == None) # noqa: E711
if pack_cardset_id is not None: if pack_cardset_id is not None:
try: try:
this_pack_cardset = Cardset.get_by_id(pack_cardset_id) this_pack_cardset = Cardset.get_by_id(pack_cardset_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No cardset found with id {pack_cardset_id}') raise HTTPException(
status_code=404, detail=f"No cardset found with id {pack_cardset_id}"
)
all_packs = all_packs.where(Pack.pack_cardset == this_pack_cardset) all_packs = all_packs.where(Pack.pack_cardset == this_pack_cardset)
elif exact_match: elif exact_match:
all_packs = all_packs.where(Pack.pack_cardset == None) all_packs = all_packs.where(Pack.pack_cardset == None) # noqa: E711
if opened is not None: if opened is not None:
all_packs = all_packs.where(Pack.open_time.is_null(not opened)) all_packs = all_packs.where(Pack.open_time.is_null(not opened))
@ -78,60 +87,62 @@ async def get_packs(
else: else:
all_packs = all_packs.order_by(Pack.id) all_packs = all_packs.order_by(Pack.id)
# if all_packs.count() == 0:
# db.close()
# raise HTTPException(status_code=404, detail=f'No packs found')
if csv: if csv:
data_list = [['id', 'team', 'pack_type', 'open_time']] data_list = [["id", "team", "pack_type", "open_time"]]
for line in all_packs: for line in all_packs:
data_list.append( data_list.append(
[ [
line.id, line.team.abbrev, line.pack_type.name, line.id,
line.open_time # Already datetime in PostgreSQL line.team.abbrev,
line.pack_type.name,
line.open_time, # Already datetime in PostgreSQL
] ]
) )
return_val = DataFrame(data_list).to_csv(header=False, index=False) 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: else:
return_val = {'count': all_packs.count(), 'packs': []} return_val = {"count": all_packs.count(), "packs": []}
for x in all_packs: for x in all_packs:
return_val['packs'].append(model_to_dict(x)) return_val["packs"].append(model_to_dict(x))
return return_val return return_val
@router.get('/{pack_id}') @router.get("/{pack_id}")
async def get_one_pack(pack_id: int, csv: Optional[bool] = False): async def get_one_pack(pack_id: int, csv: Optional[bool] = False):
try: try:
this_pack = Pack.get_by_id(pack_id) this_pack = Pack.get_by_id(pack_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No pack found with id {pack_id}') raise HTTPException(status_code=404, detail=f"No pack found with id {pack_id}")
if csv: if csv:
data_list = [ data_list = [
['id', 'team', 'pack_type', 'open_time'], ["id", "team", "pack_type", "open_time"],
[this_pack.id, this_pack.team.abbrev, this_pack.pack_type.name, [
this_pack.open_time] # Already datetime in PostgreSQL this_pack.id,
this_pack.team.abbrev,
this_pack.pack_type.name,
this_pack.open_time,
], # Already datetime in PostgreSQL
] ]
return_val = DataFrame(data_list).to_csv(header=False, index=False) 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: else:
return_val = model_to_dict(this_pack) return_val = model_to_dict(this_pack)
return return_val return return_val
@router.post('') @router.post("")
async def post_pack(packs: PackModel, token: str = Depends(oauth2_scheme)): async def post_pack(packs: PackModel, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to post packs. This event has been logged.' detail="You are not authorized to post packs. This event has been logged.",
) )
new_packs = [] new_packs = []
@ -141,23 +152,27 @@ async def post_pack(packs: PackModel, token: str = Depends(oauth2_scheme)):
pack_type_id=x.pack_type_id, pack_type_id=x.pack_type_id,
pack_team_id=x.pack_team_id, pack_team_id=x.pack_team_id,
pack_cardset_id=x.pack_cardset_id, pack_cardset_id=x.pack_cardset_id,
open_time=datetime.fromtimestamp(x.open_time / 1000) if x.open_time else None open_time=datetime.fromtimestamp(x.open_time / 1000)
if x.open_time
else None,
) )
new_packs.append(this_player) new_packs.append(this_player)
with db.atomic(): with db.atomic():
Pack.bulk_create(new_packs, batch_size=15) Pack.bulk_create(new_packs, batch_size=15)
raise HTTPException(status_code=200, detail=f'{len(new_packs)} packs have been added') raise HTTPException(
status_code=200, detail=f"{len(new_packs)} packs have been added"
)
@router.post('/one') @router.post("/one")
async def post_one_pack(pack: PackPydantic, token: str = Depends(oauth2_scheme)): async def post_one_pack(pack: PackPydantic, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to post packs. This event has been logged.' detail="You are not authorized to post packs. This event has been logged.",
) )
this_pack = Pack( this_pack = Pack(
@ -165,7 +180,9 @@ async def post_one_pack(pack: PackPydantic, token: str = Depends(oauth2_scheme))
pack_type_id=pack.pack_type_id, pack_type_id=pack.pack_type_id,
pack_team_id=pack.pack_team_id, pack_team_id=pack.pack_team_id,
pack_cardset_id=pack.pack_cardset_id, pack_cardset_id=pack.pack_cardset_id,
open_time=datetime.fromtimestamp(pack.open_time / 1000) if pack.open_time else None open_time=datetime.fromtimestamp(pack.open_time / 1000)
if pack.open_time
else None,
) )
saved = this_pack.save() saved = this_pack.save()
@ -175,24 +192,30 @@ async def post_one_pack(pack: PackPydantic, token: str = Depends(oauth2_scheme))
else: else:
raise HTTPException( raise HTTPException(
status_code=418, status_code=418,
detail='Well slap my ass and call me a teapot; I could not save that cardset' detail="Well slap my ass and call me a teapot; I could not save that cardset",
) )
@router.patch('/{pack_id}') @router.patch("/{pack_id}")
async def patch_pack( async def patch_pack(
pack_id, team_id: Optional[int] = None, pack_type_id: Optional[int] = None, open_time: Optional[int] = None, pack_id,
pack_team_id: Optional[int] = None, pack_cardset_id: Optional[int] = None, token: str = Depends(oauth2_scheme)): team_id: Optional[int] = None,
pack_type_id: Optional[int] = None,
open_time: Optional[int] = None,
pack_team_id: Optional[int] = None,
pack_cardset_id: Optional[int] = None,
token: str = Depends(oauth2_scheme),
):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to patch packs. This event has been logged.' detail="You are not authorized to patch packs. This event has been logged.",
) )
try: try:
this_pack = Pack.get_by_id(pack_id) this_pack = Pack.get_by_id(pack_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No pack found with id {pack_id}') raise HTTPException(status_code=404, detail=f"No pack found with id {pack_id}")
if team_id is not None: if team_id is not None:
this_pack.team_id = team_id this_pack.team_id = team_id
@ -220,26 +243,26 @@ async def patch_pack(
else: else:
raise HTTPException( raise HTTPException(
status_code=418, status_code=418,
detail='Well slap my ass and call me a teapot; I could not save that rarity' detail="Well slap my ass and call me a teapot; I could not save that rarity",
) )
@router.delete('/{pack_id}') @router.delete("/{pack_id}")
async def delete_pack(pack_id, token: str = Depends(oauth2_scheme)): async def delete_pack(pack_id, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to delete packs. This event has been logged.' detail="You are not authorized to delete packs. This event has been logged.",
) )
try: try:
this_pack = Pack.get_by_id(pack_id) this_pack = Pack.get_by_id(pack_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No packs found with id {pack_id}') raise HTTPException(status_code=404, detail=f"No packs found with id {pack_id}")
count = this_pack.delete_instance() count = this_pack.delete_instance()
if count == 1: if count == 1:
raise HTTPException(status_code=200, detail=f'Pack {pack_id} has been deleted') raise HTTPException(status_code=200, detail=f"Pack {pack_id} has been deleted")
else: else:
raise HTTPException(status_code=500, detail=f'Pack {pack_id} was not deleted') raise HTTPException(status_code=500, detail=f"Pack {pack_id} was not deleted")

View File

@ -252,10 +252,13 @@ async def get_card_scouting(team_id: int, ts: str):
logging.debug(f"Team: {this_team} / has_guide: {this_team.has_guide}") logging.debug(f"Team: {this_team} / has_guide: {this_team.has_guide}")
if this_team is None or ts != this_team.team_hash() or this_team.has_guide != 1: if this_team is None or ts != this_team.team_hash() or this_team.has_guide != 1:
logging.warning(f"Team_id {team_id} attempted to pull ratings") logging.warning(f"Team_id {team_id} attempted to pull ratings")
return ( raise HTTPException(
"Your team does not have the ratings guide enabled. If you have purchased a copy ping Cal to " status_code=403,
"make sure it is enabled on your team. If you are interested you can pick it up here (thank you!): " detail=(
"https://ko-fi.com/manticorum/shop" "Your team does not have the ratings guide enabled. If you have purchased a copy ping Cal to "
"make sure it is enabled on your team. If you are interested you can pick it up here (thank you!): "
"https://ko-fi.com/manticorum/shop"
),
) )
if os.path.isfile(RATINGS_FILE): if os.path.isfile(RATINGS_FILE):

View File

@ -40,7 +40,7 @@ from ..db_engine import (
) )
from ..db_helpers import upsert_players from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token from ..dependencies import oauth2_scheme, valid_token
from ..services.card_storage import backfill_variant_image_url from ..services.card_storage import backfill_variant_image_url, upload_variant_apng
from ..services.refractor_boost import compute_variant_hash from ..services.refractor_boost import compute_variant_hash
from ..services.apng_generator import apng_cache_path, generate_animated_card from ..services.apng_generator import apng_cache_path, generate_animated_card
@ -740,6 +740,7 @@ async def get_one_player(player_id: int, csv: Optional[bool] = False):
@router.get("/{player_id}/{card_type}card/{d}/{variant}/animated") @router.get("/{player_id}/{card_type}card/{d}/{variant}/animated")
async def get_animated_card( async def get_animated_card(
request: Request, request: Request,
background_tasks: BackgroundTasks,
player_id: int, player_id: int,
card_type: Literal["batting", "pitching"], card_type: Literal["batting", "pitching"],
variant: int, variant: int,
@ -860,6 +861,16 @@ async def get_animated_card(
finally: finally:
await page.close() await page.close()
if tier is None:
background_tasks.add_task(
upload_variant_apng,
player_id=player_id,
variant=variant,
card_type=card_type,
cardset_id=this_player.cardset.id,
apng_path=cache_path,
)
return FileResponse(path=cache_path, media_type="image/apng", headers=headers) return FileResponse(path=cache_path, media_type="image/apng", headers=headers)

View File

@ -1,4 +1,5 @@
import os import os
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
import logging import logging
@ -476,9 +477,15 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
# Non-breaking addition: include boost info when available. # Non-breaking addition: include boost info when available.
if boost_result: if boost_result:
tier_up_entry["variant_created"] = boost_result.get( variant_num = boost_result.get("variant_created")
"variant_created" tier_up_entry["variant_created"] = variant_num
) if computed_tier >= 3 and variant_num and card_type:
d = date.today().strftime("%Y-%m-%d")
api_base = os.environ.get("API_BASE_URL", "").rstrip("/")
tier_up_entry["animated_url"] = (
f"{api_base}/api/v2/players/{player_id}/{card_type}card"
f"/{d}/{variant_num}/animated"
)
tier_ups.append(tier_up_entry) tier_ups.append(tier_up_entry)

View File

@ -8,7 +8,10 @@ get_s3_client()
(environment variables or instance profile). (environment variables or instance profile).
build_s3_key(cardset_id, player_id, variant, card_type) build_s3_key(cardset_id, player_id, variant, card_type)
Construct the S3 object key for a variant card image. Construct the S3 object key for a variant card PNG image.
build_apng_s3_key(cardset_id, player_id, variant, card_type)
Construct the S3 object key for a variant animated card APNG.
build_s3_url(s3_key, render_date) build_s3_url(s3_key, render_date)
Return the full HTTPS S3 URL with a cache-busting date query param. Return the full HTTPS S3 URL with a cache-busting date query param.
@ -16,11 +19,19 @@ build_s3_url(s3_key, render_date)
upload_card_to_s3(s3_client, png_bytes, s3_key) upload_card_to_s3(s3_client, png_bytes, s3_key)
Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers. Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers.
upload_apng_to_s3(s3_client, apng_bytes, s3_key)
Upload raw APNG bytes to S3 with correct ContentType and CacheControl headers.
backfill_variant_image_url(player_id, variant, card_type, cardset_id, png_path) backfill_variant_image_url(player_id, variant, card_type, cardset_id, png_path)
End-to-end: read PNG from disk, upload to S3, update BattingCard or End-to-end: read PNG from disk, upload to S3, update BattingCard or
PitchingCard.image_url in the database. All exceptions are caught and PitchingCard.image_url in the database. All exceptions are caught and
logged; this function never raises (safe to call as a background task). logged; this function never raises (safe to call as a background task).
upload_variant_apng(player_id, variant, card_type, cardset_id, apng_path)
End-to-end: read APNG from disk and upload to S3. No DB update (no
animated_url column exists yet). All exceptions are caught and logged;
this function never raises (safe to call as a background task).
Design notes Design notes
------------ ------------
- S3 credentials are resolved from the environment by boto3 at call time; - S3 credentials are resolved from the environment by boto3 at call time;
@ -97,6 +108,29 @@ def build_s3_url(s3_key: str, render_date: date) -> str:
return f"{base_url}/{s3_key}?d={date_str}" return f"{base_url}/{s3_key}?d={date_str}"
def build_apng_s3_key(
cardset_id: int, player_id: int, variant: int, card_type: str
) -> str:
"""Construct the S3 object key for a variant animated card APNG.
Key format:
cards/cardset-{csid:03d}/player-{pid}/v{variant}/{card_type}card.apng
Args:
cardset_id: Numeric cardset ID (zero-padded to 3 digits).
player_id: Player ID.
variant: Variant number (1-4 = refractor tiers).
card_type: Either "batting" or "pitching".
Returns:
The S3 object key string.
"""
return (
f"cards/cardset-{cardset_id:03d}/player-{player_id}"
f"/v{variant}/{card_type}card.apng"
)
def upload_card_to_s3(s3_client, png_bytes: bytes, s3_key: str) -> None: def upload_card_to_s3(s3_client, png_bytes: bytes, s3_key: str) -> None:
"""Upload raw PNG bytes to S3 with the standard card image headers. """Upload raw PNG bytes to S3 with the standard card image headers.
@ -196,3 +230,81 @@ def backfill_variant_image_url(
variant, variant,
card_type, card_type,
) )
def upload_apng_to_s3(s3_client, apng_bytes: bytes, s3_key: str) -> None:
"""Upload raw APNG bytes to S3 with the standard animated card headers.
Sets ContentType=image/apng and CacheControl=public, max-age=86400 (1 day)
matching the animated endpoint's own Cache-Control header.
Args:
s3_client: A boto3 S3 client (from get_s3_client).
apng_bytes: Raw APNG image bytes.
s3_key: S3 object key (from build_apng_s3_key).
Returns:
None
"""
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=apng_bytes,
ContentType="image/apng",
CacheControl="public, max-age=86400",
)
def upload_variant_apng(
player_id: int,
variant: int,
card_type: str,
cardset_id: int,
apng_path: str,
) -> None:
"""Read a rendered APNG from disk and upload it to S3.
Intended to be called as a background task after a new animated card is
rendered. No DB update is performed (no animated_url column exists yet).
All exceptions are caught and logged this function is intended to be
called as a background task and must never propagate exceptions.
Args:
player_id: Player ID used for the S3 key.
variant: Variant number (matches the refractor tier variant).
card_type: "batting" or "pitching" selects the S3 key.
cardset_id: Cardset ID used for the S3 key.
apng_path: Absolute path to the rendered APNG file on disk.
Returns:
None
"""
try:
with open(apng_path, "rb") as f:
apng_bytes = f.read()
s3_key = build_apng_s3_key(
cardset_id=cardset_id,
player_id=player_id,
variant=variant,
card_type=card_type,
)
s3_client = get_s3_client()
upload_apng_to_s3(s3_client, apng_bytes, s3_key)
logger.info(
"upload_variant_apng: uploaded %s animated card player=%s variant=%s key=%s",
card_type,
player_id,
variant,
s3_key,
)
except Exception:
logger.exception(
"upload_variant_apng: failed for player=%s variant=%s card_type=%s",
player_id,
variant,
card_type,
)

View File

@ -148,10 +148,11 @@ def evaluate_card(
strikeouts=sum(r.strikeouts for r in rows), strikeouts=sum(r.strikeouts for r in rows),
) )
else: else:
from app.db_engine import ( from app.db_engine import ( # noqa: PLC0415
BattingSeasonStats, BattingSeasonStats,
PitchingSeasonStats, PitchingSeasonStats,
) # noqa: PLC0415 REFRACTOR_START_SEASON,
)
card_type = card_state.track.card_type card_type = card_state.track.card_type
if card_type == "batter": if card_type == "batter":
@ -159,6 +160,7 @@ def evaluate_card(
BattingSeasonStats.select().where( BattingSeasonStats.select().where(
(BattingSeasonStats.player == player_id) (BattingSeasonStats.player == player_id)
& (BattingSeasonStats.team == team_id) & (BattingSeasonStats.team == team_id)
& (BattingSeasonStats.season >= REFRACTOR_START_SEASON)
) )
) )
totals = _CareerTotals( totals = _CareerTotals(
@ -175,6 +177,7 @@ def evaluate_card(
PitchingSeasonStats.select().where( PitchingSeasonStats.select().where(
(PitchingSeasonStats.player == player_id) (PitchingSeasonStats.player == player_id)
& (PitchingSeasonStats.team == team_id) & (PitchingSeasonStats.team == team_id)
& (PitchingSeasonStats.season >= REFRACTOR_START_SEASON)
) )
) )
totals = _CareerTotals( totals = _CareerTotals(