diff --git a/CLAUDE.md b/CLAUDE.md index 9ad18eb..efbc93e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,10 +57,10 @@ app/ ``` ### Database Configuration -- **Primary**: PostgreSQL via `PooledPostgresqlDatabase` (production) -- **Fallback**: SQLite with WAL mode (`storage/sba_master.db`) +- **Database**: PostgreSQL via `PooledPostgresqlDatabase` (production and development) - **ORM**: Peewee with model-based schema definition -- **Migrations**: Manual migrations via `playhouse.migrate` +- **Migrations**: SQL migration files in `migrations/` directory +- **Table Naming**: Peewee models must specify `Meta.table_name` to match PostgreSQL table names ### Authentication & Error Handling - **Authentication**: OAuth2 bearer token validation via `API_TOKEN` environment variable @@ -76,10 +76,13 @@ app/ ### Environment Variables **Required**: - `API_TOKEN` - Authentication token for API access -- `DATABASE_TYPE` - 'postgresql' or 'sqlite' (defaults to sqlite) -**PostgreSQL Configuration**: -- `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_PORT` +**PostgreSQL Configuration** (required): +- `POSTGRES_HOST` - PostgreSQL server hostname (default: 10.10.0.42) +- `POSTGRES_DB` - Database name (default: sba_master) +- `POSTGRES_USER` - Database user (default: sba_admin) +- `POSTGRES_PASSWORD` - Database password +- `POSTGRES_PORT` - Database port (default: 5432) **Optional**: - `LOG_LEVEL` - INFO or WARNING (defaults to WARNING) @@ -92,6 +95,8 @@ app/ - **Draft System**: Draft picks, draft lists, keeper selections - **Game Data**: Schedules, results, Strat-o-Matic play-by-play data - **League Management**: Standings, transactions, injuries, decisions +- **Custom Commands**: User-created Discord bot commands with creator tracking and usage statistics +- **Help Commands**: Static help text for Discord bot commands ### Testing & Quality - **No formal test framework currently configured** @@ -103,6 +108,8 @@ app/ - All active development occurs in the `/app` directory - Root directory files (`main.py`, `db_engine.py`, etc.) are legacy and not in use -- The system supports both SQLite (development) and PostgreSQL (production) backends -- Database migrations must be manually coded using Peewee's migration system -- Authentication is required for all endpoints except documentation \ No newline at end of file +- **PostgreSQL migration completed**: System now uses PostgreSQL exclusively (no SQLite fallback) +- Database migrations are SQL files in `migrations/` directory, applied manually via psql +- Authentication is required for all endpoints except documentation and public read endpoints +- **Peewee Models**: Always specify `Meta.table_name` to match PostgreSQL naming conventions +- **Custom Commands**: Fully functional with creator tracking, usage statistics, and Discord bot integration \ No newline at end of file diff --git a/app/db_engine.py b/app/db_engine.py index 9ce2670..e7aac43 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -2235,6 +2235,9 @@ class CustomCommandCreator(BaseModel): total_commands = IntegerField(default=0) active_commands = IntegerField(default=0) + class Meta: + table_name = 'custom_command_creators' + class CustomCommand(BaseModel): """Model for custom commands created by users.""" @@ -2254,7 +2257,10 @@ class CustomCommand(BaseModel): # Metadata is_active = BooleanField(default=True) tags = TextField(null=True) # JSON string for tags list - + + class Meta: + table_name = 'custom_commands' + @staticmethod def get_by_name(name: str): """Get a custom command by name (case-insensitive).""" diff --git a/app/routers_v3/custom_commands.py b/app/routers_v3/custom_commands.py index 21a08d5..39359b7 100644 --- a/app/routers_v3/custom_commands.py +++ b/app/routers_v3/custom_commands.py @@ -92,6 +92,99 @@ def update_creator_stats(creator_id: int): creator.save() +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() + + 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 + 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() + + 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 + return result + return None + + +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']) + + # Set created_at if not provided + if 'created_at' not in command_data: + command_data['created_at'] = datetime.now() + + command = CustomCommand.create(**command_data) + return command.id + + +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']) + + query = CustomCommand.update(**update_data).where(CustomCommand.id == command_id) + query.execute() + + +def delete_custom_command(command_id: int): + """Delete a custom command""" + query = CustomCommand.delete().where(CustomCommand.id == command_id) + query.execute() + + +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)) + if creator: + return model_to_dict(creator) + return None + + +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() + + creator = CustomCommandCreator.create(**creator_data) + return creator.id + + # API Endpoints @router.get('') @@ -113,7 +206,7 @@ async def get_custom_commands( params = [] if name is not None: - where_conditions.append("LOWER(cc.name) LIKE LOWER(%s)" if DATABASE_TYPE.lower() == 'sqlite' else "cc.name ILIKE %s") + where_conditions.append("cc.name ILIKE %s") params.append(f"%{name}%") if creator_discord_id is not None: @@ -798,7 +891,7 @@ async def execute_custom_command( update_custom_command(command_id, update_data) - # Return updated command + # 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) @@ -811,33 +904,21 @@ async def execute_custom_command( except: updated_dict['tags'] = [] - # Add creator info - get full creator record - creator_id = updated_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']) - updated_dict['creator'] = creator_dict - else: - # Fallback to basic info if full creator not found - updated_dict['creator'] = { - 'id': creator_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': updated_dict['created_at'], # Use command creation as fallback - 'total_commands': 0, - 'active_commands': 0 - } - - # Remove the duplicate fields - updated_dict.pop('creator_discord_id', None) - updated_dict.pop('creator_username', None) - updated_dict.pop('creator_display_name', None) - + # 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 = 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') + } + return updated_dict except HTTPException: @@ -858,10 +939,9 @@ async def get_command_names_for_autocomplete( """Get command names for Discord autocomplete""" try: if partial_name: - like_clause = "LOWER(name) LIKE LOWER(%s)" if DATABASE_TYPE.lower() == 'sqlite' else "name ILIKE %s" - results = db.execute_sql(f""" + results = db.execute_sql(""" SELECT name FROM custom_commands - WHERE is_active = TRUE AND {like_clause} + WHERE is_active = TRUE AND name ILIKE %s ORDER BY name LIMIT %s """, (f"%{partial_name}%", limit)).fetchall()