- 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
293 lines
9.1 KiB
Python
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
|