""" card_storage.py — S3 upload utility for variant card images. Public API ---------- get_s3_client() Create and return a boto3 S3 client using ambient AWS credentials (environment variables or instance profile). build_s3_key(cardset_id, player_id, variant, card_type) Construct the S3 object key for a variant card image. build_s3_url(s3_key, render_date) Return the full HTTPS S3 URL with a cache-busting date query param. upload_card_to_s3(s3_client, png_bytes, s3_key) Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers. 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 PitchingCard.image_url in the database. All exceptions are caught and logged; this function never raises (safe to call as a background task). Design notes ------------ - S3 credentials are resolved from the environment by boto3 at call time; no credentials are hard-coded here. - The cache-bust ?d= param matches the card-creation pipeline convention so that clients can compare URLs across pipelines. """ import logging import os from datetime import date import boto3 from app.db_engine import BattingCard, PitchingCard logger = logging.getLogger(__name__) S3_BUCKET = os.environ.get("S3_BUCKET", "paper-dynasty") S3_REGION = os.environ.get("S3_REGION", "us-east-1") def get_s3_client(): """Create and return a boto3 S3 client for the configured region. Credentials are resolved by boto3 from the standard chain: environment variables → ~/.aws/credentials → instance profile. Returns: A boto3 S3 client instance. """ return boto3.client("s3", region_name=S3_REGION) def build_s3_key(cardset_id: int, player_id: int, variant: int, card_type: str) -> str: """Construct the S3 object key for a variant card image. Key format: cards/cardset-{csid:03d}/player-{pid}/v{variant}/{card_type}card.png Args: cardset_id: Numeric cardset ID (zero-padded to 3 digits). player_id: Player ID. variant: Variant number (0 = base, 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.png" ) def build_s3_url(s3_key: str, render_date: date) -> str: """Return the full HTTPS S3 URL for a card image with a cache-bust param. URL format: https://{bucket}.s3.{region}.amazonaws.com/{key}?d={date} The ?d= query param matches the card-creation pipeline convention so that clients invalidate their cache after each re-render. Args: s3_key: S3 object key (from build_s3_key). render_date: The date the card was rendered, used for cache-busting. Returns: Full HTTPS URL string. """ base_url = f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com" date_str = render_date.strftime("%Y-%m-%d") return f"{base_url}/{s3_key}?d={date_str}" 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. Sets ContentType=image/png and CacheControl=public, max-age=300 (5 min) so that CDN and browser caches are refreshed within a short window after a re-render. Args: s3_client: A boto3 S3 client (from get_s3_client). png_bytes: Raw PNG image bytes. s3_key: S3 object key (from build_s3_key). Returns: None """ s3_client.put_object( Bucket=S3_BUCKET, Key=s3_key, Body=png_bytes, ContentType="image/png", CacheControl="public, max-age=300", ) def backfill_variant_image_url( player_id: int, variant: int, card_type: str, cardset_id: int, png_path: str, ) -> None: """Read a rendered PNG from disk, upload it to S3, and update the DB row. Determines the correct card model (BattingCard or PitchingCard) from card_type, then: 1. Reads PNG bytes from png_path. 2. Uploads to S3 via upload_card_to_s3. 3. Fetches the card row by (player_id, variant). 4. Sets image_url to the new S3 URL and calls save(). 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 to locate the card row. variant: Variant number (matches the card row's variant field). card_type: "batting" or "pitching" — selects the model. cardset_id: Cardset ID used for the S3 key. png_path: Absolute path to the rendered PNG file on disk. Returns: None """ try: # 1. Read PNG from disk with open(png_path, "rb") as f: png_bytes = f.read() # 2. Build key and upload s3_key = build_s3_key( cardset_id=cardset_id, player_id=player_id, variant=variant, card_type=card_type, ) s3_client = get_s3_client() upload_card_to_s3(s3_client, png_bytes, s3_key) # 3. Build URL with today's date for cache-busting image_url = build_s3_url(s3_key, render_date=date.today()) # 4. Locate the card row and update image_url if card_type == "batting": card = BattingCard.get( BattingCard.player_id == player_id, BattingCard.variant == variant ) else: card = PitchingCard.get( PitchingCard.player_id == player_id, PitchingCard.variant == variant ) card.image_url = image_url card.save() logger.info( "backfill_variant_image_url: updated %s card player=%s variant=%s url=%s", card_type, player_id, variant, image_url, ) except Exception: logger.exception( "backfill_variant_image_url: failed for player=%s variant=%s card_type=%s", player_id, variant, card_type, )