paper-dynasty-database/app/routers_v2/featured.py
Cal Corum 507037f5b9 feat: add GET /api/v2/cards/featured/card-of-the-week endpoint
Exposes the highest-rated card from the past 7 days (configurable via
?days=) as a Discord-embed-ready payload.  AI teams are excluded by
default (?include_ai=true lifts the filter).  Deterministic tiebreak:
rating desc, pack open_time desc, card id desc.

Roadmap: Phase 2.6c — lowest-friction entry into the automated content
pipeline.  Single-call response includes player name, team, rarity,
rating, card image URL, and cardset name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:31:48 -05:00

128 lines
4.5 KiB
Python

"""
Router: featured cards
Provides endpoints that surface curated "featured" card selections for use
in automated content pipelines (n8n, Discord scheduled posts, etc.).
Endpoints
---------
GET /api/v2/cards/featured/card-of-the-week
Returns the single highest-rated eligible card created in the past N days,
with all fields needed to build a Discord embed in one call. Deterministic
for a given calendar day (cache-friendly).
Design notes
------------
- "Card created" is approximated by Pack.open_time — the timestamp when the
pack containing the card was opened. The Card model has no created_at
column; open_time is the closest available proxy and is semantically correct
(the card enters a team's collection at pack-open time).
- Rating is Card.value (the integer rating field set at card issuance).
- Tiebreak: highest value desc, then newest pack open_time desc, then
highest card.id desc — fully deterministic with no random element.
- AI teams (Team.is_ai is not NULL and != 0) are excluded by default.
Pass ?include_ai=true to lift that filter.
"""
from datetime import datetime, timedelta
from typing import Optional
import pydantic
from fastapi import APIRouter, HTTPException, Query
from ..db_engine import Card, Cardset, Pack, Player, Rarity, Team
router = APIRouter(prefix="/api/v2/cards/featured", tags=["featured"])
class CardOfTheWeekResponse(pydantic.BaseModel):
"""Single-call Discord embed payload for the card-of-the-week feature.
All fields needed for a rich embed are present so the consumer (n8n,
Discord bot) never needs a follow-up API call.
"""
card_id: int
player_name: str
team_name: str
team_abbrev: str
rarity_name: str
rating: int
card_image_url: str
cardset_name: str
card_created_at: Optional[str] # ISO-8601 string; None when pack has no open_time
@router.get(
"/card-of-the-week",
response_model=CardOfTheWeekResponse,
summary="Card of the Week",
description=(
"Returns the highest-rated card created (pack opened) within the last N days. "
"Excludes AI-team cards by default. Deterministic within a calendar day."
),
)
async def card_of_the_week(
days: int = Query(default=7, ge=1, le=90, description="Look-back window in days"),
include_ai: bool = Query(
default=False,
description="Include cards owned by AI teams (excluded by default)",
),
):
"""Return the card-of-the-week for use in Discord embeds and n8n automation.
Selection algorithm:
1. Filter cards whose pack was opened within the last `days` days.
2. Exclude AI-team cards unless include_ai=true.
3. Order by Card.value DESC, Pack.open_time DESC, Card.id DESC.
4. Return the first row as a flat Discord-ready payload.
Returns 404 when no eligible card exists in the window.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
query = (
Card.select(Card, Pack, Player, Team, Rarity, Cardset)
.join(Pack, on=(Card.pack == Pack.id))
.switch(Card)
.join(Player, on=(Card.player == Player.player_id))
.join(Rarity, on=(Player.rarity == Rarity.id))
.switch(Player)
.join(Cardset, on=(Player.cardset == Cardset.id))
.switch(Card)
.join(Team, on=(Card.team == Team.id))
.where(Pack.open_time >= cutoff)
.where(Card.player.is_null(False))
.where(Card.team.is_null(False))
.where(Card.pack.is_null(False))
)
if not include_ai:
# is_ai is an IntegerField(null=True); non-AI teams have is_ai=0 or NULL
query = query.where((Team.is_ai == 0) | (Team.is_ai.is_null(True)))
# Deterministic tiebreak: highest rating first, then newest pack, then card id
query = query.order_by(Card.value.desc(), Pack.open_time.desc(), Card.id.desc())
card = query.first()
if card is None:
raise HTTPException(status_code=404, detail="no featured card this week")
pack_open_time = None
if card.pack and card.pack.open_time:
ot = card.pack.open_time
pack_open_time = ot.isoformat() if hasattr(ot, "isoformat") else str(ot)
return CardOfTheWeekResponse(
card_id=card.id,
player_name=card.player.p_name,
team_name=card.team.sname,
team_abbrev=card.team.abbrev,
rarity_name=card.player.rarity.name,
rating=card.value,
card_image_url=card.player.image,
cardset_name=card.player.cardset.name,
card_created_at=pack_open_time,
)