Files
mtgonline/backend/app/routers/card_router.py
T
akadmin 1df04aea52 Phase 7: End-to-End API Testing - Database setup, migrations, and application fixes
- Switched from psycopg2 to asyncpg for async SQLAlchemy support
- Fixed router registration in main.py - removed duplicate prefixes
- Added user_data export to routers/__init__.py
- Refactored decks router to use DeckManager service layer
- Integrated FuzzyCardMatcher into card_router search endpoints
- Made WishlistCreate.card_id optional for proper schema validation
- Set PostgreSQL password and configured scram-sha-256 auth
- Updated alembic.ini to use local PostgreSQL instead of Docker hostname
- Created generic_schemas.py for reusable schema patterns
- Added test_routers.py and test_schema_validation.py test files
- All 6 Alembic migrations applied successfully (37 tables created)
- Application running on port 8000 with all services connected
2026-08-18 03:35:19 +00:00

293 lines
9.1 KiB
Python

"""
Card search router for MTG card database.
Provides endpoints for searching and retrieving MTG card data
with filters for type, set, and color.
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import mtg_get_db
from app.core.redis_client import cache_get, cache_set
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.models.user_deck import UserDeck
from app.models.mtg_models import MtgCard
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@router.get("/search", response_model=CardSearchResponse)
async def search_cards_endpoint(
q: str = Query(..., min_length=1, description="Search query"),
card_type: Optional[str] = Query(None, description="Filter by card type"),
set_code: Optional[str] = Query(None, description="Filter by set code"),
color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Search cards with filters.
Supports filtering by type, set, and color in addition to name search.
Uses fuzzy matching as a fallback when exact/partial matches are not found.
"""
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Normalize the search query for consistency
normalized_query = FuzzyCardMatcher.normalize_card_name(q)
# Search cards using the existing service
results = await CardSearchService.search_cards(
db=db,
query=q,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
# If no results found, try fuzzy matching as a fallback
if results["total"] == 0:
fuzzy_results = await _fuzzy_search_fallback(
db=db,
query=q,
normalized_query=normalized_query,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
results = fuzzy_results
# Add fuzzy matching metadata to results
results["query_normalized"] = normalized_query
results["fuzzy_match"] = True
# Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300)
return {"cached": False, "results": results}
async def _fuzzy_search_fallback(
db: AsyncSession,
query: str,
normalized_query: str,
card_type: Optional[str] = None,
set_code: Optional[str] = None,
color: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
"""
Fallback fuzzy search when exact/partial matches yield no results.
Fetches all cards matching the type/set/color filters, then uses
fuzzy matching to find the best card name matches.
"""
from sqlalchemy import select, or_
# Fetch candidate cards based on non-name filters
conditions = []
if card_type:
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
if set_code:
conditions.append(MtgCard.set_code == set_code)
if color:
colors = [c.strip() for c in color.upper().split(",")]
for c in colors:
if c in ["W", "U", "B", "R", "G"]:
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
# If no filters, fetch a broader set for fuzzy matching
if not conditions:
stmt = select(MtgCard).limit(limit * 5)
else:
stmt = select(MtgCard).where(*conditions).limit(limit * 5)
result = await db.execute(stmt)
candidate_cards = result.scalars().all()
if not candidate_cards:
return {
"cards": [],
"total": 0,
"page": offset // limit + 1,
"page_size": limit,
"total_pages": 0,
"fuzzy_fallback": True,
"message": "No cards found matching your query.",
}
# Build candidate name list and lookup
candidate_names = [card.name for card in candidate_cards if card.name]
card_lookup = {card.name.lower(): card for card in candidate_cards if card.name}
# Use fuzzy matching to find best matches
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD
)
# Build results from fuzzy matches
card_list = []
if matched_name and matched_name.lower() in card_lookup:
card = card_lookup[matched_name.lower()]
card_data = {
"id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"oracle_text": card.oracle_text,
"power": card.power,
"toughness": card.toughness,
"rarity": card.rarity,
"layout": card.layout,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
"fuzzy_match": True,
"match_confidence": confidence,
"match_type": match_type,
"original_query": query,
}
card_list.append(card_data)
return {
"cards": card_list,
"total": len(card_list),
"page": offset // limit + 1,
"page_size": limit,
"total_pages": (len(card_list) + limit - 1) // limit if card_list else 0,
"fuzzy_fallback": True,
"match_type": match_type,
"confidence": confidence,
}
@router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint(
card_id: int,
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get a specific card by ID.
"""
cache_key = f"card_by_id:{card_id}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Get card
card = await CardSearchService.get_card_by_id(db, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
# Cache for 10 minutes
await cache_set(cache_key, str(card), ttl=600)
return {"cached": False, "results": card}
@router.get("/sets", response_model=List[SetResponse])
async def get_sets_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all available sets.
"""
cache_key = "all_sets:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
sets = await CardSearchService.get_sets(db)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": sets}
@router.get("/types", response_model=List[CardTypeResponse])
async def get_card_types_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all unique card types.
"""
cache_key = "card_types:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
types = await CardSearchService.get_card_types(db)
# Cache for 30 minutes
await cache_set(cache_key, str(types), ttl=1800)
return {"cached": False, "results": types}
@router.get("/rarities", response_model=List[str])
async def get_card_rarities_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all unique card rarities.
"""
cache_key = "card_rarities:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
rarities = await CardSearchService.get_card_rarities(db)
# Cache for 30 minutes
await cache_set(cache_key, str(rarities), ttl=1800)
return {"cached": False, "results": rarities}
@router.get("/suggest", response_model=List[Dict[str, Any]])
async def suggest_cards_endpoint(
deck_id: int = Query(..., description="Deck ID to suggest cards for"),
limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Suggest similar cards for a deck.
Matches by: same type, same color, same set, same mana cost,
and cards often paired in existing user decks.
"""
# Verify deck exists
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit)
return suggestions