New service with S3 upload functions for the refractor card art pipeline. backfill_variant_image_url reads rendered PNGs from disk, uploads to S3, and sets image_url on BattingCard/PitchingCard rows. 18 tests covering key construction, URL formatting, upload params, and error swallowing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.3 KiB
Python
202 lines
6.3 KiB
Python
"""
|
|
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
|
|
------------
|
|
- backfill_variant_image_url uses a lazy import for BattingCard/PitchingCard
|
|
to avoid circular imports at module load time (db_engine imports routers
|
|
indirectly via the app module in some paths).
|
|
- 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
|
|
from datetime import date
|
|
|
|
import boto3
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
S3_BUCKET = "paper-dynasty"
|
|
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
|
|
"""
|
|
# Lazy import to avoid circular imports at module load time
|
|
from app.db_engine import BattingCard, PitchingCard
|
|
|
|
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 == player_id, BattingCard.variant == variant
|
|
)
|
|
else:
|
|
card = PitchingCard.get(
|
|
PitchingCard.player == 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,
|
|
)
|