- Add `= None` default to `Optional[str]` field in CardOfTheWeekResponse (Pydantic v1 convention: Optional fields should have explicit None default) - Remove unused `_PACK_COUNTER` dead variable from test module - Remove unused `cardset` parameter from `_make_pack_type` helper and update all 6 call sites Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
4.5 KiB
Python
130 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] = (
|
|
None # 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,
|
|
)
|