CLAUDE: Complete PostgreSQL migration for custom commands

- Update CLAUDE.md to reflect PostgreSQL-only architecture
- Add table_name Meta class to CustomCommand models for PostgreSQL
- Remove SQLite-specific LIKE queries, use PostgreSQL ILIKE
- Refactor custom command creator info handling
- Add helper functions for database operations
- Fix creator data serialization in execute endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-23 16:07:18 -05:00
parent 4db6982bc5
commit e75c1fbc7d
3 changed files with 135 additions and 42 deletions

View File

@ -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
- **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

View File

@ -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)."""

View File

@ -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()