Files
AEmotionStudio-ComfyUI-Disc…/bot/database/repository.py
T
AEmotionStudio ad26e34c4f fix(pr): Address PR feedback Round 6 (#5)
- Fix duplicate deliveries for multi-output workflows

- Append output images instead of overwrite

- Debounce job delivery calls
2026-01-14 17:30:10 -08:00

657 lines
22 KiB
Python

"""
Data access layer for bot database operations.
Provides async CRUD operations for all database models.
"""
import json
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update, delete, func
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import selectinload
from .models import (
Base,
User,
Server,
ServerRole,
Template,
Job,
Workflow,
JobStatus,
PermissionLevel,
)
class Repository:
"""Async repository for database operations."""
def __init__(self, database_url: str):
"""
Initialize the repository.
Args:
database_url: SQLAlchemy async database URL
"""
self.engine = create_async_engine(database_url, echo=False)
self.async_session = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def init_db(self) -> None:
"""Create all database tables."""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close(self) -> None:
"""Close the database connection."""
await self.engine.dispose()
# ==================== User Operations ====================
async def get_or_create_user(
self,
discord_id: str,
username: str,
) -> User:
"""Get existing user or create new one."""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.discord_id == discord_id)
)
user = result.scalar_one_or_none()
if user is None:
user = User(discord_id=discord_id, username=username)
session.add(user)
await session.commit()
await session.refresh(user)
elif user.username != username and username != "Unknown":
user.username = username
await session.commit()
return user
async def get_user(self, discord_id: str) -> Optional[User]:
"""Get user by Discord ID."""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.discord_id == discord_id)
)
return result.scalar_one_or_none()
async def update_user_delivery(
self,
discord_id: str,
delivery_type: str,
) -> Optional[User]:
"""Update user's default delivery preference."""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.discord_id == discord_id)
)
user = result.scalar_one_or_none()
if user:
user.default_delivery = delivery_type
await session.commit()
return user
# ==================== Server Operations ====================
async def get_or_create_server(
self,
discord_id: str,
name: str,
) -> Server:
"""Get existing server or create new one."""
async with self.async_session() as session:
result = await session.execute(
select(Server).where(Server.discord_id == discord_id)
)
server = result.scalar_one_or_none()
if server is None:
server = Server(discord_id=discord_id, name=name)
session.add(server)
await session.commit()
await session.refresh(server)
elif server.name != name:
server.name = name
await session.commit()
return server
async def get_server(self, discord_id: str) -> Optional[Server]:
"""Get server by Discord ID."""
async with self.async_session() as session:
result = await session.execute(
select(Server)
.options(selectinload(Server.roles))
.where(Server.discord_id == discord_id)
)
return result.scalar_one_or_none()
async def update_server_channel(
self,
discord_id: str,
channel_id: str,
) -> Optional[Server]:
"""Update server's default output channel."""
async with self.async_session() as session:
result = await session.execute(
select(Server).where(Server.discord_id == discord_id)
)
server = result.scalar_one_or_none()
if server:
server.default_channel_id = channel_id
await session.commit()
return server
async def update_server_queue_limit(
self,
discord_id: str,
limit: int,
) -> Optional[Server]:
"""Update server's per-user queue limit."""
async with self.async_session() as session:
result = await session.execute(
select(Server).where(Server.discord_id == discord_id)
)
server = result.scalar_one_or_none()
if server:
server.max_queue_per_user = limit
await session.commit()
return server
# ==================== Role Operations ====================
async def set_server_role(
self,
server_discord_id: str,
role_discord_id: str,
permission_level: str,
) -> ServerRole:
"""Set or update a role's permission level for a server."""
async with self.async_session() as session:
# Get server
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if not server:
raise ValueError(f"Server {server_discord_id} not found")
# Check for existing role mapping
role_result = await session.execute(
select(ServerRole).where(
ServerRole.server_id == server.id,
ServerRole.role_discord_id == role_discord_id,
)
)
role = role_result.scalar_one_or_none()
if role:
role.permission_level = permission_level
else:
role = ServerRole(
server_id=server.id,
role_discord_id=role_discord_id,
permission_level=permission_level,
)
session.add(role)
await session.commit()
await session.refresh(role)
return role
async def get_server_roles(self, server_discord_id: str) -> list[ServerRole]:
"""Get all role mappings for a server."""
async with self.async_session() as session:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if not server:
return []
result = await session.execute(
select(ServerRole).where(ServerRole.server_id == server.id)
)
return list(result.scalars().all())
async def delete_server_role(
self,
server_discord_id: str,
role_discord_id: str,
) -> bool:
"""Remove a role mapping."""
async with self.async_session() as session:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if not server:
return False
result = await session.execute(
delete(ServerRole).where(
ServerRole.server_id == server.id,
ServerRole.role_discord_id == role_discord_id,
)
)
await session.commit()
return result.rowcount > 0
# ==================== Template Operations ====================
async def create_template(
self,
user_discord_id: str,
name: str,
positive_prompt: str,
negative_prompt: str = "",
parameters: Optional[dict] = None,
server_discord_id: Optional[str] = None,
) -> Template:
"""Create a new prompt template."""
async with self.async_session() as session:
# Get user
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise ValueError(f"User {user_discord_id} not found")
# Get server if provided
server_id = None
if server_discord_id:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if server:
server_id = server.id
template = Template(
user_id=user.id,
server_id=server_id,
name=name,
positive_prompt=positive_prompt,
negative_prompt=negative_prompt,
parameters=json.dumps(parameters) if parameters else None,
)
session.add(template)
await session.commit()
await session.refresh(template)
return template
async def get_template(
self,
user_discord_id: str,
name: str,
server_discord_id: Optional[str] = None,
) -> Optional[Template]:
"""Get a template by name."""
async with self.async_session() as session:
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
return None
# Build query
query = select(Template).where(
Template.user_id == user.id,
Template.name == name,
)
if server_discord_id:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if server:
query = query.where(Template.server_id == server.id)
else:
query = query.where(Template.server_id.is_(None))
result = await session.execute(query)
return result.scalar_one_or_none()
async def list_templates(
self,
user_discord_id: str,
server_discord_id: Optional[str] = None,
include_shared: bool = True,
) -> list[Template]:
"""List templates for a user."""
async with self.async_session() as session:
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
return []
# Private templates
query = select(Template).where(
Template.user_id == user.id,
Template.server_id.is_(None),
)
result = await session.execute(query)
templates = list(result.scalars().all())
# Shared templates in server
if include_shared and server_discord_id:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if server:
shared_query = select(Template).where(
Template.server_id == server.id
)
shared_result = await session.execute(shared_query)
templates.extend(shared_result.scalars().all())
return templates
async def delete_template(
self,
user_discord_id: str,
name: str,
server_discord_id: Optional[str] = None,
) -> bool:
"""Delete a template."""
async with self.async_session() as session:
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
return False
query = delete(Template).where(
Template.user_id == user.id,
Template.name == name,
)
if server_discord_id:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if server:
query = query.where(Template.server_id == server.id)
else:
query = query.where(Template.server_id.is_(None))
result = await session.execute(query)
await session.commit()
return result.rowcount > 0
# ==================== Job Operations ====================
async def create_job(
self,
prompt_id: str,
user_discord_id: str,
positive_prompt: str,
negative_prompt: str = "",
parameters: Optional[dict] = None,
workflow_json: Optional[str] = None,
delivery_type: str = "channel",
server_discord_id: Optional[str] = None,
channel_id: Optional[str] = None,
) -> Job:
"""Create a new generation job."""
async with self.async_session() as session:
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise ValueError(f"User {user_discord_id} not found")
server_id = None
if server_discord_id:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if server:
server_id = server.id
job = Job(
prompt_id=prompt_id,
user_id=user.id,
server_id=server_id,
channel_id=channel_id,
positive_prompt=positive_prompt,
negative_prompt=negative_prompt,
parameters=json.dumps(parameters) if parameters else None,
workflow_json=workflow_json,
delivery_type=delivery_type,
)
session.add(job)
await session.commit()
await session.refresh(job)
return job
async def get_job(self, prompt_id: str) -> Optional[Job]:
"""Get a job by prompt ID."""
async with self.async_session() as session:
result = await session.execute(
select(Job)
.options(selectinload(Job.user))
.where(Job.prompt_id == prompt_id)
)
return result.scalar_one_or_none()
async def get_job_by_id(self, job_id: int) -> Optional[Job]:
"""Get a job by internal ID."""
async with self.async_session() as session:
result = await session.execute(
select(Job)
.options(selectinload(Job.user))
.where(Job.id == job_id)
)
return result.scalar_one_or_none()
async def update_job_status(
self,
prompt_id: str,
status: str,
error_message: Optional[str] = None,
output_images: Optional[list[str]] = None,
) -> Optional[Job]:
"""Update job status."""
async with self.async_session() as session:
result = await session.execute(
select(Job)
.options(selectinload(Job.user))
.where(Job.prompt_id == prompt_id)
)
job = result.scalar_one_or_none()
if not job:
return None
job.status = status
if status == JobStatus.RUNNING.value:
job.started_at = datetime.utcnow()
elif status in (JobStatus.COMPLETED.value, JobStatus.FAILED.value, JobStatus.CANCELLED.value):
job.completed_at = datetime.utcnow()
if error_message is not None:
job.error_message = error_message
if output_images is not None:
current_images = []
if job.output_images:
try:
current_images = json.loads(job.output_images)
except json.JSONDecodeError:
current_images = []
# Append new images
current_images.extend(output_images)
job.output_images = json.dumps(current_images)
await session.commit()
return job
async def update_job_progress(
self,
prompt_id: str,
progress: int,
progress_max: int,
) -> Optional[Job]:
"""Update job progress."""
async with self.async_session() as session:
result = await session.execute(
select(Job).where(Job.prompt_id == prompt_id)
)
job = result.scalar_one_or_none()
if job:
job.progress = progress
job.progress_max = progress_max
await session.commit()
return job
async def update_job_message(
self,
prompt_id: str,
message_id: str,
) -> Optional[Job]:
"""Update the Discord message ID for a job."""
async with self.async_session() as session:
result = await session.execute(
select(Job).where(Job.prompt_id == prompt_id)
)
job = result.scalar_one_or_none()
if job:
job.message_id = message_id
await session.commit()
return job
async def list_user_jobs(
self,
user_discord_id: str,
limit: int = 10,
status: Optional[str] = None,
) -> list[Job]:
"""List jobs for a user."""
async with self.async_session() as session:
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
return []
query = (
select(Job)
.where(Job.user_id == user.id)
.order_by(Job.created_at.desc())
.limit(limit)
)
if status:
query = query.where(Job.status == status)
result = await session.execute(query)
return list(result.scalars().all())
async def count_user_pending_jobs(
self,
user_discord_id: str,
server_discord_id: Optional[str] = None,
) -> int:
"""Count pending/running jobs for a user."""
async with self.async_session() as session:
user_result = await session.execute(
select(User).where(User.discord_id == user_discord_id)
)
user = user_result.scalar_one_or_none()
if not user:
return 0
query = (
select(func.count(Job.id))
.where(Job.user_id == user.id)
.where(Job.status.in_([JobStatus.PENDING.value, JobStatus.RUNNING.value]))
)
if server_discord_id:
server_result = await session.execute(
select(Server).where(Server.discord_id == server_discord_id)
)
server = server_result.scalar_one_or_none()
if server:
query = query.where(Job.server_id == server.id)
result = await session.execute(query)
return result.scalar() or 0
async def get_pending_jobs(self) -> list[Job]:
"""Get all pending jobs ordered by creation time."""
async with self.async_session() as session:
result = await session.execute(
select(Job)
.options(selectinload(Job.user))
.where(Job.status.in_([JobStatus.PENDING.value, JobStatus.RUNNING.value]))
.order_by(Job.created_at)
)
return list(result.scalars().all())
# ==================== Workflow Operations ====================
async def save_workflow(
self,
name: str,
workflow_json: str,
description: Optional[str] = None,
is_default: bool = False,
) -> Workflow:
"""Save a workflow configuration."""
async with self.async_session() as session:
# If setting as default, unset other defaults
if is_default:
await session.execute(
update(Workflow).where(Workflow.is_default == True).values(is_default=False)
)
workflow = Workflow(
name=name,
workflow_json=workflow_json,
description=description,
is_default=is_default,
)
session.add(workflow)
await session.commit()
await session.refresh(workflow)
return workflow
async def get_default_workflow(self) -> Optional[Workflow]:
"""Get the default workflow."""
async with self.async_session() as session:
result = await session.execute(
select(Workflow).where(Workflow.is_default == True)
)
return result.scalar_one_or_none()
async def get_workflow(self, name: str) -> Optional[Workflow]:
"""Get a workflow by name."""
async with self.async_session() as session:
result = await session.execute(
select(Workflow).where(Workflow.name == name)
)
return result.scalar_one_or_none()