- Removed 11 duplicate router files (auth.py, users.py, decks.py, card_router.py, interactions.py, user_data.py, rooms.py, games/router.py, refresh.py, card_import.py, ws.py) - Removed duplicate core files (logging_config.py, settings.py) - Removed unused services (card_mirror_service.py, mtgjson_loader.py, mtgjson_uploader.py) - Removed unused schemas (proto_messages.py, protocol_constants.py) - Created run.py startup script for simple execution - Created app/core/config.py for unified configuration management - Created app/core/logging.py for logging configuration - Consolidated routers into 6 focused files (auth_router.py, users_router.py, decks_router.py, cards_router.py, user_data_router.py, games_router.py) - Updated all imports to use consolidated model paths - Added backward compatibility aliases in database.py - Created SIMPLIFICATION_SUMMARY.md documentation Architecture improvements: - Single unified database engine with auto-detection (SQLite/PostgreSQL) - Consolidated models into 2 files (models.py, mtg_models.py) - Clean router structure with one file per domain - Simplified configuration with sensible defaults - SQLite by default for development, PostgreSQL for production Testing: - All imports verified working - App loads successfully - Ready for development and testing
996 lines
31 KiB
Python
996 lines
31 KiB
Python
"""
|
|
Cards Router - Consolidated Card Data and Interaction Endpoints.
|
|
|
|
This module provides two FastAPI routers for the MTG Online card database:
|
|
|
|
1. **Card Search Router** (`/api/cards`) - Card data endpoints:
|
|
- Search cards with filters and fuzzy matching fallback
|
|
- Retrieve individual cards by ID
|
|
- List available sets, card types, and rarities
|
|
- Suggest cards for a given deck
|
|
|
|
2. **Card Interactions Router** (`/interactions`) - Interaction data endpoints:
|
|
- Get synergies, counters, and evolutions for a card
|
|
- Search interactions with multi-filter support
|
|
- Get interaction recommendations
|
|
- Retrieve aggregated interaction statistics
|
|
|
|
The card search endpoints use SQLAlchemy ORM models and dedicated services
|
|
with Redis caching. The interaction endpoints use raw SQL queries against
|
|
the `mtg_card_*` tables, also with Redis caching.
|
|
|
|
Both routers share the `mtg_get_db` dependency (secondary database session).
|
|
"""
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import mtg_get_db
|
|
from app.core.redis_client import cache_get, cache_set
|
|
from app.models.mtg_models import MtgCard
|
|
from app.models.models import UserDeck
|
|
from app.schemas.card_search_schemas import (
|
|
CardResponse,
|
|
CardSearchResponse,
|
|
CardTypeResponse,
|
|
SetResponse,
|
|
)
|
|
from app.services.card_search_service import CardSearchService
|
|
from app.services.deck_suggestion_service import DeckSuggestionService
|
|
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Card Search Router (/api/cards)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
card_router = APIRouter(prefix="/api/cards", tags=["Card Search"])
|
|
|
|
|
|
@card_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),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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.
|
|
"""
|
|
# Fetch candidate cards based on non-name filters
|
|
conditions: list = []
|
|
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: Dict[str, Any] = {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: List[Dict[str, Any]] = []
|
|
if matched_name and matched_name.lower() in card_lookup:
|
|
card = card_lookup[matched_name.lower()]
|
|
card_data: Dict[str, Any] = {
|
|
"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,
|
|
}
|
|
|
|
|
|
@card_router.get("/{card_id}", response_model=CardResponse)
|
|
async def get_card_endpoint(
|
|
card_id: int,
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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}
|
|
|
|
|
|
@card_router.get("/sets", response_model=List[SetResponse])
|
|
async def get_sets_endpoint(
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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}
|
|
|
|
|
|
@card_router.get("/types", response_model=List[CardTypeResponse])
|
|
async def get_card_types_endpoint(
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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}
|
|
|
|
|
|
@card_router.get("/rarities", response_model=List[str])
|
|
async def get_card_rarities_endpoint(
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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}
|
|
|
|
|
|
@card_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),
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Card Interactions Router (/interactions)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
interaction_router = APIRouter(prefix="/interactions", tags=["Card Interactions"])
|
|
|
|
|
|
@interaction_router.get("/synergies/{card_id}")
|
|
async def get_card_synergies(
|
|
card_id: int,
|
|
synergy_type: Optional[str] = Query(
|
|
None, description="Filter by synergy type (archetype, mechanic, mana, combo)"
|
|
),
|
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum synergy strength"),
|
|
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),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get synergies for a specific card.
|
|
|
|
Synergies are positive interactions where cards work well together.
|
|
"""
|
|
cache_key = f"synergies:{card_id}:{synergy_type}:{min_strength}:{limit}:{offset}"
|
|
|
|
try:
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
# Build query
|
|
conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
|
params: Dict[str, Any] = {"card_id": card_id}
|
|
|
|
if synergy_type:
|
|
conditions.append("synergy_type = :synergy_type")
|
|
params["synergy_type"] = synergy_type
|
|
|
|
if min_strength:
|
|
conditions.append("strength >= :min_strength")
|
|
params["min_strength"] = min_strength
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
SELECT id, card_a_id, card_b_id, synergy_type, strength, notes, confidence
|
|
FROM mtg_card_synergies
|
|
WHERE {where_clause}
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
params["limit"] = limit
|
|
params["offset"] = offset
|
|
|
|
# Execute query
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
synergies: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
synergies.append({
|
|
"id": row[0],
|
|
"card_a_id": row[1],
|
|
"card_b_id": row[2],
|
|
"synergy_type": row[3],
|
|
"strength": row[4],
|
|
"notes": row[5],
|
|
"confidence": row[6],
|
|
})
|
|
|
|
# Get count for pagination
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM mtg_card_synergies
|
|
WHERE {where_clause}
|
|
"""
|
|
count_result = await db.execute(text(count_query), params)
|
|
total = count_result.scalar()
|
|
|
|
# Cache for 10 minutes
|
|
await cache_set(cache_key, {"synergies": synergies, "total": total}, ttl=600)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": synergies,
|
|
"pagination": {
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error fetching synergies: {str(e)}")
|
|
|
|
|
|
@interaction_router.get("/counters/{card_id}")
|
|
async def get_card_counters(
|
|
card_id: int,
|
|
counter_type: Optional[str] = Query(
|
|
None, description="Filter by counter type (color, stats, spell, keyword)"
|
|
),
|
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum counter strength"),
|
|
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),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get counters for a specific card.
|
|
|
|
Counters are negative interactions where one card is disadvantaged by another.
|
|
"""
|
|
cache_key = f"counters:{card_id}:{counter_type}:{min_strength}:{limit}:{offset}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
|
params: Dict[str, Any] = {"card_id": card_id}
|
|
|
|
if counter_type:
|
|
conditions.append("counter_type = :counter_type")
|
|
params["counter_type"] = counter_type
|
|
|
|
if min_strength:
|
|
conditions.append("strength >= :min_strength")
|
|
params["min_strength"] = min_strength
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
SELECT id, card_a_id, card_b_id, counter_type, strength, notes, confidence
|
|
FROM mtg_card_counters
|
|
WHERE {where_clause}
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
params["limit"] = limit
|
|
params["offset"] = offset
|
|
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
counters: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
counters.append({
|
|
"id": row[0],
|
|
"card_a_id": row[1],
|
|
"card_b_id": row[2],
|
|
"counter_type": row[3],
|
|
"strength": row[4],
|
|
"notes": row[5],
|
|
"confidence": row[6],
|
|
})
|
|
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM mtg_card_counters
|
|
WHERE {where_clause}
|
|
"""
|
|
count_result = await db.execute(text(count_query), params)
|
|
total = count_result.scalar()
|
|
|
|
await cache_set(cache_key, {"counters": counters, "total": total}, ttl=600)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": counters,
|
|
"pagination": {
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
}
|
|
|
|
|
|
@interaction_router.get("/evolutions/{card_id}")
|
|
async def get_card_evolutions(
|
|
card_id: int,
|
|
evolution_type: Optional[str] = Query(
|
|
None, description="Filter by evolution type (reprint, transform, double_sided)"
|
|
),
|
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
|
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),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get evolutions for a specific card.
|
|
|
|
Evolutions track when a card has been reprinted, transformed, or evolved.
|
|
"""
|
|
cache_key = f"evolutions:{card_id}:{evolution_type}:{min_strength}:{limit}:{offset}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
conditions: list[str] = ["card_id = :card_id"]
|
|
params: Dict[str, Any] = {"card_id": card_id}
|
|
|
|
if evolution_type:
|
|
conditions.append("evolution_type = :evolution_type")
|
|
params["evolution_type"] = evolution_type
|
|
|
|
if min_strength:
|
|
conditions.append("strength >= :min_strength")
|
|
params["min_strength"] = min_strength
|
|
|
|
where_clause = " AND ".join(conditions)
|
|
|
|
query = f"""
|
|
SELECT id, card_id, evolved_card_id, evolution_type, strength, notes, confidence
|
|
FROM mtg_card_evolution
|
|
WHERE {where_clause}
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
params["limit"] = limit
|
|
params["offset"] = offset
|
|
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
evolutions: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
evolutions.append({
|
|
"id": row[0],
|
|
"card_id": row[1],
|
|
"evolved_card_id": row[2],
|
|
"evolution_type": row[3],
|
|
"strength": row[4],
|
|
"notes": row[5],
|
|
"confidence": row[6],
|
|
})
|
|
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM mtg_card_evolution
|
|
WHERE {where_clause}
|
|
"""
|
|
count_result = await db.execute(text(count_query), params)
|
|
total = count_result.scalar()
|
|
|
|
await cache_set(cache_key, {"evolutions": evolutions, "total": total}, ttl=600)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": evolutions,
|
|
"pagination": {
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
}
|
|
|
|
|
|
@interaction_router.get("/recommend/{card_id}")
|
|
async def get_card_recommendations(
|
|
card_id: int,
|
|
recommendation_type: str = Query(
|
|
"synergy", description="Type of recommendation (synergy, counter, evolution)"
|
|
),
|
|
limit: int = Query(10, ge=1, le=100, description="Maximum results"),
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get interaction recommendations for a card.
|
|
|
|
Provides cards that work well together or counter a specific card.
|
|
"""
|
|
cache_key = f"recommend:{card_id}:{recommendation_type}:{limit}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
if recommendation_type == "synergy":
|
|
# Get cards that synergize with this card
|
|
query = """
|
|
SELECT
|
|
CASE WHEN card_a_id = :card_id THEN card_b_id ELSE card_a_id END as recommended_card_id,
|
|
strength,
|
|
synergy_type,
|
|
confidence
|
|
FROM mtg_card_synergies
|
|
WHERE card_a_id = :card_id OR card_b_id = :card_id
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit
|
|
"""
|
|
elif recommendation_type == "counter":
|
|
# Get cards that counter this card
|
|
query = """
|
|
SELECT
|
|
CASE WHEN card_a_id = :card_id THEN card_b_id ELSE card_a_id END as recommended_card_id,
|
|
strength,
|
|
counter_type,
|
|
confidence
|
|
FROM mtg_card_counters
|
|
WHERE card_a_id = :card_id OR card_b_id = :card_id
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit
|
|
"""
|
|
elif recommendation_type == "evolution":
|
|
# Get evolutions of this card
|
|
query = """
|
|
SELECT evolved_card_id as recommended_card_id,
|
|
strength,
|
|
evolution_type,
|
|
confidence
|
|
FROM mtg_card_evolution
|
|
WHERE card_id = :card_id
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit
|
|
"""
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Invalid recommendation type: {recommendation_type}"
|
|
)
|
|
|
|
params: Dict[str, Any] = {"card_id": card_id, "limit": limit}
|
|
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
recommendations: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
recommendations.append({
|
|
"recommended_card_id": row[0],
|
|
"strength": row[1],
|
|
"type": recommendation_type,
|
|
"subtype": row[2],
|
|
"confidence": row[3],
|
|
})
|
|
|
|
await cache_set(cache_key, recommendations, ttl=900)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": recommendations,
|
|
}
|
|
|
|
|
|
@interaction_router.get("/search/synergies")
|
|
async def search_synergies(
|
|
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
|
card_b_id: Optional[int] = Query(None, description="Card B ID"),
|
|
synergy_type: Optional[str] = Query(None, description="Filter by synergy type"),
|
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
|
min_confidence: float = Query(0.0, ge=0.0, le=1.0, description="Minimum confidence"),
|
|
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Search synergies with multiple filters.
|
|
"""
|
|
cache_key = f"search_synergies:{card_a_id}:{card_b_id}:{synergy_type}:{min_strength}:{min_confidence}:{limit}:{offset}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
conditions: list[str] = []
|
|
params: Dict[str, Any] = {}
|
|
|
|
if card_a_id:
|
|
conditions.append("card_a_id = :card_a_id")
|
|
params["card_a_id"] = card_a_id
|
|
|
|
if card_b_id:
|
|
conditions.append("card_b_id = :card_b_id")
|
|
params["card_b_id"] = card_b_id
|
|
|
|
if synergy_type:
|
|
conditions.append("synergy_type = :synergy_type")
|
|
params["synergy_type"] = synergy_type
|
|
|
|
if min_strength:
|
|
conditions.append("strength >= :min_strength")
|
|
params["min_strength"] = min_strength
|
|
|
|
if min_confidence:
|
|
conditions.append("confidence >= :min_confidence")
|
|
params["min_confidence"] = min_confidence
|
|
|
|
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
|
|
|
query = f"""
|
|
SELECT id, card_a_id, card_b_id, synergy_type, strength, notes, confidence
|
|
FROM mtg_card_synergies
|
|
WHERE {where_clause}
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
params["limit"] = limit
|
|
params["offset"] = offset
|
|
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
synergies: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
synergies.append({
|
|
"id": row[0],
|
|
"card_a_id": row[1],
|
|
"card_b_id": row[2],
|
|
"synergy_type": row[3],
|
|
"strength": row[4],
|
|
"notes": row[5],
|
|
"confidence": row[6],
|
|
})
|
|
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM mtg_card_synergies
|
|
WHERE {where_clause}
|
|
"""
|
|
count_result = await db.execute(text(count_query), params)
|
|
total = count_result.scalar()
|
|
|
|
await cache_set(cache_key, {"synergies": synergies, "total": total}, ttl=600)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": synergies,
|
|
"pagination": {
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
}
|
|
|
|
|
|
@interaction_router.get("/search/counters")
|
|
async def search_counters(
|
|
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
|
card_b_id: Optional[int] = Query(None, description="Card B ID"),
|
|
counter_type: Optional[str] = Query(None, description="Filter by counter type"),
|
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
|
min_confidence: float = Query(0.0, ge=0.0, le=1.0, description="Minimum confidence"),
|
|
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Search counters with multiple filters.
|
|
"""
|
|
cache_key = f"search_counters:{card_a_id}:{card_b_id}:{counter_type}:{min_strength}:{min_confidence}:{limit}:{offset}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
conditions: list[str] = []
|
|
params: Dict[str, Any] = {}
|
|
|
|
if card_a_id:
|
|
conditions.append("card_a_id = :card_a_id")
|
|
params["card_a_id"] = card_a_id
|
|
|
|
if card_b_id:
|
|
conditions.append("card_b_id = :card_b_id")
|
|
params["card_b_id"] = card_b_id
|
|
|
|
if counter_type:
|
|
conditions.append("counter_type = :counter_type")
|
|
params["counter_type"] = counter_type
|
|
|
|
if min_strength:
|
|
conditions.append("strength >= :min_strength")
|
|
params["min_strength"] = min_strength
|
|
|
|
if min_confidence:
|
|
conditions.append("confidence >= :min_confidence")
|
|
params["min_confidence"] = min_confidence
|
|
|
|
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
|
|
|
query = f"""
|
|
SELECT id, card_a_id, card_b_id, counter_type, strength, notes, confidence
|
|
FROM mtg_card_counters
|
|
WHERE {where_clause}
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
params["limit"] = limit
|
|
params["offset"] = offset
|
|
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
counters: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
counters.append({
|
|
"id": row[0],
|
|
"card_a_id": row[1],
|
|
"card_b_id": row[2],
|
|
"counter_type": row[3],
|
|
"strength": row[4],
|
|
"notes": row[5],
|
|
"confidence": row[6],
|
|
})
|
|
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM mtg_card_counters
|
|
WHERE {where_clause}
|
|
"""
|
|
count_result = await db.execute(text(count_query), params)
|
|
total = count_result.scalar()
|
|
|
|
await cache_set(cache_key, {"counters": counters, "total": total}, ttl=600)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": counters,
|
|
"pagination": {
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
}
|
|
|
|
|
|
@interaction_router.get("/search/evolutions")
|
|
async def search_evolutions(
|
|
card_id: Optional[int] = Query(None, description="Card ID"),
|
|
evolved_card_id: Optional[int] = Query(None, description="Evolved Card ID"),
|
|
evolution_type: Optional[str] = Query(None, description="Filter by evolution type"),
|
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
|
min_confidence: float = Query(0.0, ge=0.0, le=1.0, description="Minimum confidence"),
|
|
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Search evolutions with multiple filters.
|
|
"""
|
|
cache_key = f"search_evolutions:{card_id}:{evolved_card_id}:{evolution_type}:{min_strength}:{min_confidence}:{limit}:{offset}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
conditions: list[str] = []
|
|
params: Dict[str, Any] = {}
|
|
|
|
if card_id:
|
|
conditions.append("card_id = :card_id")
|
|
params["card_id"] = card_id
|
|
|
|
if evolved_card_id:
|
|
conditions.append("evolved_card_id = :evolved_card_id")
|
|
params["evolved_card_id"] = evolved_card_id
|
|
|
|
if evolution_type:
|
|
conditions.append("evolution_type = :evolution_type")
|
|
params["evolution_type"] = evolution_type
|
|
|
|
if min_strength:
|
|
conditions.append("strength >= :min_strength")
|
|
params["min_strength"] = min_strength
|
|
|
|
if min_confidence:
|
|
conditions.append("confidence >= :min_confidence")
|
|
params["min_confidence"] = min_confidence
|
|
|
|
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
|
|
|
query = f"""
|
|
SELECT id, card_id, evolved_card_id, evolution_type, strength, notes, confidence
|
|
FROM mtg_card_evolution
|
|
WHERE {where_clause}
|
|
ORDER BY strength DESC, confidence DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
params["limit"] = limit
|
|
params["offset"] = offset
|
|
|
|
result = await db.execute(text(query), params)
|
|
rows = result.fetchall()
|
|
|
|
evolutions: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
evolutions.append({
|
|
"id": row[0],
|
|
"card_id": row[1],
|
|
"evolved_card_id": row[2],
|
|
"evolution_type": row[3],
|
|
"strength": row[4],
|
|
"notes": row[5],
|
|
"confidence": row[6],
|
|
})
|
|
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM mtg_card_evolution
|
|
WHERE {where_clause}
|
|
"""
|
|
count_result = await db.execute(text(count_query), params)
|
|
total = count_result.scalar()
|
|
|
|
await cache_set(cache_key, {"evolutions": evolutions, "total": total}, ttl=600)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": evolutions,
|
|
"pagination": {
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
}
|
|
|
|
|
|
@interaction_router.get("/stats/{card_id}")
|
|
async def get_card_interaction_stats(
|
|
card_id: int,
|
|
db: AsyncSession = Depends(mtg_get_db),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get aggregated interaction statistics for a card.
|
|
"""
|
|
cache_key = f"interaction_stats:{card_id}"
|
|
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return {"cached": True, "results": cached}
|
|
|
|
query = """
|
|
SELECT
|
|
card_id,
|
|
total_synergies,
|
|
total_counters,
|
|
total_evolutions,
|
|
total_synergy_strength,
|
|
avg_synergy_strength
|
|
FROM mtg_card_interaction_stats
|
|
WHERE card_id = :card_id
|
|
"""
|
|
params: Dict[str, Any] = {"card_id": card_id}
|
|
|
|
result = await db.execute(text(query), params)
|
|
row = result.fetchone()
|
|
|
|
if not row:
|
|
return {
|
|
"cached": False,
|
|
"results": {
|
|
"card_id": card_id,
|
|
"total_synergies": 0,
|
|
"total_counters": 0,
|
|
"total_evolutions": 0,
|
|
"total_synergy_strength": 0,
|
|
"avg_synergy_strength": 0,
|
|
},
|
|
}
|
|
|
|
stats: Dict[str, Any] = {
|
|
"card_id": row[0],
|
|
"total_synergies": row[1],
|
|
"total_counters": row[2],
|
|
"total_evolutions": row[3],
|
|
"total_synergy_strength": row[4],
|
|
"avg_synergy_strength": row[5],
|
|
}
|
|
|
|
await cache_set(cache_key, stats, ttl=1800)
|
|
|
|
return {
|
|
"cached": False,
|
|
"results": stats,
|
|
}
|