diff --git a/app/routers_v2/cardsets.py b/app/routers_v2/cardsets.py index b9806d4..8be4433 100644 --- a/app/routers_v2/cardsets.py +++ b/app/routers_v2/cardsets.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Response, Query from typing import Optional import logging import pydantic @@ -84,6 +84,79 @@ async def get_cardsets( return return_val +@router.get('/search') +async def search_cardsets( + q: str = Query(..., description="Search query for cardset name"), + in_packs: Optional[bool] = None, + ranked_legal: Optional[bool] = None, + event_id: Optional[int] = None, + limit: int = Query(default=25, ge=1, le=100, description="Maximum number of results to return")): + """ + Real-time fuzzy search for cardsets by name. + + Returns cardsets matching the query with exact matches prioritized over partial matches. + """ + # Start with all cardsets + all_cardsets = Cardset.select() + + # Apply name filter (partial match) + all_cardsets = all_cardsets.where(fn.Lower(Cardset.name).contains(q.lower())) + + # Apply optional filters + if in_packs is not None: + all_cardsets = all_cardsets.where(Cardset.in_packs == in_packs) + + if ranked_legal is not None: + all_cardsets = all_cardsets.where(Cardset.ranked_legal == ranked_legal) + + if event_id is not None: + try: + this_event = Event.get_by_id(event_id) + all_cardsets = all_cardsets.where(Cardset.event == this_event) + except Exception as e: + logging.error(f'Failed to find event {event_id}: {e}') + db.close() + raise HTTPException(status_code=404, detail=f'Event id {event_id} not found') + + # Convert to list for sorting + cardsets_list = list(all_cardsets) + + # Sort by relevance (exact matches first, then name starts, then partial) + query_lower = q.lower() + exact_matches = [] + name_start_matches = [] + partial_matches = [] + + for cardset in cardsets_list: + name_lower = cardset.name.lower() + if name_lower == query_lower: + exact_matches.append(cardset) + else: + # Check if query matches the start of any word in name + name_parts = name_lower.split() + starts_with_match = any(part.startswith(query_lower) for part in name_parts) + + if starts_with_match: + name_start_matches.append(cardset) + elif query_lower in name_lower: + partial_matches.append(cardset) + + # Combine and limit results (exact, then name starts, then partial) + results = exact_matches + name_start_matches + partial_matches + total_matches = len(results) + limited_results = results[:limit] + + # Build response + return_val = { + 'count': len(limited_results), + 'total_matches': total_matches, + 'cardsets': [model_to_dict(x) for x in limited_results] + } + + db.close() + return return_val + + @router.get('/{cardset_id}') async def get_one_cardset(cardset_id, csv: Optional[bool] = False): try: diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 34c72d5..eded10e 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -331,6 +331,92 @@ async def get_random_player( return return_val +@router.get('/search') +async def search_players( + q: str = Query(..., description="Search query for player name"), + cardset_id: list = Query(default=None), + rarity_id: list = Query(default=None), + limit: int = Query(default=25, ge=1, le=100, description="Maximum number of results to return"), + unique_names: bool = Query(default=False, description="Return only unique player names (highest player_id)"), + short_output: bool = False): + """ + Real-time fuzzy search for players by name. + + Returns players matching the query with exact matches prioritized over partial matches. + When unique_names=True, only returns one player per unique name (the one with highest player_id). + """ + # Start with all players + all_players = Player.select() + + # Apply name filter (partial match) + all_players = all_players.where(fn.Lower(Player.p_name).contains(q.lower())) + + # Apply optional filters + if cardset_id is not None: + all_players = all_players.where(Player.cardset_id << cardset_id) + + if rarity_id is not None: + all_players = all_players.where(Player.rarity_id << rarity_id) + + # Convert to list for sorting + players_list = list(all_players) + + # Sort by relevance (exact matches first, then name starts, then partial) + query_lower = q.lower() + exact_matches = [] + name_start_matches = [] + partial_matches = [] + + for player in players_list: + name_lower = player.p_name.lower() + if name_lower == query_lower: + exact_matches.append(player) + else: + # Check if query matches the start of first or last name + name_parts = name_lower.split() + starts_with_match = any(part.startswith(query_lower) for part in name_parts) + + if starts_with_match: + name_start_matches.append(player) + elif query_lower in name_lower: + partial_matches.append(player) + + # Combine results (exact, then name starts, then partial) + results = exact_matches + name_start_matches + partial_matches + + # Deduplicate by name if requested (keeping highest player_id) + if unique_names: + seen_names = {} + for player in results: + name_lower = player.p_name.lower() + if name_lower not in seen_names or player.player_id > seen_names[name_lower].player_id: + seen_names[name_lower] = player + results = list(seen_names.values()) + + total_matches = len(results) + limited_results = results[:limit] + + # Build response + return_val = { + 'count': len(limited_results), + 'total_matches': total_matches, + 'players': [] + } + + for x in limited_results: + this_record = model_to_dict(x, recurse=not short_output) + + # this_dex = Paperdex.select().where(Paperdex.player == x) + # this_record['paperdex'] = {'count': this_dex.count(), 'paperdex': []} + # for y in this_dex: + # this_record['paperdex']['paperdex'].append(model_to_dict(y, recurse=False)) + + return_val['players'].append(this_record) + + db.close() + return return_val + + @router.get('/{player_id}') async def get_one_player(player_id, csv: Optional[bool] = False): try: