From ef9cd3d4a5c42b11d0f4d7f45b6636efa9601208 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 16:03:28 -0600 Subject: [PATCH] fix: eliminate N+1 queries in get_custom_commands (#26) Expand the JOIN SELECT to include all creator fields (created_at, total_commands, active_commands) and build the CustomCommandCreatorModel directly from joined row data instead of issuing a separate SELECT per command. Reduces 26 queries to 2 for a full 25-command page. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v3/custom_commands.py | 755 ++++++++++++++++-------------- 1 file changed, 415 insertions(+), 340 deletions(-) diff --git a/app/routers_v3/custom_commands.py b/app/routers_v3/custom_commands.py index 4830a50..c9110ff 100644 --- a/app/routers_v3/custom_commands.py +++ b/app/routers_v3/custom_commands.py @@ -7,15 +7,17 @@ import json from playhouse.shortcuts import model_to_dict from peewee import fn -from ..dependencies import oauth2_scheme, valid_token, PRIVATE_IN_SCHEMA, handle_db_errors +from ..dependencies import ( + oauth2_scheme, + valid_token, + PRIVATE_IN_SCHEMA, + handle_db_errors, +) from ..db_engine import db, CustomCommand, CustomCommandCreator -logger = logging.getLogger('database_api') +logger = logging.getLogger("database_api") -router = APIRouter( - prefix='/api/v3/custom_commands', - tags=['custom_commands'] -) +router = APIRouter(prefix="/api/v3/custom_commands", tags=["custom_commands"]) # Pydantic Models for API @@ -69,9 +71,9 @@ class CustomCommandStatsResponse(BaseModel): def convert_datetime_to_iso(data_dict, fields=None): """Convert datetime objects to ISO strings in a dictionary.""" if fields is None: - fields = ['created_at', 'updated_at', 'last_used'] + fields = ["created_at", "updated_at", "last_used"] for field in fields: - if data_dict.get(field) and hasattr(data_dict[field], 'isoformat'): + if data_dict.get(field) and hasattr(data_dict[field], "isoformat"): data_dict[field] = data_dict[field].isoformat() return data_dict @@ -83,9 +85,11 @@ def update_creator_stats(creator_id: int): return total = CustomCommand.select().where(CustomCommand.creator == creator).count() - active = CustomCommand.select().where( - (CustomCommand.creator == creator) & (CustomCommand.is_active == True) - ).count() + active = ( + CustomCommand.select() + .where((CustomCommand.creator == creator) & (CustomCommand.is_active == True)) + .count() + ) creator.total_commands = total creator.active_commands = active @@ -94,46 +98,46 @@ def update_creator_stats(creator_id: int): def get_custom_command_by_name(name: str): """Get a custom command by name with creator info""" - command = CustomCommand.select( - CustomCommand, - CustomCommandCreator - ).join( - CustomCommandCreator, on=(CustomCommand.creator == CustomCommandCreator.id) - ).where( - fn.LOWER(CustomCommand.name) == name.lower() - ).first() + command = ( + CustomCommand.select(CustomCommand, CustomCommandCreator) + .join( + CustomCommandCreator, on=(CustomCommand.creator == CustomCommandCreator.id) + ) + .where(fn.LOWER(CustomCommand.name) == name.lower()) + .first() + ) if command: result = model_to_dict(command, recurse=False) # Ensure creator_id is in the result (it should be from model_to_dict, but make sure) - result['creator_id'] = command.creator.id - result['creator_discord_id'] = command.creator.discord_id - result['creator_username'] = command.creator.username - result['creator_display_name'] = command.creator.display_name + result["creator_id"] = command.creator.id + result["creator_discord_id"] = command.creator.discord_id + result["creator_username"] = command.creator.username + result["creator_display_name"] = command.creator.display_name return result return None def get_custom_command_by_id(command_id: int): """Get a custom command by ID with creator info""" - command = CustomCommand.select( - CustomCommand, - CustomCommandCreator - ).join( - CustomCommandCreator, on=(CustomCommand.creator == CustomCommandCreator.id) - ).where( - CustomCommand.id == command_id - ).first() + command = ( + CustomCommand.select(CustomCommand, CustomCommandCreator) + .join( + CustomCommandCreator, on=(CustomCommand.creator == CustomCommandCreator.id) + ) + .where(CustomCommand.id == command_id) + .first() + ) if command: result = model_to_dict(command, recurse=False) - result['creator_db_id'] = command.creator.id - result['creator_discord_id'] = command.creator.discord_id - result['creator_username'] = command.creator.username - result['creator_display_name'] = command.creator.display_name - result['creator_created_at'] = command.creator.created_at - result['total_commands'] = command.creator.total_commands - result['active_commands'] = command.creator.active_commands + result["creator_db_id"] = command.creator.id + result["creator_discord_id"] = command.creator.discord_id + result["creator_username"] = command.creator.username + result["creator_display_name"] = command.creator.display_name + result["creator_created_at"] = command.creator.created_at + result["total_commands"] = command.creator.total_commands + result["active_commands"] = command.creator.active_commands return result return None @@ -141,12 +145,12 @@ def get_custom_command_by_id(command_id: int): def create_custom_command(command_data: dict) -> int: """Create a new custom command and return its ID""" # Convert tags list to JSON if present - if 'tags' in command_data and isinstance(command_data['tags'], list): - command_data['tags'] = json.dumps(command_data['tags']) + if "tags" in command_data and isinstance(command_data["tags"], list): + command_data["tags"] = json.dumps(command_data["tags"]) # Set created_at if not provided - if 'created_at' not in command_data: - command_data['created_at'] = datetime.now() + if "created_at" not in command_data: + command_data["created_at"] = datetime.now() command = CustomCommand.create(**command_data) return command.id @@ -155,8 +159,8 @@ def create_custom_command(command_data: dict) -> int: def update_custom_command(command_id: int, update_data: dict): """Update an existing custom command""" # Convert tags list to JSON if present - if 'tags' in update_data and isinstance(update_data['tags'], list): - update_data['tags'] = json.dumps(update_data['tags']) + if "tags" in update_data and isinstance(update_data["tags"], list): + update_data["tags"] = json.dumps(update_data["tags"]) query = CustomCommand.update(**update_data).where(CustomCommand.id == command_id) query.execute() @@ -170,7 +174,9 @@ def delete_custom_command(command_id: int): def get_creator_by_discord_id(discord_id: int): """Get a creator by Discord ID""" - creator = CustomCommandCreator.get_or_none(CustomCommandCreator.discord_id == str(discord_id)) + creator = CustomCommandCreator.get_or_none( + CustomCommandCreator.discord_id == str(discord_id) + ) if creator: return model_to_dict(creator) return None @@ -178,8 +184,8 @@ def get_creator_by_discord_id(discord_id: int): def create_creator(creator_data: dict) -> int: """Create a new creator and return their ID""" - if 'created_at' not in creator_data: - creator_data['created_at'] = datetime.now() + if "created_at" not in creator_data: + creator_data["created_at"] = datetime.now() creator = CustomCommandCreator.create(**creator_data) return creator.id @@ -187,7 +193,8 @@ def create_creator(creator_data: dict) -> int: # API Endpoints -@router.get('') + +@router.get("") @handle_db_errors async def get_custom_commands( name: Optional[str] = None, @@ -195,16 +202,16 @@ async def get_custom_commands( min_uses: Optional[int] = None, max_days_unused: Optional[int] = None, is_active: Optional[bool] = True, - sort: Optional[str] = 'name', + sort: Optional[str] = "name", page: int = Query(1, ge=1), - page_size: int = Query(25, ge=1, le=100) + page_size: int = Query(25, ge=1, le=100), ): """Get custom commands with filtering and pagination""" try: # Build WHERE clause where_conditions = [] params = [] - + if name is not None: where_conditions.append("cc.name ILIKE %s") params.append(f"%{name}%") @@ -225,28 +232,30 @@ async def get_custom_commands( if is_active is not None: where_conditions.append("cc.is_active = %s") params.append(is_active) - - where_clause = "WHERE " + " AND ".join(where_conditions) if where_conditions else "" - + + where_clause = ( + "WHERE " + " AND ".join(where_conditions) if where_conditions else "" + ) + # Build ORDER BY clause sort_mapping = { - 'name': 'cc.name', - 'created_at': 'cc.created_at', - 'last_used': 'cc.last_used', - 'use_count': 'cc.use_count', - 'creator': 'creator.username' + "name": "cc.name", + "created_at": "cc.created_at", + "last_used": "cc.last_used", + "use_count": "cc.use_count", + "creator": "creator.username", } - - if sort.startswith('-'): - order_direction = 'DESC' + + if sort.startswith("-"): + order_direction = "DESC" sort_field = sort[1:] else: - order_direction = 'ASC' + order_direction = "ASC" sort_field = sort - - order_by = sort_mapping.get(sort_field, 'cc.name') + + order_by = sort_mapping.get(sort_field, "cc.name") order_clause = f"ORDER BY {order_by} {order_direction}" - + # Get total count count_sql = f""" SELECT COUNT(*) @@ -255,16 +264,19 @@ async def get_custom_commands( {where_clause} """ total_count = db.execute_sql(count_sql, params).fetchone()[0] - + # Calculate pagination offset = (page - 1) * page_size total_pages = (total_count + page_size - 1) // page_size - + # Get commands sql = f""" SELECT cc.*, creator.discord_id as creator_discord_id, creator.username as creator_username, - creator.display_name as creator_display_name + creator.display_name as creator_display_name, + creator.created_at as creator_created_at, + creator.total_commands as creator_total_commands, + creator.active_commands as creator_active_commands FROM custom_commands cc LEFT JOIN custom_command_creators creator ON cc.creator_id = creator.id {where_clause} @@ -275,51 +287,61 @@ async def get_custom_commands( params.extend([page_size, offset]) cursor3 = db.execute_sql(sql, params) results = cursor3.fetchall() - + # Convert to CustomCommandModel objects with creator info commands = [] if results: columns3 = [desc[0] for desc in cursor3.description] for row in results: command_dict = dict(zip(columns3, row)) - + # Parse tags if they exist - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] - - # Get full creator information - creator_id = command_dict['creator_id'] - creator_cursor = db.execute_sql("SELECT * FROM custom_command_creators WHERE id = %s", (creator_id,)) - creator_result = creator_cursor.fetchone() - - if creator_result: - # Create complete creator object - creator_columns = [desc[0] for desc in creator_cursor.description] - creator_dict = dict(zip(creator_columns, creator_result)) - # Convert datetime to ISO string - if creator_dict.get('created_at') and hasattr(creator_dict['created_at'], 'isoformat'): - creator_dict['created_at'] = creator_dict['created_at'].isoformat() + command_dict["tags"] = [] + + # Build creator object from joined data (avoids N+1 queries) + if command_dict.get("creator_discord_id") is not None: + creator_created_at = command_dict.get("creator_created_at") + if creator_created_at and hasattr(creator_created_at, "isoformat"): + creator_created_at = creator_created_at.isoformat() + creator_dict = { + "id": command_dict["creator_id"], + "discord_id": command_dict["creator_discord_id"], + "username": command_dict["creator_username"], + "display_name": command_dict.get("creator_display_name"), + "created_at": creator_created_at, + "total_commands": command_dict.get("creator_total_commands", 0), + "active_commands": command_dict.get( + "creator_active_commands", 0 + ), + } try: creator_model = CustomCommandCreatorModel(**creator_dict) - command_dict['creator'] = creator_model + command_dict["creator"] = creator_model except Exception as e: - logger.error(f"Error creating CustomCommandCreatorModel: {e}, data: {creator_dict}") - command_dict['creator'] = None + logger.error( + f"Error creating CustomCommandCreatorModel: {e}, data: {creator_dict}" + ) + command_dict["creator"] = None else: - # No creator found, set to None - command_dict['creator'] = None - - # Remove the individual creator fields now that we have the creator object - command_dict.pop('creator_discord_id', None) - command_dict.pop('creator_username', None) - command_dict.pop('creator_display_name', None) + command_dict["creator"] = None + + # Remove joined creator fields before building the command model + command_dict.pop("creator_discord_id", None) + command_dict.pop("creator_username", None) + command_dict.pop("creator_display_name", None) + command_dict.pop("creator_created_at", None) + command_dict.pop("creator_total_commands", None) + command_dict.pop("creator_active_commands", None) # Convert datetime fields to ISO strings - for field in ['created_at', 'updated_at', 'last_used']: - if command_dict.get(field) and hasattr(command_dict[field], 'isoformat'): + for field in ["created_at", "updated_at", "last_used"]: + if command_dict.get(field) and hasattr( + command_dict[field], "isoformat" + ): command_dict[field] = command_dict[field].isoformat() # Create CustomCommandModel instance @@ -327,19 +349,21 @@ async def get_custom_commands( command_model = CustomCommandModel(**command_dict) commands.append(command_model) except Exception as e: - logger.error(f"Error creating CustomCommandModel: {e}, data: {command_dict}") + logger.error( + f"Error creating CustomCommandModel: {e}, data: {command_dict}" + ) # Skip invalid commands rather than failing the entire request continue - + return CustomCommandListResponse( custom_commands=commands, total_count=total_count, page=page, page_size=page_size, total_pages=total_pages, - has_more=page < total_pages + has_more=page < total_pages, ) - + except Exception as e: logger.error(f"Error getting custom commands: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -350,30 +374,31 @@ async def get_custom_commands( # Move this route to after the specific string routes -@router.post('', include_in_schema=PRIVATE_IN_SCHEMA) +@router.post("", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def create_custom_command_endpoint( - command: CustomCommandModel, - token: str = Depends(oauth2_scheme) + command: CustomCommandModel, token: str = Depends(oauth2_scheme) ): """Create a new custom command""" if not valid_token(token): - logger.warning(f'create_custom_command - Bad Token: {token}') - raise HTTPException(status_code=401, detail='Unauthorized') - + logger.warning(f"create_custom_command - Bad Token: {token}") + raise HTTPException(status_code=401, detail="Unauthorized") + try: # Check if command name already exists existing = get_custom_command_by_name(command.name) if existing: - raise HTTPException(status_code=409, detail=f"Command '{command.name}' already exists") - + raise HTTPException( + status_code=409, detail=f"Command '{command.name}' already exists" + ) + # Create the command - command_data = command.model_dump(exclude={'id', 'creator'}) + command_data = command.model_dump(exclude={"id", "creator"}) command_id = create_custom_command(command_data) - + # Update creator stats update_creator_stats(command.creator_id) - + # Return the created command result = get_custom_command_by_id(command_id) command_dict = dict(result) @@ -381,28 +406,28 @@ async def create_custom_command_endpoint( # Convert datetime fields convert_datetime_to_iso(command_dict) - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] + command_dict["tags"] = [] - creator_created_at = command_dict.pop('creator_created_at') - if hasattr(creator_created_at, 'isoformat'): + creator_created_at = command_dict.pop("creator_created_at") + if hasattr(creator_created_at, "isoformat"): creator_created_at = creator_created_at.isoformat() - command_dict['creator'] = { - 'id': command_dict.pop('creator_db_id'), - 'discord_id': command_dict.pop('creator_discord_id'), - 'username': command_dict.pop('creator_username'), - 'display_name': command_dict.pop('creator_display_name'), - 'created_at': creator_created_at, - 'total_commands': command_dict.pop('total_commands'), - 'active_commands': command_dict.pop('active_commands') + command_dict["creator"] = { + "id": command_dict.pop("creator_db_id"), + "discord_id": command_dict.pop("creator_discord_id"), + "username": command_dict.pop("creator_username"), + "display_name": command_dict.pop("creator_display_name"), + "created_at": creator_created_at, + "total_commands": command_dict.pop("total_commands"), + "active_commands": command_dict.pop("active_commands"), } return command_dict - + except HTTPException: raise except Exception as e: @@ -412,29 +437,29 @@ async def create_custom_command_endpoint( db.close() -@router.put('/{command_id}', include_in_schema=PRIVATE_IN_SCHEMA) +@router.put("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def update_custom_command_endpoint( - command_id: int, - command: CustomCommandModel, - token: str = Depends(oauth2_scheme) + command_id: int, command: CustomCommandModel, token: str = Depends(oauth2_scheme) ): """Update an existing custom command""" if not valid_token(token): - logger.warning(f'update_custom_command - Bad Token: {token}') - raise HTTPException(status_code=401, detail='Unauthorized') - + logger.warning(f"update_custom_command - Bad Token: {token}") + raise HTTPException(status_code=401, detail="Unauthorized") + try: # Check if command exists existing = get_custom_command_by_id(command_id) if not existing: - raise HTTPException(status_code=404, detail=f"Custom command {command_id} not found") - + raise HTTPException( + status_code=404, detail=f"Custom command {command_id} not found" + ) + # Update the command - update_data = command.model_dump(exclude={'id', 'creator'}) - update_data['updated_at'] = datetime.now().isoformat() + update_data = command.model_dump(exclude={"id", "creator"}) + update_data["updated_at"] = datetime.now().isoformat() update_custom_command(command_id, update_data) - + # Return updated command result = get_custom_command_by_id(command_id) command_dict = dict(result) @@ -442,24 +467,24 @@ async def update_custom_command_endpoint( # Convert datetime fields convert_datetime_to_iso(command_dict) - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] + command_dict["tags"] = [] - creator_created_at = command_dict.pop('creator_created_at') - if hasattr(creator_created_at, 'isoformat'): + creator_created_at = command_dict.pop("creator_created_at") + if hasattr(creator_created_at, "isoformat"): creator_created_at = creator_created_at.isoformat() - command_dict['creator'] = { - 'id': command_dict.pop('creator_db_id'), - 'discord_id': command_dict.pop('creator_discord_id'), - 'username': command_dict.pop('creator_username'), - 'display_name': command_dict.pop('creator_display_name'), - 'created_at': creator_created_at, - 'total_commands': command_dict.pop('total_commands'), - 'active_commands': command_dict.pop('active_commands') + command_dict["creator"] = { + "id": command_dict.pop("creator_db_id"), + "discord_id": command_dict.pop("creator_discord_id"), + "username": command_dict.pop("creator_username"), + "display_name": command_dict.pop("creator_display_name"), + "created_at": creator_created_at, + "total_commands": command_dict.pop("total_commands"), + "active_commands": command_dict.pop("active_commands"), } return command_dict @@ -473,7 +498,7 @@ async def update_custom_command_endpoint( db.close() -@router.patch('/{command_id}', include_in_schema=PRIVATE_IN_SCHEMA) +@router.patch("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def patch_custom_command( command_id: int, @@ -483,41 +508,43 @@ async def patch_custom_command( use_count: Optional[int] = None, last_used: Optional[str] = None, warning_sent: Optional[bool] = None, - is_active: Optional[bool] = None + is_active: Optional[bool] = None, ): """Partially update a custom command""" if not valid_token(token): - logger.warning(f'patch_custom_command - Bad Token: {token}') - raise HTTPException(status_code=401, detail='Unauthorized') - + logger.warning(f"patch_custom_command - Bad Token: {token}") + raise HTTPException(status_code=401, detail="Unauthorized") + try: # Check if command exists existing = get_custom_command_by_id(command_id) if not existing: - raise HTTPException(status_code=404, detail=f"Custom command {command_id} not found") - + raise HTTPException( + status_code=404, detail=f"Custom command {command_id} not found" + ) + # Build update data update_data = {} if content is not None: - update_data['content'] = content - update_data['updated_at'] = datetime.now().isoformat() + update_data["content"] = content + update_data["updated_at"] = datetime.now().isoformat() if tags is not None: - update_data['tags'] = tags + update_data["tags"] = tags if use_count is not None: - update_data['use_count'] = use_count + update_data["use_count"] = use_count if last_used is not None: - update_data['last_used'] = last_used + update_data["last_used"] = last_used if warning_sent is not None: - update_data['warning_sent'] = warning_sent + update_data["warning_sent"] = warning_sent if is_active is not None: - update_data['is_active'] = is_active - + update_data["is_active"] = is_active + if not update_data: raise HTTPException(status_code=400, detail="No fields to update") - + # Update the command update_custom_command(command_id, update_data) - + # Return updated command result = get_custom_command_by_id(command_id) command_dict = dict(result) @@ -525,28 +552,28 @@ async def patch_custom_command( # Convert datetime fields to ISO strings convert_datetime_to_iso(command_dict) - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] + command_dict["tags"] = [] - creator_created_at = command_dict.pop('creator_created_at') - if hasattr(creator_created_at, 'isoformat'): + creator_created_at = command_dict.pop("creator_created_at") + if hasattr(creator_created_at, "isoformat"): creator_created_at = creator_created_at.isoformat() - command_dict['creator'] = { - 'id': command_dict.pop('creator_db_id'), - 'discord_id': command_dict.pop('creator_discord_id'), - 'username': command_dict.pop('creator_username'), - 'display_name': command_dict.pop('creator_display_name'), - 'created_at': creator_created_at, - 'total_commands': command_dict.pop('total_commands'), - 'active_commands': command_dict.pop('active_commands') + command_dict["creator"] = { + "id": command_dict.pop("creator_db_id"), + "discord_id": command_dict.pop("creator_discord_id"), + "username": command_dict.pop("creator_username"), + "display_name": command_dict.pop("creator_display_name"), + "created_at": creator_created_at, + "total_commands": command_dict.pop("total_commands"), + "active_commands": command_dict.pop("active_commands"), } - + return command_dict - + except HTTPException: raise except Exception as e: @@ -556,33 +583,34 @@ async def patch_custom_command( db.close() -@router.delete('/{command_id}', include_in_schema=PRIVATE_IN_SCHEMA) +@router.delete("/{command_id}", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def delete_custom_command_endpoint( - command_id: int, - token: str = Depends(oauth2_scheme) + command_id: int, token: str = Depends(oauth2_scheme) ): """Delete a custom command""" if not valid_token(token): - logger.warning(f'delete_custom_command - Bad Token: {token}') - raise HTTPException(status_code=401, detail='Unauthorized') - + logger.warning(f"delete_custom_command - Bad Token: {token}") + raise HTTPException(status_code=401, detail="Unauthorized") + try: # Check if command exists existing = get_custom_command_by_id(command_id) if not existing: - raise HTTPException(status_code=404, detail=f"Custom command {command_id} not found") - - creator_id = existing['creator_db_id'] - + raise HTTPException( + status_code=404, detail=f"Custom command {command_id} not found" + ) + + creator_id = existing["creator_db_id"] + # Delete the command delete_custom_command(command_id) - + # Update creator stats update_creator_stats(creator_id) - + return {"message": f"Custom command {command_id} deleted successfully"} - + except HTTPException: raise except Exception as e: @@ -593,33 +621,35 @@ async def delete_custom_command_endpoint( # Creator endpoints -@router.get('/creators') +@router.get("/creators") @handle_db_errors async def get_creators( discord_id: Optional[int] = None, page: int = Query(1, ge=1), - page_size: int = Query(25, ge=1, le=100) + page_size: int = Query(25, ge=1, le=100), ): """Get custom command creators with optional filtering""" try: # Build WHERE clause where_conditions = [] params = [] - + if discord_id is not None: where_conditions.append("discord_id = %s") params.append(discord_id) - - where_clause = "WHERE " + " AND ".join(where_conditions) if where_conditions else "" - + + where_clause = ( + "WHERE " + " AND ".join(where_conditions) if where_conditions else "" + ) + # Get total count count_sql = f"SELECT COUNT(*) FROM custom_command_creators {where_clause}" total_count = db.execute_sql(count_sql, params).fetchone()[0] - + # Calculate pagination offset = (page - 1) * page_size total_pages = (total_count + page_size - 1) // page_size - + # Get creators sql = f""" SELECT * FROM custom_command_creators @@ -631,7 +661,7 @@ async def get_creators( params.extend([page_size, offset]) cursor = db.execute_sql(sql, params) results = cursor.fetchall() - + # Convert to dict format creators = [] if results: @@ -639,19 +669,21 @@ async def get_creators( for row in results: creator_dict = dict(zip(columns, row)) # Convert datetime to ISO string - if creator_dict.get('created_at') and hasattr(creator_dict['created_at'], 'isoformat'): - creator_dict['created_at'] = creator_dict['created_at'].isoformat() + if creator_dict.get("created_at") and hasattr( + creator_dict["created_at"], "isoformat" + ): + creator_dict["created_at"] = creator_dict["created_at"].isoformat() creators.append(creator_dict) - + return { - 'creators': creators, - 'total_count': total_count, - 'page': page, - 'page_size': page_size, - 'total_pages': total_pages, - 'has_more': page < total_pages + "creators": creators, + "total_count": total_count, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "has_more": page < total_pages, } - + except Exception as e: logger.error(f"Error getting creators: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -659,36 +691,42 @@ async def get_creators( db.close() -@router.post('/creators', include_in_schema=PRIVATE_IN_SCHEMA) +@router.post("/creators", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def create_creator_endpoint( - creator: CustomCommandCreatorModel, - token: str = Depends(oauth2_scheme) + creator: CustomCommandCreatorModel, token: str = Depends(oauth2_scheme) ): """Create a new command creator""" if not valid_token(token): - logger.warning(f'create_creator - Bad Token: {token}') - raise HTTPException(status_code=401, detail='Unauthorized') - + logger.warning(f"create_creator - Bad Token: {token}") + raise HTTPException(status_code=401, detail="Unauthorized") + try: # Check if creator already exists existing = get_creator_by_discord_id(creator.discord_id) if existing: - raise HTTPException(status_code=409, detail=f"Creator with Discord ID {creator.discord_id} already exists") - + raise HTTPException( + status_code=409, + detail=f"Creator with Discord ID {creator.discord_id} already exists", + ) + # Create the creator - creator_data = creator.model_dump(exclude={'id'}) + creator_data = creator.model_dump(exclude={"id"}) creator_id = create_creator(creator_data) - + # Return the created creator - cursor = db.execute_sql("SELECT * FROM custom_command_creators WHERE id = %s", (creator_id,)) + cursor = db.execute_sql( + "SELECT * FROM custom_command_creators WHERE id = %s", (creator_id,) + ) result = cursor.fetchone() if result: columns = [desc[0] for desc in cursor.description] return dict(zip(columns, result)) else: - raise HTTPException(status_code=500, detail="Failed to retrieve created creator") - + raise HTTPException( + status_code=500, detail="Failed to retrieve created creator" + ) + except HTTPException: raise except Exception as e: @@ -698,20 +736,28 @@ async def create_creator_endpoint( db.close() -@router.get('/stats') +@router.get("/stats") @handle_db_errors async def get_custom_command_stats(): """Get comprehensive statistics about custom commands""" try: # Get basic counts - total_commands = db.execute_sql("SELECT COUNT(*) FROM custom_commands").fetchone()[0] - active_commands = db.execute_sql("SELECT COUNT(*) FROM custom_commands WHERE is_active = TRUE").fetchone()[0] - total_creators = db.execute_sql("SELECT COUNT(*) FROM custom_command_creators").fetchone()[0] + total_commands = db.execute_sql( + "SELECT COUNT(*) FROM custom_commands" + ).fetchone()[0] + active_commands = db.execute_sql( + "SELECT COUNT(*) FROM custom_commands WHERE is_active = TRUE" + ).fetchone()[0] + total_creators = db.execute_sql( + "SELECT COUNT(*) FROM custom_command_creators" + ).fetchone()[0] # Get total uses - total_uses_result = db.execute_sql("SELECT SUM(use_count) FROM custom_commands WHERE is_active = TRUE").fetchone() + total_uses_result = db.execute_sql( + "SELECT SUM(use_count) FROM custom_commands WHERE is_active = TRUE" + ).fetchone() total_uses = total_uses_result[0] if total_uses_result[0] else 0 - + # Get most popular command cursor = db.execute_sql(""" SELECT cc.*, creator.discord_id as creator_discord_id, @@ -723,65 +769,80 @@ async def get_custom_command_stats(): ORDER BY cc.use_count DESC LIMIT 1 """) - + most_popular_result = cursor.fetchone() most_popular_command = None if most_popular_result: columns = [desc[0] for desc in cursor.description] command_dict = dict(zip(columns, most_popular_result)) # Convert datetime fields to ISO strings - for field in ['created_at', 'updated_at', 'last_used']: - if command_dict.get(field) and hasattr(command_dict[field], 'isoformat'): + for field in ["created_at", "updated_at", "last_used"]: + if command_dict.get(field) and hasattr( + command_dict[field], "isoformat" + ): command_dict[field] = command_dict[field].isoformat() - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] - command_dict['creator'] = { - 'discord_id': command_dict.pop('creator_discord_id'), - 'username': command_dict.pop('creator_username'), - 'display_name': command_dict.pop('creator_display_name') + command_dict["tags"] = [] + command_dict["creator"] = { + "discord_id": command_dict.pop("creator_discord_id"), + "username": command_dict.pop("creator_username"), + "display_name": command_dict.pop("creator_display_name"), } most_popular_command = command_dict - + # Get most active creator cursor2 = db.execute_sql(""" SELECT * FROM custom_command_creators ORDER BY active_commands DESC LIMIT 1 """) - + most_active_creator_result = cursor2.fetchone() most_active_creator = None if most_active_creator_result: columns2 = [desc[0] for desc in cursor2.description] most_active_creator = dict(zip(columns2, most_active_creator_result)) # Convert datetime to ISO string - if most_active_creator.get('created_at') and hasattr(most_active_creator['created_at'], 'isoformat'): - most_active_creator['created_at'] = most_active_creator['created_at'].isoformat() - + if most_active_creator.get("created_at") and hasattr( + most_active_creator["created_at"], "isoformat" + ): + most_active_creator["created_at"] = most_active_creator[ + "created_at" + ].isoformat() + # Get recent commands count (last 7 days) week_ago = (datetime.now() - timedelta(days=7)).isoformat() - recent_count = db.execute_sql(""" + recent_count = db.execute_sql( + """ SELECT COUNT(*) FROM custom_commands WHERE created_at >= %s AND is_active = TRUE - """, (week_ago,)).fetchone()[0] + """, + (week_ago,), + ).fetchone()[0] # Get cleanup stats sixty_days_ago = (datetime.now() - timedelta(days=60)).isoformat() ninety_days_ago = (datetime.now() - timedelta(days=90)).isoformat() - warning_count = db.execute_sql(""" + warning_count = db.execute_sql( + """ SELECT COUNT(*) FROM custom_commands WHERE last_used < %s AND warning_sent = FALSE AND is_active = TRUE - """, (sixty_days_ago,)).fetchone()[0] + """, + (sixty_days_ago,), + ).fetchone()[0] - deletion_count = db.execute_sql(""" + deletion_count = db.execute_sql( + """ SELECT COUNT(*) FROM custom_commands WHERE last_used < %s AND is_active = TRUE - """, (ninety_days_ago,)).fetchone()[0] - + """, + (ninety_days_ago,), + ).fetchone()[0] + return CustomCommandStatsResponse( total_commands=total_commands, active_commands=active_commands, @@ -791,9 +852,9 @@ async def get_custom_command_stats(): most_active_creator=most_active_creator, recent_commands_count=recent_count, commands_needing_warning=warning_count, - commands_eligible_for_deletion=deletion_count + commands_eligible_for_deletion=deletion_count, ) - + except Exception as e: logger.error(f"Error getting custom command stats: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -802,7 +863,7 @@ async def get_custom_command_stats(): # Special endpoints for Discord bot integration -@router.get('/by_name/{command_name}') +@router.get("/by_name/{command_name}") @handle_db_errors async def get_custom_command_by_name_endpoint(command_name: str): """Get a custom command by name (for Discord bot execution)""" @@ -810,7 +871,9 @@ async def get_custom_command_by_name_endpoint(command_name: str): result = get_custom_command_by_name(command_name) if not result: - raise HTTPException(status_code=404, detail=f"Custom command '{command_name}' not found") + raise HTTPException( + status_code=404, detail=f"Custom command '{command_name}' not found" + ) command_dict = dict(result) @@ -818,41 +881,45 @@ async def get_custom_command_by_name_endpoint(command_name: str): convert_datetime_to_iso(command_dict) # Parse tags - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] + command_dict["tags"] = [] # Add creator info - get full creator record - creator_id = command_dict['creator_id'] - creator_cursor = db.execute_sql("SELECT * FROM custom_command_creators WHERE id = %s", (creator_id,)) + creator_id = command_dict["creator_id"] + creator_cursor = db.execute_sql( + "SELECT * FROM custom_command_creators WHERE id = %s", (creator_id,) + ) creator_result = creator_cursor.fetchone() if creator_result: creator_columns = [desc[0] for desc in creator_cursor.description] creator_dict = dict(zip(creator_columns, creator_result)) # Convert creator datetime to ISO string - convert_datetime_to_iso(creator_dict, fields=['created_at']) - command_dict['creator'] = creator_dict + convert_datetime_to_iso(creator_dict, fields=["created_at"]) + command_dict["creator"] = creator_dict else: # Fallback to basic info if full creator not found - command_dict['creator'] = { - 'id': creator_id, - 'discord_id': command_dict.pop('creator_discord_id'), - 'username': command_dict.pop('creator_username'), - 'display_name': command_dict.pop('creator_display_name'), - 'created_at': command_dict['created_at'], # Use command creation as fallback - 'total_commands': 0, - 'active_commands': 0 + command_dict["creator"] = { + "id": creator_id, + "discord_id": command_dict.pop("creator_discord_id"), + "username": command_dict.pop("creator_username"), + "display_name": command_dict.pop("creator_display_name"), + "created_at": command_dict[ + "created_at" + ], # Use command creation as fallback + "total_commands": 0, + "active_commands": 0, } - + # Remove the duplicate fields - command_dict.pop('creator_discord_id', None) - command_dict.pop('creator_username', None) - command_dict.pop('creator_display_name', None) - + command_dict.pop("creator_discord_id", None) + command_dict.pop("creator_username", None) + command_dict.pop("creator_display_name", None) + return command_dict - + except HTTPException: raise except Exception as e: @@ -862,35 +929,36 @@ async def get_custom_command_by_name_endpoint(command_name: str): db.close() -@router.patch('/by_name/{command_name}/execute', include_in_schema=PRIVATE_IN_SCHEMA) +@router.patch("/by_name/{command_name}/execute", include_in_schema=PRIVATE_IN_SCHEMA) @handle_db_errors async def execute_custom_command( - command_name: str, - token: str = Depends(oauth2_scheme) + command_name: str, token: str = Depends(oauth2_scheme) ): """Execute a custom command and update usage statistics""" if not valid_token(token): - logger.warning(f'execute_custom_command - Bad Token: {token}') - raise HTTPException(status_code=401, detail='Unauthorized') - + logger.warning(f"execute_custom_command - Bad Token: {token}") + raise HTTPException(status_code=401, detail="Unauthorized") + try: result = get_custom_command_by_name(command_name) - + if not result: - raise HTTPException(status_code=404, detail=f"Custom command '{command_name}' not found") - + raise HTTPException( + status_code=404, detail=f"Custom command '{command_name}' not found" + ) + command_dict = dict(result) - command_id = command_dict['id'] - + command_id = command_dict["id"] + # Update usage statistics update_data = { - 'last_used': datetime.now().isoformat(), - 'use_count': command_dict['use_count'] + 1, - 'warning_sent': False # Reset warning on use + "last_used": datetime.now().isoformat(), + "use_count": command_dict["use_count"] + 1, + "warning_sent": False, # Reset warning on use } - + update_custom_command(command_id, update_data) - + # Return updated command - get_custom_command_by_id already has all creator info updated_result = get_custom_command_by_id(command_id) updated_dict = dict(updated_result) @@ -898,29 +966,29 @@ async def execute_custom_command( # Convert datetime fields to ISO strings convert_datetime_to_iso(updated_dict) - if updated_dict.get('tags'): + if updated_dict.get("tags"): try: - updated_dict['tags'] = json.loads(updated_dict['tags']) + updated_dict["tags"] = json.loads(updated_dict["tags"]) except: - updated_dict['tags'] = [] + updated_dict["tags"] = [] # Build creator object from the fields returned by get_custom_command_by_id - creator_created_at = updated_dict.pop('creator_created_at') - if hasattr(creator_created_at, 'isoformat'): + creator_created_at = updated_dict.pop("creator_created_at") + if hasattr(creator_created_at, "isoformat"): creator_created_at = creator_created_at.isoformat() - updated_dict['creator'] = { - 'id': updated_dict.pop('creator_db_id'), - 'discord_id': updated_dict.pop('creator_discord_id'), - 'username': updated_dict.pop('creator_username'), - 'display_name': updated_dict.pop('creator_display_name'), - 'created_at': creator_created_at, - 'total_commands': updated_dict.pop('total_commands'), - 'active_commands': updated_dict.pop('active_commands') + updated_dict["creator"] = { + "id": updated_dict.pop("creator_db_id"), + "discord_id": updated_dict.pop("creator_discord_id"), + "username": updated_dict.pop("creator_username"), + "display_name": updated_dict.pop("creator_display_name"), + "created_at": creator_created_at, + "total_commands": updated_dict.pop("total_commands"), + "active_commands": updated_dict.pop("active_commands"), } return updated_dict - + except HTTPException: raise except Exception as e: @@ -930,31 +998,36 @@ async def execute_custom_command( db.close() -@router.get('/autocomplete') +@router.get("/autocomplete") @handle_db_errors async def get_command_names_for_autocomplete( - partial_name: str = "", - limit: int = Query(25, ge=1, le=100) + partial_name: str = "", limit: int = Query(25, ge=1, le=100) ): """Get command names for Discord autocomplete""" try: if partial_name: - results = db.execute_sql(""" + results = db.execute_sql( + """ SELECT name FROM custom_commands WHERE is_active = TRUE AND name ILIKE %s ORDER BY name LIMIT %s - """, (f"%{partial_name}%", limit)).fetchall() + """, + (f"%{partial_name}%", limit), + ).fetchall() else: - results = db.execute_sql(""" + results = db.execute_sql( + """ SELECT name FROM custom_commands WHERE is_active = TRUE ORDER BY name LIMIT %s - """, (limit,)).fetchall() - + """, + (limit,), + ).fetchall() + return [row[0] for row in results] - + except Exception as e: logger.error(f"Error getting command names for autocomplete: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -962,7 +1035,7 @@ async def get_command_names_for_autocomplete( db.close() -@router.get('/{command_id}') +@router.get("/{command_id}") @handle_db_errors async def get_custom_command(command_id: int): """Get a single custom command by ID""" @@ -970,7 +1043,9 @@ async def get_custom_command(command_id: int): result = get_custom_command_by_id(command_id) if not result: - raise HTTPException(status_code=404, detail=f"Custom command {command_id} not found") + raise HTTPException( + status_code=404, detail=f"Custom command {command_id} not found" + ) command_dict = dict(result) @@ -978,33 +1053,33 @@ async def get_custom_command(command_id: int): convert_datetime_to_iso(command_dict) # Parse tags - if command_dict.get('tags'): + if command_dict.get("tags"): try: - command_dict['tags'] = json.loads(command_dict['tags']) + command_dict["tags"] = json.loads(command_dict["tags"]) except: - command_dict['tags'] = [] + command_dict["tags"] = [] - creator_created_at = command_dict.pop('creator_created_at') - if hasattr(creator_created_at, 'isoformat'): + creator_created_at = command_dict.pop("creator_created_at") + if hasattr(creator_created_at, "isoformat"): creator_created_at = creator_created_at.isoformat() # Add creator info - command_dict['creator'] = { - 'id': command_dict.pop('creator_db_id'), - 'discord_id': command_dict.pop('creator_discord_id'), - 'username': command_dict.pop('creator_username'), - 'display_name': command_dict.pop('creator_display_name'), - 'created_at': creator_created_at, - 'total_commands': command_dict.pop('total_commands'), - 'active_commands': command_dict.pop('active_commands') + command_dict["creator"] = { + "id": command_dict.pop("creator_db_id"), + "discord_id": command_dict.pop("creator_discord_id"), + "username": command_dict.pop("creator_username"), + "display_name": command_dict.pop("creator_display_name"), + "created_at": creator_created_at, + "total_commands": command_dict.pop("total_commands"), + "active_commands": command_dict.pop("active_commands"), } - + return command_dict - + except HTTPException: raise except Exception as e: logger.error(f"Error getting custom command {command_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) finally: - db.close() \ No newline at end of file + db.close()