mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
Initial Commit 🚀 🚀
This commit is contained in:
commit
4f2a629340
444 changed files with 76863 additions and 0 deletions
29
.env.example
Normal file
29
.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Core application config
|
||||
ENVIRONMENT: "local"
|
||||
LOG_LEVEL: "INFO"
|
||||
|
||||
# Database configuration (using containerized postgres)
|
||||
DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# Redis configuration (using containerized redis)
|
||||
REDIS_URL: "redis://:redissecret@redis:6379"
|
||||
|
||||
# Storage configuration - using local MinIO
|
||||
ENABLE_AWS_S3: "false"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
MINIO_ACCESS_KEY: "minioadmin"
|
||||
MINIO_SECRET_KEY: "minioadmin"
|
||||
MINIO_BUCKET: "voice-audio"
|
||||
MINIO_SECURE: "false"
|
||||
|
||||
# Langfuse
|
||||
ENABLE_TRACING: "false"
|
||||
# LANGFUSE_SECRET_KEY: ""
|
||||
# LANGFUSE_PUBLIC_KEY: ""
|
||||
# LANGFUSE_HOST: "https://langfuse.dograh.com"
|
||||
|
||||
# Sentry
|
||||
ENABLE_SETRY: "false"
|
||||
SENTRY_DSN: ""
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
__pycache__
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
# logs and run directory on production
|
||||
/logs/
|
||||
/run/
|
||||
docs/
|
||||
infrastructure/
|
||||
nginx/
|
||||
scripts/
|
||||
15
api/.cursorignore
Normal file
15
api/.cursorignore
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# root
|
||||
.git
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.DS_Store
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
|
||||
# ui
|
||||
ui/.next
|
||||
ui/node_modules
|
||||
ui/dist
|
||||
ui/build
|
||||
ui/coverage
|
||||
ui/public
|
||||
94
api/.dockerignore
Normal file
94
api/.dockerignore
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.Python
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
*.log
|
||||
|
||||
# Virtual Environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
.ENV/
|
||||
virtualenv/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Environment files (we'll copy only what's needed)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
README*
|
||||
|
||||
# Tests
|
||||
tests/
|
||||
test/
|
||||
*_test.py
|
||||
test_*.py
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Notebooks
|
||||
*.ipynb
|
||||
.ipynb_checkpoints/
|
||||
notebooks/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
docker-compose*.yaml
|
||||
.dockerignore
|
||||
|
||||
# Other unnecessary files for production
|
||||
*.bak
|
||||
*.orig
|
||||
.editorconfig
|
||||
.pre-commit-config.yaml
|
||||
pyproject.toml
|
||||
setup.cfg
|
||||
Makefile
|
||||
scripts/
|
||||
migrations/alembic.ini
|
||||
84
api/.env.example
Normal file
84
api/.env.example
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Environment Configuration
|
||||
ENVIRONMENT="local"
|
||||
LOG_LEVEL="DEBUG"
|
||||
|
||||
# Backend API Configuration
|
||||
BACKEND_API_ENDPOINT="your-ngrok-url.ngrok-free.app"
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||
REDIS_URL="redis://:redissecret@localhost:6379"
|
||||
|
||||
# Superuser Configuration
|
||||
SUPERUSER_PASSWORD="your-secure-password"
|
||||
|
||||
# AI Service API Keys (commented out by default)
|
||||
# CARTESIA_API_KEY="sk_car_xxxxxxxxxxxxxxxxx"
|
||||
# DEEPGRAM_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# LiveKit Configuration (optional)
|
||||
# LIVEKIT_API_KEY="APIxxxxxxxxxx"
|
||||
# LIVEKIT_API_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# LIVEKIT_URL="wss://your-livekit-instance.livekit.cloud"
|
||||
|
||||
# AWS S3 Configuration (required for SaaS mode)
|
||||
AWS_ACCESS_KEY_ID="AKIAXXXXXXXXXXXXXXXXX"
|
||||
AWS_SECRET_ACCESS_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
S3_BUCKET="your-s3-bucket-name"
|
||||
S3_REGION="us-east-1"
|
||||
|
||||
# Stack Auth Configuration
|
||||
STACK_AUTH_API_URL="https://api.stack-auth.com"
|
||||
STACK_AUTH_PROJECT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
STACK_SECRET_SERVER_KEY="ssk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
STACK_PUBLISHABLE_CLIENT_KEY="pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# Twilio Configuration
|
||||
TWILIO_ACCOUNT_SID="SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
TWILIO_FROM_NUMBER="+1234567890"
|
||||
|
||||
# Tracing and Analytics
|
||||
ENABLE_TRACING=true
|
||||
LANGFUSE_SECRET_KEY="sk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
LANGFUSE_PUBLIC_KEY="pk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
LANGFUSE_HOST="https://langfuse.your-domain.com"
|
||||
|
||||
# AI Service API Keys
|
||||
GROQ_API_KEY="gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
NEUPHONIC_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
NANGO_API_KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
|
||||
# Default API Keys for new user signups
|
||||
OPENAI_API_KEY="sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
ELEVENLABS_API_KEY="sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
DEEPGRAM_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
GOOGLE_API_KEY="AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
|
||||
# Smart Turn Configuration
|
||||
ENABLE_SMART_TURN=true
|
||||
SMART_TURN_WS_SERVICE_ENDPOINT="wss://your-gpu-server.domain.com/ws"
|
||||
SMART_TURN_HTTP_SERVICE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx="
|
||||
|
||||
# SSL Key Logging (for debugging)
|
||||
# SSLKEYLOGFILE=/path/to/ssl-keys.log
|
||||
|
||||
# Turn Logging
|
||||
ENABLE_TURN_LOGGING=true
|
||||
|
||||
# OpenTelemetry Configuration
|
||||
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
|
||||
SIGNOZ_EXPORTER_ENDPOINT="ingest.us.signoz.cloud:443"
|
||||
SIGNOZ_EXPORTER_HEADERS="signoz-ingestion-key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
|
||||
# Storage Configuration
|
||||
# User mode: 'saas' (default) uses AWS S3; 'oss' uses local MinIO
|
||||
USER_MODE=saas
|
||||
|
||||
# OSS (MinIO) Configuration - only required when USER_MODE=oss
|
||||
# These values are optional and will use defaults if not provided
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=voice-audio
|
||||
MINIO_SECURE=false
|
||||
2
api/.gitignore
vendored
Normal file
2
api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
services/admin_utils
|
||||
services/gender/names
|
||||
24
api/Dockerfile
Normal file
24
api/Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
ffmpeg \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy and install requirements
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . ./api
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Expose the port FastAPI will run on
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the FastAPI app with uvicorn
|
||||
CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
121
api/alembic.ini
Normal file
121
api/alembic.ini
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
version_path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# This is only a fallback. The actual value is determined in
|
||||
# alembic/env.py get_url() method
|
||||
sqlalchemy.url = ""
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
111
api/alembic/env.py
Normal file
111
api/alembic/env.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import asyncio
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
import alembic_postgresql_enum # noqa: F401 - registers enum handling hooks
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from api.constants import DATABASE_URL
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Import your model's MetaData object for 'autogenerate' support.
|
||||
from api.db.models import Base # noqa: E402 ensure this points to your models.py
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url():
|
||||
"""Get database URL from environment variable or config file."""
|
||||
return DATABASE_URL or config.get_main_option("sqlalchemy.url")
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
include_object=include_object,
|
||||
render_item=render_item,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def include_object(object, name, type_, reflected, compare_to):
|
||||
"""
|
||||
Custom function to exclude redundant indexes on primary keys.
|
||||
"""
|
||||
# Unused parameters are required by Alembic's API
|
||||
_ = (name, reflected, compare_to)
|
||||
|
||||
if type_ == "index":
|
||||
# Skip indexes on primary key columns that aren't unique
|
||||
# Primary keys already have implicit unique indexes
|
||||
if hasattr(object, "columns") and len(object.columns) == 1:
|
||||
col = list(object.columns)[0]
|
||||
if col.primary_key and not object.unique:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def render_item(type_, obj, autogen_context):
|
||||
"""
|
||||
Custom render function to fix index generation.
|
||||
"""
|
||||
# Unused parameter is required by Alembic's API
|
||||
_ = autogen_context
|
||||
|
||||
if type_ == "index":
|
||||
# For indexes on columns marked as unique in the model,
|
||||
# ensure the index is also unique
|
||||
if hasattr(obj, "columns"):
|
||||
for col in obj.columns:
|
||||
if hasattr(col, "unique") and col.unique and not obj.unique:
|
||||
obj.unique = True
|
||||
break
|
||||
return False # Let Alembic handle the rendering
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_item=render_item,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online():
|
||||
"""Run migrations in 'online' mode."""
|
||||
connectable = create_async_engine(
|
||||
get_url(),
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
# Run migrations using the synchronous 'do_run_migrations' function
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
26
api/alembic/script.py.mako
Normal file
26
api/alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
91
api/alembic/versions/08bb6e7f1397_added_campaign_table.py
Normal file
91
api/alembic/versions/08bb6e7f1397_added_campaign_table.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""added campaign table
|
||||
|
||||
Revision ID: 08bb6e7f1397
|
||||
Revises: 0c1223cc266f
|
||||
Create Date: 2025-07-23 18:46:38.955381
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "08bb6e7f1397"
|
||||
down_revision: Union[str, None] = "0c1223cc266f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"campaigns",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("workflow_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_by", sa.Integer(), nullable=False),
|
||||
sa.Column("source_type", sa.String(), nullable=False),
|
||||
sa.Column("source_id", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"state",
|
||||
sa.Enum(
|
||||
"created",
|
||||
"running",
|
||||
"paused",
|
||||
"completed",
|
||||
"failed",
|
||||
name="campaign_state",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("total_rows", sa.Integer(), nullable=True),
|
||||
sa.Column("processed_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("failed_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"],
|
||||
["organizations.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["workflow_id"],
|
||||
["workflows.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_campaigns_id"), "campaigns", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_campaigns_name"), "campaigns", ["name"], unique=False)
|
||||
op.create_index(
|
||||
"ix_campaigns_org_id", "campaigns", ["organization_id"], unique=False
|
||||
)
|
||||
op.create_index("ix_campaigns_state", "campaigns", ["state"], unique=False)
|
||||
op.create_index(
|
||||
"ix_campaigns_workflow_id", "campaigns", ["workflow_id"], unique=False
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("campaign_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(None, "workflow_runs", "campaigns", ["campaign_id"], ["id"])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflow_runs", type_="foreignkey")
|
||||
op.drop_column("workflow_runs", "campaign_id")
|
||||
op.drop_index("ix_campaigns_workflow_id", table_name="campaigns")
|
||||
op.drop_index("ix_campaigns_state", table_name="campaigns")
|
||||
op.drop_index("ix_campaigns_org_id", table_name="campaigns")
|
||||
op.drop_index(op.f("ix_campaigns_name"), table_name="campaigns")
|
||||
op.drop_index(op.f("ix_campaigns_id"), table_name="campaigns")
|
||||
op.drop_table("campaigns")
|
||||
# ### end Alembic commands ###
|
||||
207
api/alembic/versions/0c1223cc266f_make_json_not_nullable.py
Normal file
207
api/alembic/versions/0c1223cc266f_make_json_not_nullable.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""make json not nullable
|
||||
|
||||
Revision ID: 0c1223cc266f
|
||||
Revises: 0fe708f2acb9
|
||||
Create Date: 2025-07-21 14:36:31.182969
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0c1223cc266f"
|
||||
down_revision: Union[str, None] = "0fe708f2acb9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Update existing NULL values to empty dict for workflows table
|
||||
op.execute("""
|
||||
UPDATE workflows
|
||||
SET template_context_variables = '{}'::jsonb
|
||||
WHERE template_context_variables IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE workflows
|
||||
SET call_disposition_codes = '{}'::jsonb
|
||||
WHERE call_disposition_codes IS NULL
|
||||
""")
|
||||
|
||||
# Update existing NULL values to empty dict for workflow_runs table
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET usage_info = '{}'::jsonb
|
||||
WHERE usage_info IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET cost_info = '{}'::jsonb
|
||||
WHERE cost_info IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET initial_context = '{}'::jsonb
|
||||
WHERE initial_context IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET gathered_context = '{}'::jsonb
|
||||
WHERE gathered_context IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET annotations = '{}'::jsonb
|
||||
WHERE annotations IS NULL
|
||||
""")
|
||||
|
||||
# Update existing NULL values to empty dict for looptalk_test_sessions table
|
||||
op.execute("""
|
||||
UPDATE looptalk_test_sessions
|
||||
SET results = '{}'::jsonb
|
||||
WHERE results IS NULL
|
||||
""")
|
||||
|
||||
# Update existing NULL values to empty dict for looptalk_conversations table
|
||||
op.execute("""
|
||||
UPDATE looptalk_conversations
|
||||
SET transcript = '{}'::jsonb
|
||||
WHERE transcript IS NULL
|
||||
""")
|
||||
op.execute("""
|
||||
UPDATE looptalk_conversations
|
||||
SET metrics = '{}'::jsonb
|
||||
WHERE metrics IS NULL
|
||||
""")
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"looptalk_conversations",
|
||||
"transcript",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"looptalk_conversations",
|
||||
"metrics",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"looptalk_test_sessions",
|
||||
"results",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"usage_info",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"cost_info",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"initial_context",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"gathered_context",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"annotations",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflows",
|
||||
"template_context_variables",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflows",
|
||||
"call_disposition_codes",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"workflows",
|
||||
"call_disposition_codes",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflows",
|
||||
"template_context_variables",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"annotations",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"gathered_context",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"initial_context",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"cost_info",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_runs",
|
||||
"usage_info",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"looptalk_test_sessions",
|
||||
"results",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"looptalk_conversations",
|
||||
"metrics",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"looptalk_conversations",
|
||||
"transcript",
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""fix unique constraint on workflow definition
|
||||
|
||||
Revision ID: 0c1bbc83fe9e
|
||||
Revises: 9f5f2d35f6fb
|
||||
Create Date: 2025-06-04 15:56:02.979658
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0c1bbc83fe9e"
|
||||
down_revision: Union[str, None] = "9f5f2d35f6fb"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
"ix_workflow_definitions_workflow_hash", table_name="workflow_definitions"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_hash_workflow_id",
|
||||
"workflow_definitions",
|
||||
["workflow_hash", "workflow_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_workflow_hash_workflow_id",
|
||||
"workflow_definitions",
|
||||
["workflow_hash", "workflow_id"],
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(
|
||||
"uq_workflow_hash_workflow_id", "workflow_definitions", type_="unique"
|
||||
)
|
||||
op.drop_index("ix_workflow_hash_workflow_id", table_name="workflow_definitions")
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_workflow_hash",
|
||||
"workflow_definitions",
|
||||
["workflow_hash"],
|
||||
unique=True,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add call disposition codes
|
||||
|
||||
Revision ID: 0fe708f2acb9
|
||||
Revises: 1225ac786848
|
||||
Create Date: 2025-07-21 14:27:42.942689
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0fe708f2acb9"
|
||||
down_revision: Union[str, None] = "1225ac786848"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflows", sa.Column("call_disposition_codes", sa.JSON(), nullable=True)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflows", "call_disposition_codes")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"""add organization configurations
|
||||
|
||||
Revision ID: 1225ac786848
|
||||
Revises: 1da1d650c0e4
|
||||
Create Date: 2025-07-12 16:32:04.725072
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1225ac786848"
|
||||
down_revision: Union[str, None] = "1da1d650c0e4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"organization_configurations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("key", sa.String(), nullable=False),
|
||||
sa.Column("value", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"], ["organizations.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("organization_id", "key", name="_organization_key_uc"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organization_configurations_id"),
|
||||
"organization_configurations",
|
||||
["id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_organization_configurations_organization_id",
|
||||
"organization_configurations",
|
||||
["organization_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
"ix_organization_configurations_organization_id",
|
||||
table_name="organization_configurations",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_organization_configurations_id"),
|
||||
table_name="organization_configurations",
|
||||
)
|
||||
op.drop_table("organization_configurations")
|
||||
# ### end Alembic commands ###
|
||||
39
api/alembic/versions/13ccd6e1f5ad_add_workflow_run_mode.py
Normal file
39
api/alembic/versions/13ccd6e1f5ad_add_workflow_run_mode.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""add workflow run mode
|
||||
|
||||
Revision ID: 13ccd6e1f5ad
|
||||
Revises: 4735a1f0cdb3
|
||||
Create Date: 2025-07-28 17:44:10.827671
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "13ccd6e1f5ad"
|
||||
down_revision: Union[str, None] = "4735a1f0cdb3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute(
|
||||
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'webrtc' AFTER 'CHAT';"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'smallwebrtc' AFTER 'webrtc';"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'twilio' AFTER 'smallwebrtc';"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'stasis' AFTER 'twilio';"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
48
api/alembic/versions/1d441e79db94_add_user_model.py
Normal file
48
api/alembic/versions/1d441e79db94_add_user_model.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Add user model
|
||||
|
||||
Revision ID: 1d441e79db94
|
||||
Revises: 2d6e2f41caa2
|
||||
Create Date: 2025-04-16 17:40:04.699129
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1d441e79db94"
|
||||
down_revision: Union[str, None] = "2d6e2f41caa2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("provider_id", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_users_provider_id"), "users", ["provider_id"], unique=True)
|
||||
op.add_column("livekit_rooms", sa.Column("user_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, "livekit_rooms", "users", ["user_id"], ["id"])
|
||||
op.add_column("workflows", sa.Column("user_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, "workflows", "users", ["user_id"], ["id"])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflows", type_="foreignkey")
|
||||
op.drop_column("workflows", "user_id")
|
||||
op.drop_constraint(None, "livekit_rooms", type_="foreignkey")
|
||||
op.drop_column("livekit_rooms", "user_id")
|
||||
op.drop_index(op.f("ix_users_provider_id"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_id"), table_name="users")
|
||||
op.drop_table("users")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""add total call duration to usage
|
||||
|
||||
Revision ID: 1da1d650c0e4
|
||||
Revises: ec010596a0b4
|
||||
Create Date: 2025-07-08 15:41:15.039999
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1da1d650c0e4"
|
||||
down_revision: Union[str, None] = "ec010596a0b4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"organization_usage_cycles",
|
||||
sa.Column(
|
||||
"total_duration_seconds",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("organization_usage_cycles", "total_duration_seconds")
|
||||
# ### end Alembic commands ###
|
||||
30
api/alembic/versions/20c780c2a218_add_provider.py
Normal file
30
api/alembic/versions/20c780c2a218_add_provider.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""add provider
|
||||
|
||||
Revision ID: 20c780c2a218
|
||||
Revises: ac6da37c5034
|
||||
Create Date: 2025-06-09 19:23:00.111377
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20c780c2a218"
|
||||
down_revision: Union[str, None] = "ac6da37c5034"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("integrations", sa.Column("provider", sa.String(), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("integrations", "provider")
|
||||
# ### end Alembic commands ###
|
||||
118
api/alembic/versions/2159d4ac431a_added_quota_tables.py
Normal file
118
api/alembic/versions/2159d4ac431a_added_quota_tables.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""added quota tables
|
||||
|
||||
Revision ID: 2159d4ac431a
|
||||
Revises: e0d1a9b9f6c4
|
||||
Create Date: 2025-07-08 14:38:38.542486
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2159d4ac431a"
|
||||
down_revision: Union[str, None] = "e0d1a9b9f6c4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# 1) Create the `quota_type` enum *before* we add the column that references it.
|
||||
# Alembic does **not** automatically emit a CREATE TYPE statement for Enum
|
||||
# objects when they are used inside `op.add_column()`. If we skip this step
|
||||
# Postgres raises `type "quota_type" does not exist` when applying the
|
||||
# migration.
|
||||
quota_type_enum = sa.Enum("monthly", "annual", name="quota_type")
|
||||
quota_type_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
op.create_table(
|
||||
"organization_usage_cycles",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(), nullable=False),
|
||||
sa.Column("quota_dograh_tokens", sa.Integer(), nullable=False),
|
||||
sa.Column("used_dograh_tokens", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"],
|
||||
["organizations.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"organization_id", "period_start", "period_end", name="unique_org_period"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_usage_cycles_org_period",
|
||||
"organization_usage_cycles",
|
||||
["organization_id", "period_end"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organization_usage_cycles_id"),
|
||||
"organization_usage_cycles",
|
||||
["id"],
|
||||
unique=False,
|
||||
)
|
||||
# Add the column now that the enum type exists.
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column(
|
||||
"quota_type", quota_type_enum, nullable=False, server_default="monthly"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column(
|
||||
"quota_dograh_tokens",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column(
|
||||
"quota_reset_day",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("LEAST(EXTRACT(DAY FROM CURRENT_DATE)::int, 28)"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"organizations", sa.Column("quota_start_date", sa.DateTime(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column(
|
||||
"quota_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("organizations", "quota_enabled")
|
||||
op.drop_column("organizations", "quota_start_date")
|
||||
op.drop_column("organizations", "quota_reset_day")
|
||||
op.drop_column("organizations", "quota_dograh_tokens")
|
||||
op.drop_column("organizations", "quota_type")
|
||||
op.drop_index(
|
||||
op.f("ix_organization_usage_cycles_id"), table_name="organization_usage_cycles"
|
||||
)
|
||||
op.drop_index("idx_usage_cycles_org_period", table_name="organization_usage_cycles")
|
||||
op.drop_table("organization_usage_cycles")
|
||||
|
||||
# Drop the enum type now that no column depends on it.
|
||||
quota_type_enum = sa.Enum(name="quota_type")
|
||||
quota_type_enum.drop(op.get_bind(), checkfirst=True)
|
||||
# ### end Alembic commands ###
|
||||
46
api/alembic/versions/2d6e2f41caa2_add_workflow_run.py
Normal file
46
api/alembic/versions/2d6e2f41caa2_add_workflow_run.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Add workflow run
|
||||
|
||||
Revision ID: 2d6e2f41caa2
|
||||
Revises: bee2a9fcc6a6
|
||||
Create Date: 2025-04-11 17:02:58.461460
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2d6e2f41caa2"
|
||||
down_revision: Union[str, None] = "bee2a9fcc6a6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"workflow_runs",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("workflow_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"mode", sa.Enum("VOICE", "CHAT", name="workflow_run_mode"), nullable=False
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["workflow_id"],
|
||||
["workflows.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_workflow_runs_id"), "workflow_runs", ["id"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_workflow_runs_id"), table_name="workflow_runs")
|
||||
op.drop_table("workflow_runs")
|
||||
# ### end Alembic commands ###
|
||||
27
api/alembic/versions/2dfee251117b_fix_defaults.py
Normal file
27
api/alembic/versions/2dfee251117b_fix_defaults.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""fix defaults
|
||||
|
||||
Revision ID: 2dfee251117b
|
||||
Revises: 2159d4ac431a
|
||||
Create Date: 2025-07-08 14:47:40.747689
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2dfee251117b"
|
||||
down_revision: Union[str, None] = "2159d4ac431a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""add annotations in workflow run model
|
||||
|
||||
Revision ID: 2ed4baa89f15
|
||||
Revises: cdc80a4fd2dd
|
||||
Create Date: 2025-06-17 13:30:43.784644
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2ed4baa89f15"
|
||||
down_revision: Union[str, None] = "cdc80a4fd2dd"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("workflow_runs", sa.Column("annotations", sa.JSON(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflow_runs", "annotations")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""add workflow configurations
|
||||
|
||||
Revision ID: 3717ae6146e2
|
||||
Revises: f952c9c1105a
|
||||
Create Date: 2025-08-19 14:41:44.852794
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3717ae6146e2"
|
||||
down_revision: Union[str, None] = "f952c9c1105a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflows",
|
||||
sa.Column(
|
||||
"workflow_configurations",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflows", "workflow_configurations")
|
||||
# ### end Alembic commands ###
|
||||
60
api/alembic/versions/37d0a90fccba_add_api_keys_model.py
Normal file
60
api/alembic/versions/37d0a90fccba_add_api_keys_model.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Add API Keys model
|
||||
|
||||
Revision ID: 37d0a90fccba
|
||||
Revises: c425d3445750
|
||||
Create Date: 2025-09-06 14:19:35.300761
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "37d0a90fccba"
|
||||
down_revision: Union[str, None] = "c425d3445750"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("key_hash", sa.String(), nullable=False),
|
||||
sa.Column("key_prefix", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"], ["organizations.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_api_keys_active", "api_keys", ["is_active"], unique=False)
|
||||
op.create_index(op.f("ix_api_keys_id"), "api_keys", ["id"], unique=False)
|
||||
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||
op.create_index(
|
||||
"ix_api_keys_organization_id", "api_keys", ["organization_id"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("ix_api_keys_organization_id", table_name="api_keys")
|
||||
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
|
||||
op.drop_index(op.f("ix_api_keys_id"), table_name="api_keys")
|
||||
op.drop_index("ix_api_keys_active", table_name="api_keys")
|
||||
op.drop_table("api_keys")
|
||||
# ### end Alembic commands ###
|
||||
44
api/alembic/versions/3a0384c5ab2e_add_user_configuration.py
Normal file
44
api/alembic/versions/3a0384c5ab2e_add_user_configuration.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""add user configuration
|
||||
|
||||
Revision ID: 3a0384c5ab2e
|
||||
Revises: 1d441e79db94
|
||||
Create Date: 2025-04-24 12:26:33.635090
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3a0384c5ab2e"
|
||||
down_revision: Union[str, None] = "1d441e79db94"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"user_configurations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("configuration", sa.JSON(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_user_configurations_id"), "user_configurations", ["id"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_user_configurations_id"), table_name="user_configurations")
|
||||
op.drop_table("user_configurations")
|
||||
# ### end Alembic commands ###
|
||||
64
api/alembic/versions/3a30110d7cd7_added_livekit_room.py
Normal file
64
api/alembic/versions/3a30110d7cd7_added_livekit_room.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Added livekit room
|
||||
|
||||
Revision ID: 3a30110d7cd7
|
||||
Revises: 93a1ddbb6ffd
|
||||
Create Date: 2025-04-05 15:03:00.749510
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3a30110d7cd7"
|
||||
down_revision: Union[str, None] = "93a1ddbb6ffd"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"livekit_rooms",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"type", sa.Enum("VOICE", "TEXT", "VIDEO", name="room_type"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"state",
|
||||
sa.Enum(
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"RECONNECTING",
|
||||
"RECONNECTED",
|
||||
name="room_state",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_livekit_rooms_id"), "livekit_rooms", ["id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_livekit_rooms_name"), "livekit_rooms", ["name"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_livekit_rooms_state"), "livekit_rooms", ["state"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_livekit_rooms_type"), "livekit_rooms", ["type"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_livekit_rooms_type"), table_name="livekit_rooms")
|
||||
op.drop_index(op.f("ix_livekit_rooms_state"), table_name="livekit_rooms")
|
||||
op.drop_index(op.f("ix_livekit_rooms_name"), table_name="livekit_rooms")
|
||||
op.drop_index(op.f("ix_livekit_rooms_id"), table_name="livekit_rooms")
|
||||
op.drop_table("livekit_rooms")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"""add selected organisation id field in user model
|
||||
|
||||
Revision ID: 45fa7fec2993
|
||||
Revises: f6f19156bcb7
|
||||
Create Date: 2025-06-09 16:00:52.209779
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "45fa7fec2993"
|
||||
down_revision: Union[str, None] = "f6f19156bcb7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"users", sa.Column("selected_organization_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "users", "organizations", ["selected_organization_id"], ["id"]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "users", type_="foreignkey")
|
||||
op.drop_column("users", "selected_organization_id")
|
||||
# ### end Alembic commands ###
|
||||
94
api/alembic/versions/4735a1f0cdb3_add_queued_runs_table.py
Normal file
94
api/alembic/versions/4735a1f0cdb3_add_queued_runs_table.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""add queued runs table
|
||||
|
||||
Revision ID: 4735a1f0cdb3
|
||||
Revises: 08bb6e7f1397
|
||||
Create Date: 2025-07-24 16:04:07.899092
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4735a1f0cdb3"
|
||||
down_revision: Union[str, None] = "08bb6e7f1397"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"queued_runs",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_uuid", sa.String(), nullable=False),
|
||||
sa.Column("context_variables", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"state",
|
||||
sa.Enum("queued", "processed", name="queued_run_state"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("workflow_run_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["workflow_run_id"],
|
||||
["workflow_runs.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"campaign_id", "source_uuid", name="unique_campaign_source_uuid"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_queued_runs_campaign_state",
|
||||
"queued_runs",
|
||||
["campaign_id", "state"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_queued_runs_created", "queued_runs", ["created_at"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_queued_runs_source_uuid", "queued_runs", ["source_uuid"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_queued_runs_id"), "queued_runs", ["id"], unique=False)
|
||||
op.add_column(
|
||||
"campaigns", sa.Column("rate_limit_per_second", sa.Integer(), nullable=False)
|
||||
)
|
||||
op.add_column("campaigns", sa.Column("max_retries", sa.Integer(), nullable=False))
|
||||
op.add_column(
|
||||
"campaigns", sa.Column("source_sync_status", sa.String(), nullable=False)
|
||||
)
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column("source_last_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaigns", sa.Column("source_sync_error", sa.String(), nullable=True)
|
||||
)
|
||||
|
||||
# Add syncing in the campaign_state
|
||||
op.execute(
|
||||
"ALTER TYPE campaign_state ADD VALUE IF NOT EXISTS 'syncing' AFTER 'created';"
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("campaigns", "source_sync_error")
|
||||
op.drop_column("campaigns", "source_last_synced_at")
|
||||
op.drop_column("campaigns", "source_sync_status")
|
||||
op.drop_column("campaigns", "max_retries")
|
||||
op.drop_column("campaigns", "rate_limit_per_second")
|
||||
op.drop_index(op.f("ix_queued_runs_id"), table_name="queued_runs")
|
||||
op.drop_index("idx_queued_runs_source_uuid", table_name="queued_runs")
|
||||
op.drop_index("idx_queued_runs_created", table_name="queued_runs")
|
||||
op.drop_index("idx_queued_runs_campaign_state", table_name="queued_runs")
|
||||
op.drop_table("queued_runs")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"""add organisation_id in workflow
|
||||
|
||||
Revision ID: 477b47ce346b
|
||||
Revises: fec0fb9a8db7
|
||||
Create Date: 2025-08-15 14:11:28.088841
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "477b47ce346b"
|
||||
down_revision: Union[str, None] = "fec0fb9a8db7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflows", sa.Column("organization_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "workflows", "organizations", ["organization_id"], ["id"]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflows", type_="foreignkey")
|
||||
op.drop_column("workflows", "organization_id")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add teplate context variables in workflow
|
||||
|
||||
Revision ID: 5253971e3f03
|
||||
Revises: 0c1bbc83fe9e
|
||||
Create Date: 2025-06-05 14:06:51.059664
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5253971e3f03"
|
||||
down_revision: Union[str, None] = "0c1bbc83fe9e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflows", sa.Column("template_context_variables", sa.JSON(), nullable=True)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflows", "template_context_variables")
|
||||
# ### end Alembic commands ###
|
||||
66
api/alembic/versions/58f17b468b3c_add_workflow_definition.py
Normal file
66
api/alembic/versions/58f17b468b3c_add_workflow_definition.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""add workflow definition
|
||||
|
||||
Revision ID: 58f17b468b3c
|
||||
Revises: a2b092ff7282
|
||||
Create Date: 2025-06-03 13:08:23.709070
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "58f17b468b3c"
|
||||
down_revision: Union[str, None] = "a2b092ff7282"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"workflow_definitions",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("workflow_hash", sa.String(), nullable=False),
|
||||
sa.Column("workflow_json", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_id"), "workflow_definitions", ["id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_workflow_hash"),
|
||||
"workflow_definitions",
|
||||
["workflow_hash"],
|
||||
unique=True,
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("definition_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "workflow_runs", "workflow_definitions", ["definition_id"], ["id"]
|
||||
)
|
||||
op.add_column(
|
||||
"workflows", sa.Column("current_definition_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "workflows", "workflow_definitions", ["current_definition_id"], ["id"]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflows", type_="foreignkey")
|
||||
op.drop_column("workflows", "current_definition_id")
|
||||
op.drop_constraint(None, "workflow_runs", type_="foreignkey")
|
||||
op.drop_column("workflow_runs", "definition_id")
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_workflow_hash"), table_name="workflow_definitions"
|
||||
)
|
||||
op.drop_index(op.f("ix_workflow_definitions_id"), table_name="workflow_definitions")
|
||||
op.drop_table("workflow_definitions")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"""add timezone info on tables
|
||||
|
||||
Revision ID: 594f16adf97c
|
||||
Revises: 2dfee251117b
|
||||
Create Date: 2025-07-08 15:01:26.642043
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "594f16adf97c"
|
||||
down_revision: Union[str, None] = "2dfee251117b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"organization_usage_cycles",
|
||||
"period_start",
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"organization_usage_cycles",
|
||||
"period_end",
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"organizations",
|
||||
"quota_start_date",
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
existing_nullable=True,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"organizations",
|
||||
"quota_start_date",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"organization_usage_cycles",
|
||||
"period_end",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"organization_usage_cycles",
|
||||
"period_start",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
54
api/alembic/versions/693a865c011f_add_status_in_workflow.py
Normal file
54
api/alembic/versions/693a865c011f_add_status_in_workflow.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""add status in workflow
|
||||
|
||||
Revision ID: 693a865c011f
|
||||
Revises: 477b47ce346b
|
||||
Create Date: 2025-08-15 14:27:46.748114
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "693a865c011f"
|
||||
down_revision: Union[str, None] = "477b47ce346b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# First create the enum type
|
||||
workflow_status_enum = sa.Enum("active", "archived", name="workflow_status")
|
||||
workflow_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Then add the column with the enum type
|
||||
op.add_column(
|
||||
"workflows",
|
||||
sa.Column(
|
||||
"status",
|
||||
workflow_status_enum,
|
||||
nullable=False,
|
||||
server_default=sa.text("'active'"),
|
||||
),
|
||||
)
|
||||
|
||||
# Create index for better query performance
|
||||
op.create_index("ix_workflows_status", "workflows", ["status"])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Drop index first
|
||||
op.drop_index("ix_workflows_status", table_name="workflows")
|
||||
|
||||
# Drop the column
|
||||
op.drop_column("workflows", "status")
|
||||
|
||||
# Drop the enum type
|
||||
workflow_status_enum = sa.Enum("active", "archived", name="workflow_status")
|
||||
workflow_status_enum.drop(op.get_bind(), checkfirst=True)
|
||||
# ### end Alembic commands ###
|
||||
50
api/alembic/versions/7e90cc8d025b_add_workflow_template.py
Normal file
50
api/alembic/versions/7e90cc8d025b_add_workflow_template.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""add workflow template
|
||||
|
||||
Revision ID: 7e90cc8d025b
|
||||
Revises: 9f25ff8f3cbd
|
||||
Create Date: 2025-06-10 18:17:55.080973
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7e90cc8d025b"
|
||||
down_revision: Union[str, None] = "9f25ff8f3cbd"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"workflow_templates",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("template_name", sa.String(), nullable=False),
|
||||
sa.Column("template_json", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_templates_id"), "workflow_templates", ["id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_templates_template_name"),
|
||||
"workflow_templates",
|
||||
["template_name"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_templates_template_name"), table_name="workflow_templates"
|
||||
)
|
||||
op.drop_index(op.f("ix_workflow_templates_id"), table_name="workflow_templates")
|
||||
op.drop_table("workflow_templates")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add price_per_second_usd
|
||||
|
||||
Revision ID: 7feef09d7cc6
|
||||
Revises: 3717ae6146e2
|
||||
Create Date: 2025-08-25 13:04:13.436156
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7feef09d7cc6"
|
||||
down_revision: Union[str, None] = "3717ae6146e2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"organizations", sa.Column("price_per_second_usd", sa.Float(), nullable=True)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("organizations", "price_per_second_usd")
|
||||
# ### end Alembic commands ###
|
||||
41
api/alembic/versions/93a1ddbb6ffd_add_workflow_model.py
Normal file
41
api/alembic/versions/93a1ddbb6ffd_add_workflow_model.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Add workflow model
|
||||
|
||||
Revision ID: 93a1ddbb6ffd
|
||||
Revises:
|
||||
Create Date: 2025-04-03 20:06:47.970117
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "93a1ddbb6ffd"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"workflows",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("workflow_definition", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_workflows_id"), "workflows", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_workflows_name"), "workflows", ["name"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_workflows_name"), table_name="workflows")
|
||||
op.drop_index(op.f("ix_workflows_id"), table_name="workflows")
|
||||
op.drop_table("workflows")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""add is_superuser to user model
|
||||
|
||||
Revision ID: 9641b4f306cd
|
||||
Revises: 3a0384c5ab2e
|
||||
Create Date: 2025-05-07 11:37:44.295774
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9641b4f306cd"
|
||||
down_revision: Union[str, None] = "3a0384c5ab2e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("users", sa.Column("is_superuser", sa.Boolean(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("users", "is_superuser")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"""add storage backend in workflow
|
||||
|
||||
Revision ID: 982ec8e434be
|
||||
Revises: d0060de90c18
|
||||
Create Date: 2025-09-09 10:39:12.894626
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from alembic_postgresql_enum import TableReference
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "982ec8e434be"
|
||||
down_revision: Union[str, None] = "d0060de90c18"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
sa.Enum("s3", "minio", name="storage_backend").create(op.get_bind())
|
||||
op.drop_constraint(
|
||||
"queued_runs_workflow_run_id_fkey", "queued_runs", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("queued_runs", "workflow_run_id")
|
||||
op.add_column(
|
||||
"workflow_runs",
|
||||
sa.Column(
|
||||
"storage_backend",
|
||||
postgresql.ENUM("s3", "minio", name="storage_backend", create_type=False),
|
||||
server_default=sa.text("'s3'::storage_backend"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
sa.Enum("VOICE", "TEXT", "VIDEO", name="room_type").drop(op.get_bind())
|
||||
sa.Enum(
|
||||
"CONNECTED", "DISCONNECTED", "RECONNECTING", "RECONNECTED", name="room_state"
|
||||
).drop(op.get_bind())
|
||||
op.sync_enum_values(
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.sync_enum_values(
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=["VOICE", "CHAT", "webrtc", "smallwebrtc", "twilio", "stasis"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
sa.Enum(
|
||||
"CONNECTED", "DISCONNECTED", "RECONNECTING", "RECONNECTED", name="room_state"
|
||||
).create(op.get_bind())
|
||||
sa.Enum("VOICE", "TEXT", "VIDEO", name="room_type").create(op.get_bind())
|
||||
op.drop_column("workflow_runs", "storage_backend")
|
||||
op.add_column(
|
||||
"queued_runs",
|
||||
sa.Column("workflow_run_id", sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"queued_runs_workflow_run_id_fkey",
|
||||
"queued_runs",
|
||||
"workflow_runs",
|
||||
["workflow_run_id"],
|
||||
["id"],
|
||||
)
|
||||
sa.Enum("s3", "minio", name="storage_backend").drop(op.get_bind())
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""add recording and transcript urls in workflow run
|
||||
|
||||
Revision ID: 9be6240baa00
|
||||
Revises: 9641b4f306cd
|
||||
Create Date: 2025-05-09 21:50:03.369283
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9be6240baa00"
|
||||
down_revision: Union[str, None] = "9641b4f306cd"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("is_completed", sa.Boolean(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("recording_url", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("transcript_url", sa.String(), nullable=True)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflow_runs", "transcript_url")
|
||||
op.drop_column("workflow_runs", "recording_url")
|
||||
op.drop_column("workflow_runs", "is_completed")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"""add workflow id in workflow definition
|
||||
|
||||
Revision ID: 9ef49df72862
|
||||
Revises: 58f17b468b3c
|
||||
Create Date: 2025-06-04 10:54:01.714149
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9ef49df72862"
|
||||
down_revision: Union[str, None] = "58f17b468b3c"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflow_definitions", sa.Column("workflow_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "workflow_definitions", "workflows", ["workflow_id"], ["id"]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflow_definitions", type_="foreignkey")
|
||||
op.drop_column("workflow_definitions", "workflow_id")
|
||||
# ### end Alembic commands ###
|
||||
30
api/alembic/versions/9f25ff8f3cbd_add_actions.py
Normal file
30
api/alembic/versions/9f25ff8f3cbd_add_actions.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""add actions
|
||||
|
||||
Revision ID: 9f25ff8f3cbd
|
||||
Revises: d11fbd083a55
|
||||
Create Date: 2025-06-09 20:16:19.418495
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9f25ff8f3cbd"
|
||||
down_revision: Union[str, None] = "d11fbd083a55"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("integrations", sa.Column("action", sa.String(), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("integrations", "action")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"""remove workflow definition id from workflow
|
||||
|
||||
Revision ID: 9f5f2d35f6fb
|
||||
Revises: 9ef49df72862
|
||||
Create Date: 2025-06-04 13:27:45.776741
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9f5f2d35f6fb"
|
||||
down_revision: Union[str, None] = "9ef49df72862"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflow_definitions",
|
||||
sa.Column(
|
||||
"is_current", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||
),
|
||||
)
|
||||
op.drop_constraint(
|
||||
"workflows_current_definition_id_fkey", "workflows", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("workflows", "current_definition_id")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflows",
|
||||
sa.Column(
|
||||
"current_definition_id", sa.INTEGER(), autoincrement=False, nullable=True
|
||||
),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"workflows_current_definition_id_fkey",
|
||||
"workflows",
|
||||
"workflow_definitions",
|
||||
["current_definition_id"],
|
||||
["id"],
|
||||
)
|
||||
op.drop_column("workflow_definitions", "is_current")
|
||||
# ### end Alembic commands ###
|
||||
33
api/alembic/versions/a29b05f31ddf_add_last_validated_at.py
Normal file
33
api/alembic/versions/a29b05f31ddf_add_last_validated_at.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""add last validated at
|
||||
|
||||
Revision ID: a29b05f31ddf
|
||||
Revises: 9be6240baa00
|
||||
Create Date: 2025-05-21 22:59:27.008241
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a29b05f31ddf"
|
||||
down_revision: Union[str, None] = "9be6240baa00"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"user_configurations",
|
||||
sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("user_configurations", "last_validated_at")
|
||||
# ### end Alembic commands ###
|
||||
40
api/alembic/versions/a2b092ff7282_add_usage_info.py
Normal file
40
api/alembic/versions/a2b092ff7282_add_usage_info.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""add usage info
|
||||
|
||||
Revision ID: a2b092ff7282
|
||||
Revises: a29b05f31ddf
|
||||
Create Date: 2025-05-29 20:25:37.110818
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a2b092ff7282"
|
||||
down_revision: Union[str, None] = "a29b05f31ddf"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("workflow_runs", sa.Column("usage_info", sa.JSON(), nullable=True))
|
||||
op.add_column("workflow_runs", sa.Column("cost_info", sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("initial_context", sa.JSON(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("gathered_context", sa.JSON(), nullable=True)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflow_runs", "gathered_context")
|
||||
op.drop_column("workflow_runs", "initial_context")
|
||||
op.drop_column("workflow_runs", "cost_info")
|
||||
op.drop_column("workflow_runs", "usage_info")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""add log in workflow run model
|
||||
|
||||
Revision ID: a75ae71af479
|
||||
Revises: 13ccd6e1f5ad
|
||||
Create Date: 2025-08-05 22:37:06.248630
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a75ae71af479"
|
||||
down_revision: Union[str, None] = "13ccd6e1f5ad"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflow_runs",
|
||||
sa.Column(
|
||||
"logs",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("workflow_runs", "logs")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add created by in integration
|
||||
|
||||
Revision ID: ac6da37c5034
|
||||
Revises: d666f3244648
|
||||
Create Date: 2025-06-09 17:27:37.088950
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ac6da37c5034"
|
||||
down_revision: Union[str, None] = "d666f3244648"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("integrations", sa.Column("created_by", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, "integrations", "users", ["created_by"], ["id"])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "integrations", type_="foreignkey")
|
||||
op.drop_column("integrations", "created_by")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""fix datetime to be in utc
|
||||
|
||||
Revision ID: bee2a9fcc6a6
|
||||
Revises: 3a30110d7cd7
|
||||
Create Date: 2025-04-05 15:40:00.807226
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "bee2a9fcc6a6"
|
||||
down_revision: Union[str, None] = "3a30110d7cd7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"livekit_rooms",
|
||||
"created_at",
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflows",
|
||||
"created_at",
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
existing_nullable=True,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"workflows",
|
||||
"created_at",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"livekit_rooms",
|
||||
"created_at",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""add columns in usage table
|
||||
|
||||
Revision ID: c425d3445750
|
||||
Revises: 7feef09d7cc6
|
||||
Create Date: 2025-08-25 13:37:15.524965
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c425d3445750"
|
||||
down_revision: Union[str, None] = "7feef09d7cc6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"organization_usage_cycles",
|
||||
sa.Column("used_amount_usd", sa.Float(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"organization_usage_cycles",
|
||||
sa.Column("quota_amount_usd", sa.Float(), nullable=True),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("organization_usage_cycles", "quota_amount_usd")
|
||||
op.drop_column("organization_usage_cycles", "used_amount_usd")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"""add template description
|
||||
|
||||
Revision ID: cdc80a4fd2dd
|
||||
Revises: 7e90cc8d025b
|
||||
Create Date: 2025-06-10 18:31:30.952698
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "cdc80a4fd2dd"
|
||||
down_revision: Union[str, None] = "7e90cc8d025b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"workflow_templates",
|
||||
sa.Column("template_description", sa.String(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_templates_template_description"),
|
||||
"workflow_templates",
|
||||
["template_description"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_templates_template_description"),
|
||||
table_name="workflow_templates",
|
||||
)
|
||||
op.drop_column("workflow_templates", "template_description")
|
||||
# ### end Alembic commands ###
|
||||
63
api/alembic/versions/d0060de90c18_fix_migrations.py
Normal file
63
api/alembic/versions/d0060de90c18_fix_migrations.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Fix Migrations
|
||||
|
||||
Revision ID: d0060de90c18
|
||||
Revises: 37d0a90fccba
|
||||
Create Date: 2025-09-08 13:19:03.854682
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d0060de90c18"
|
||||
down_revision: Union[str, None] = "37d0a90fccba"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
|
||||
op.create_index(op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=True)
|
||||
|
||||
# remove redundant id indexes that got created
|
||||
op.drop_index("ix_api_keys_id", table_name="api_keys")
|
||||
op.drop_index("ix_users_id", table_name="users")
|
||||
op.drop_index("ix_organizations_id", table_name="organizations")
|
||||
op.drop_index("ix_workflows_id", table_name="workflows")
|
||||
op.drop_index("ix_workflow_definitions_id", table_name="workflow_definitions")
|
||||
op.drop_index("ix_workflow_templates_id", table_name="workflow_templates")
|
||||
op.drop_index("ix_workflow_runs_id", table_name="workflow_runs")
|
||||
op.drop_index("ix_campaigns_id", table_name="campaigns")
|
||||
op.drop_index("ix_integrations_id", table_name="integrations")
|
||||
op.drop_index("ix_queued_runs_id", table_name="queued_runs")
|
||||
op.drop_index("ix_user_configurations_id", table_name="user_configurations")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys")
|
||||
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=False)
|
||||
|
||||
# Recreate all the redundant id indexes (reverting to the incorrect state)
|
||||
op.create_index(
|
||||
"ix_user_configurations_id", "user_configurations", ["id"], unique=False
|
||||
)
|
||||
op.create_index("ix_queued_runs_id", "queued_runs", ["id"], unique=False)
|
||||
op.create_index("ix_integrations_id", "integrations", ["id"], unique=False)
|
||||
op.create_index("ix_campaigns_id", "campaigns", ["id"], unique=False)
|
||||
op.create_index("ix_workflow_runs_id", "workflow_runs", ["id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_workflow_templates_id", "workflow_templates", ["id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_id", "workflow_definitions", ["id"], unique=False
|
||||
)
|
||||
op.create_index("ix_workflows_id", "workflows", ["id"], unique=False)
|
||||
op.create_index("ix_organizations_id", "organizations", ["id"], unique=False)
|
||||
op.create_index("ix_users_id", "users", ["id"], unique=False)
|
||||
op.create_index("ix_api_keys_id", "api_keys", ["id"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
32
api/alembic/versions/d11fbd083a55_add_connection_details.py
Normal file
32
api/alembic/versions/d11fbd083a55_add_connection_details.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""add connection details
|
||||
|
||||
Revision ID: d11fbd083a55
|
||||
Revises: 20c780c2a218
|
||||
Create Date: 2025-06-09 19:49:24.999108
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d11fbd083a55"
|
||||
down_revision: Union[str, None] = "20c780c2a218"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"integrations", sa.Column("connection_details", sa.JSON(), nullable=False)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("integrations", "connection_details")
|
||||
# ### end Alembic commands ###
|
||||
51
api/alembic/versions/d666f3244648_add_integrations.py
Normal file
51
api/alembic/versions/d666f3244648_add_integrations.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""add integrations
|
||||
|
||||
Revision ID: d666f3244648
|
||||
Revises: 45fa7fec2993
|
||||
Create Date: 2025-06-09 16:50:56.647020
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d666f3244648"
|
||||
down_revision: Union[str, None] = "45fa7fec2993"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"integrations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("integration_id", sa.String(), nullable=False),
|
||||
sa.Column("organisation_id", sa.Integer(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organisation_id"],
|
||||
["organizations.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_integrations_id"), "integrations", ["id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_integrations_integration_id"),
|
||||
"integrations",
|
||||
["integration_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_integrations_integration_id"), table_name="integrations")
|
||||
op.drop_index(op.f("ix_integrations_id"), table_name="integrations")
|
||||
op.drop_table("integrations")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"""add_looptalk_testing_tables_without_turns
|
||||
|
||||
Revision ID: e0d1a9b9f6c4
|
||||
Revises: 2ed4baa89f15
|
||||
Create Date: 2025-07-01 14:29:12.081935
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e0d1a9b9f6c4"
|
||||
down_revision: Union[str, None] = "2ed4baa89f15"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"looptalk_test_sessions",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum(
|
||||
"pending", "running", "completed", "failed", name="test_session_status"
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("actor_workflow_id", sa.Integer(), nullable=False),
|
||||
sa.Column("adversary_workflow_id", sa.Integer(), nullable=False),
|
||||
sa.Column("load_test_group_id", sa.String(), nullable=True),
|
||||
sa.Column("test_index", sa.Integer(), nullable=True),
|
||||
sa.Column("config", sa.JSON(), nullable=False),
|
||||
sa.Column("results", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["actor_workflow_id"],
|
||||
["workflows.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["adversary_workflow_id"],
|
||||
["workflows.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"],
|
||||
["organizations.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_looptalk_test_sessions_group_id",
|
||||
"looptalk_test_sessions",
|
||||
["load_test_group_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_looptalk_test_sessions_id"),
|
||||
"looptalk_test_sessions",
|
||||
["id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_looptalk_test_sessions_load_test_group_id"),
|
||||
"looptalk_test_sessions",
|
||||
["load_test_group_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_looptalk_test_sessions_org_id",
|
||||
"looptalk_test_sessions",
|
||||
["organization_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_looptalk_test_sessions_status",
|
||||
"looptalk_test_sessions",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"looptalk_conversations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("test_session_id", sa.Integer(), nullable=False),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("actor_recording_url", sa.String(), nullable=True),
|
||||
sa.Column("adversary_recording_url", sa.String(), nullable=True),
|
||||
sa.Column("combined_recording_url", sa.String(), nullable=True),
|
||||
sa.Column("transcript", sa.JSON(), nullable=True),
|
||||
sa.Column("metrics", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["test_session_id"],
|
||||
["looptalk_test_sessions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_looptalk_conversations_id"),
|
||||
"looptalk_conversations",
|
||||
["id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_looptalk_conversations_session_id",
|
||||
"looptalk_conversations",
|
||||
["test_session_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
"ix_looptalk_conversations_session_id", table_name="looptalk_conversations"
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_looptalk_conversations_id"), table_name="looptalk_conversations"
|
||||
)
|
||||
op.drop_table("looptalk_conversations")
|
||||
op.drop_index(
|
||||
"ix_looptalk_test_sessions_status", table_name="looptalk_test_sessions"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_looptalk_test_sessions_org_id", table_name="looptalk_test_sessions"
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_looptalk_test_sessions_load_test_group_id"),
|
||||
table_name="looptalk_test_sessions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_looptalk_test_sessions_id"), table_name="looptalk_test_sessions"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_looptalk_test_sessions_group_id", table_name="looptalk_test_sessions"
|
||||
)
|
||||
op.drop_table("looptalk_test_sessions")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""change datatype of usage to float
|
||||
|
||||
Revision ID: ec010596a0b4
|
||||
Revises: 594f16adf97c
|
||||
Create Date: 2025-07-08 15:25:49.387567
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ec010596a0b4"
|
||||
down_revision: Union[str, None] = "594f16adf97c"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"organization_usage_cycles",
|
||||
"used_dograh_tokens",
|
||||
existing_type=sa.INTEGER(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"organization_usage_cycles",
|
||||
"used_dograh_tokens",
|
||||
existing_type=sa.Float(),
|
||||
type_=sa.INTEGER(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
104
api/alembic/versions/f6f19156bcb7_add_organisation_table.py
Normal file
104
api/alembic/versions/f6f19156bcb7_add_organisation_table.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""add organisation table
|
||||
|
||||
Revision ID: f6f19156bcb7
|
||||
Revises: 5253971e3f03
|
||||
Create Date: 2025-06-09 15:12:14.636802
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f6f19156bcb7"
|
||||
down_revision: Union[str, None] = "5253971e3f03"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"organizations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("provider_id", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_organizations_id"), "organizations", ["id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_provider_id"),
|
||||
"organizations",
|
||||
["provider_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_table(
|
||||
"organization_users",
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"],
|
||||
["organizations.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("user_id", "organization_id"),
|
||||
)
|
||||
op.drop_index("ix_livekit_rooms_id", table_name="livekit_rooms")
|
||||
op.drop_index("ix_livekit_rooms_name", table_name="livekit_rooms")
|
||||
op.drop_index("ix_livekit_rooms_state", table_name="livekit_rooms")
|
||||
op.drop_index("ix_livekit_rooms_type", table_name="livekit_rooms")
|
||||
op.drop_table("livekit_rooms")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"livekit_rooms",
|
||||
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column(
|
||||
"type",
|
||||
postgresql.ENUM("VOICE", "TEXT", "VIDEO", name="room_type"),
|
||||
autoincrement=False,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"state",
|
||||
postgresql.ENUM(
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"RECONNECTING",
|
||||
"RECONNECTED",
|
||||
name="room_state",
|
||||
),
|
||||
autoincrement=False,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
postgresql.TIMESTAMP(timezone=True),
|
||||
autoincrement=False,
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"], name="livekit_rooms_user_id_fkey"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name="livekit_rooms_pkey"),
|
||||
)
|
||||
op.create_index("ix_livekit_rooms_type", "livekit_rooms", ["type"], unique=False)
|
||||
op.create_index("ix_livekit_rooms_state", "livekit_rooms", ["state"], unique=False)
|
||||
op.create_index("ix_livekit_rooms_name", "livekit_rooms", ["name"], unique=False)
|
||||
op.create_index("ix_livekit_rooms_id", "livekit_rooms", ["id"], unique=False)
|
||||
op.drop_table("organization_users")
|
||||
op.drop_index(op.f("ix_organizations_provider_id"), table_name="organizations")
|
||||
op.drop_index(op.f("ix_organizations_id"), table_name="organizations")
|
||||
op.drop_table("organizations")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""add failed state to queued runs
|
||||
|
||||
Revision ID: f952c9c1105a
|
||||
Revises: 693a865c011f
|
||||
Create Date: 2025-08-16 17:32:47.877340
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f952c9c1105a"
|
||||
down_revision: Union[str, None] = "693a865c011f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("ix_workflows_status", table_name="workflows")
|
||||
op.execute("ALTER TYPE queued_run_state ADD VALUE IF NOT EXISTS 'failed'")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index("ix_workflows_status", "workflows", ["status"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
84
api/alembic/versions/fec0fb9a8db7_add_index.py
Normal file
84
api/alembic/versions/fec0fb9a8db7_add_index.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""add index
|
||||
|
||||
Revision ID: fec0fb9a8db7
|
||||
Revises: fefdd1835b7d
|
||||
Create Date: 2025-08-06 16:21:56.450309
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "fec0fb9a8db7"
|
||||
down_revision: Union[str, None] = "fefdd1835b7d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column("last_batch_scheduled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column(
|
||||
"orchestrator_metadata",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_campaigns_active_status",
|
||||
"campaigns",
|
||||
["state"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("state IN ('syncing', 'running', 'paused')"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_queued_runs_campaign_state_optimized",
|
||||
"queued_runs",
|
||||
["campaign_id", "state"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("state = 'queued'"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_queued_runs_scheduled_optimized",
|
||||
"queued_runs",
|
||||
["campaign_id", "scheduled_for"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("scheduled_for IS NOT NULL"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
"idx_queued_runs_scheduled_optimized",
|
||||
table_name="queued_runs",
|
||||
postgresql_where=sa.text("scheduled_for IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_queued_runs_campaign_state_optimized",
|
||||
table_name="queued_runs",
|
||||
postgresql_where=sa.text("state = 'queued'"),
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_campaigns_active_status",
|
||||
table_name="campaigns",
|
||||
postgresql_where=sa.text("state IN ('syncing', 'running', 'paused')"),
|
||||
)
|
||||
op.drop_column("campaigns", "orchestrator_metadata")
|
||||
op.drop_column("campaigns", "last_activity_at")
|
||||
op.drop_column("campaigns", "last_batch_scheduled_at")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"""retry outbound calls for campaigns
|
||||
|
||||
Revision ID: fefdd1835b7d
|
||||
Revises: a75ae71af479
|
||||
Create Date: 2025-08-06 13:37:54.151453
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "fefdd1835b7d"
|
||||
down_revision: Union[str, None] = "a75ae71af479"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column(
|
||||
"retry_config",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text(
|
||||
"""'{\"enabled\": true, \"max_retries\": 2, \"retry_delay_seconds\": 120, \"retry_on_busy\": true, \"retry_on_no_answer\": true, \"retry_on_voicemail\": true}'::jsonb"""
|
||||
),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"queued_runs",
|
||||
sa.Column(
|
||||
"retry_count", sa.Integer(), nullable=False, server_default=sa.text("0")
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"queued_runs", sa.Column("parent_queued_run_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"queued_runs",
|
||||
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column("queued_runs", sa.Column("retry_reason", sa.String(), nullable=True))
|
||||
op.drop_constraint("unique_campaign_source_uuid", "queued_runs", type_="unique")
|
||||
op.create_index(
|
||||
"idx_queued_runs_scheduled", "queued_runs", ["scheduled_for"], unique=False
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"unique_campaign_source_retry",
|
||||
"queued_runs",
|
||||
["campaign_id", "source_uuid", "retry_count"],
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "queued_runs", "queued_runs", ["parent_queued_run_id"], ["id"]
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_runs", sa.Column("queued_run_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None, "workflow_runs", "queued_runs", ["queued_run_id"], ["id"]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflow_runs", type_="foreignkey")
|
||||
op.drop_column("workflow_runs", "queued_run_id")
|
||||
op.drop_constraint(None, "queued_runs", type_="foreignkey")
|
||||
op.drop_constraint("unique_campaign_source_retry", "queued_runs", type_="unique")
|
||||
op.drop_index("idx_queued_runs_scheduled", table_name="queued_runs")
|
||||
op.create_unique_constraint(
|
||||
"unique_campaign_source_uuid", "queued_runs", ["campaign_id", "source_uuid"]
|
||||
)
|
||||
op.drop_column("queued_runs", "retry_reason")
|
||||
op.drop_column("queued_runs", "scheduled_for")
|
||||
op.drop_column("queued_runs", "parent_queued_run_id")
|
||||
op.drop_column("queued_runs", "retry_count")
|
||||
op.drop_column("campaigns", "retry_config")
|
||||
# ### end Alembic commands ###
|
||||
122
api/app.py
Normal file
122
api/app.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Set up logging before importing anything else"""
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
from api.constants import ENABLE_SENTRY, REDIS_URL, SENTRY_DSN
|
||||
from api.logging_config import ENVIRONMENT, setup_logging
|
||||
|
||||
# Set up logging and get the listener for cleanup
|
||||
logging_queue_listener = setup_logging()
|
||||
|
||||
|
||||
if ENABLE_SENTRY:
|
||||
if not SENTRY_DSN:
|
||||
print(
|
||||
"Warning: ENABLE_SENTRY is true but SENTRY_DSN is not configured. Sentry disabled."
|
||||
)
|
||||
else:
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
# Add data like request headers and IP for users,
|
||||
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
||||
send_default_pii=True,
|
||||
environment=ENVIRONMENT,
|
||||
)
|
||||
print(f"Sentry initialized in environment: {ENVIRONMENT}")
|
||||
else:
|
||||
print(f"Sentry disabled (ENABLE_SENTRY=false)")
|
||||
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from api.routes.main import router as main_router
|
||||
from api.routes.rtc_offer import pcs_map
|
||||
from api.services.telephony.worker_event_subscriber import (
|
||||
WorkerEventSubscriber,
|
||||
setup_worker_subscriber,
|
||||
)
|
||||
from api.tasks.arq import get_arq_redis
|
||||
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
# Global reference to worker subscriber for graceful shutdown
|
||||
worker_subscriber_instance: Optional[WorkerEventSubscriber] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global worker_subscriber_instance
|
||||
|
||||
# warmup arq pool
|
||||
await get_arq_redis()
|
||||
|
||||
# Setup Redis connection for distributed mode
|
||||
redis = await aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
|
||||
# Setup worker subscriber (ARI Manager runs separately)
|
||||
worker_subscriber = await setup_worker_subscriber(redis)
|
||||
worker_subscriber_instance = worker_subscriber
|
||||
|
||||
# Store worker ID in app state for health check
|
||||
app.state.worker_id = worker_subscriber.worker_id
|
||||
app.state.worker_subscriber = worker_subscriber
|
||||
|
||||
yield # Run app
|
||||
|
||||
# Shutdown sequence - this runs when FastAPI is shutting down
|
||||
logger.info("Starting graceful shutdown...")
|
||||
|
||||
# First, try graceful shutdown with timeout
|
||||
if worker_subscriber:
|
||||
try:
|
||||
# Check if we should do graceful shutdown (e.g., if SIGTERM was received)
|
||||
# For now, we'll attempt graceful shutdown for all shutdowns
|
||||
await worker_subscriber.graceful_shutdown(max_wait_seconds=300)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graceful shutdown: {e}")
|
||||
# Fall back to immediate stop
|
||||
await worker_subscriber.stop()
|
||||
|
||||
# close all dangling pipecat connections
|
||||
coros = [pc.close() for pc in pcs_map.values()]
|
||||
await asyncio.gather(*coros)
|
||||
pcs_map.clear()
|
||||
|
||||
await redis.aclose()
|
||||
|
||||
if logging_queue_listener is not None:
|
||||
logging_queue_listener.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Dograh API",
|
||||
description="API for the Dograh app",
|
||||
version="1.0.0",
|
||||
openapi_url=f"{API_PREFIX}/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allows all origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"], # Allows all methods
|
||||
allow_headers=["*"], # Allows all headers
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# include subrouters here
|
||||
api_router.include_router(main_router)
|
||||
|
||||
# main router with api prefix
|
||||
app.include_router(api_router, prefix=API_PREFIX)
|
||||
BIN
api/assets/office-ambience-16000-mono.wav
Normal file
BIN
api/assets/office-ambience-16000-mono.wav
Normal file
Binary file not shown.
BIN
api/assets/office-ambience-8000-mono.wav
Normal file
BIN
api/assets/office-ambience-8000-mono.wav
Normal file
Binary file not shown.
182
api/conftest.py
Normal file
182
api/conftest.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""
|
||||
Shared pytest fixtures for the API tests.
|
||||
This file contains database setup, test client configuration, and utility fixtures
|
||||
that can be reused across all test files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from api.app import app
|
||||
from api.db import db_client
|
||||
|
||||
# Test database setup globals
|
||||
TEST_DATABASE_NAME = None
|
||||
TEST_DATABASE_URL = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_database():
|
||||
"""
|
||||
Set up a temporary PostgreSQL database for testing.
|
||||
This fixture creates a unique test database, runs migrations, and cleans up afterward.
|
||||
"""
|
||||
global TEST_DATABASE_NAME, TEST_DATABASE_URL
|
||||
|
||||
# Generate a unique test database name
|
||||
TEST_DATABASE_NAME = f"test_dograh_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Get the base DATABASE_URL and parse it
|
||||
base_url = os.environ.get("DATABASE_URL")
|
||||
# Extract connection parts and replace database name
|
||||
url_parts = base_url.split("/")
|
||||
base_connection = "/".join(url_parts[:-1])
|
||||
TEST_DATABASE_URL = f"{base_connection}/{TEST_DATABASE_NAME}"
|
||||
|
||||
# Create a connection to the default postgres database to create our test database
|
||||
default_engine = create_async_engine(base_url)
|
||||
|
||||
try:
|
||||
# Create the test database
|
||||
async with default_engine.connect() as conn:
|
||||
# Use autocommit mode to create database
|
||||
await conn.execute(text("COMMIT"))
|
||||
await conn.execute(text(f"CREATE DATABASE {TEST_DATABASE_NAME}"))
|
||||
|
||||
await default_engine.dispose()
|
||||
|
||||
# Run migrations on the test database
|
||||
env = os.environ.copy()
|
||||
env["DATABASE_URL"] = TEST_DATABASE_URL
|
||||
# Add the parent directory to PYTHONPATH so alembic can find the api module
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
env["PYTHONPATH"] = parent_dir + ":" + env.get("PYTHONPATH", "")
|
||||
|
||||
# Run alembic upgrade to create all tables
|
||||
result = subprocess.run(
|
||||
[
|
||||
"conda",
|
||||
"run",
|
||||
"-n",
|
||||
"dograh",
|
||||
"python",
|
||||
"-m",
|
||||
"alembic",
|
||||
"-c",
|
||||
"alembic.ini",
|
||||
"upgrade",
|
||||
"head",
|
||||
],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Alembic stderr: {result.stderr}")
|
||||
logger.error(f"Alembic stdout: {result.stdout}")
|
||||
raise RuntimeError(f"Alembic migration failed: {result.stderr}")
|
||||
|
||||
logger.info(f"Created test database: {TEST_DATABASE_NAME}")
|
||||
yield TEST_DATABASE_URL
|
||||
|
||||
finally:
|
||||
# Cleanup: Drop the test database
|
||||
cleanup_engine = create_async_engine(base_url)
|
||||
try:
|
||||
async with cleanup_engine.connect() as conn:
|
||||
# Terminate any connections to the test database
|
||||
await conn.execute(text("COMMIT"))
|
||||
await conn.execute(
|
||||
text(f"""
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '{TEST_DATABASE_NAME}' AND pid <> pg_backend_pid()
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text(f"DROP DATABASE IF EXISTS {TEST_DATABASE_NAME}")
|
||||
)
|
||||
logger.info(f"Cleaned up test database: {TEST_DATABASE_NAME}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Warning: Could not clean up test database {TEST_DATABASE_NAME}: {e}"
|
||||
)
|
||||
finally:
|
||||
await cleanup_engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_database):
|
||||
"""
|
||||
Create a test database client that uses the temporary database.
|
||||
This fixture replaces the global db_client with a test version.
|
||||
"""
|
||||
original_engine = db_client.engine
|
||||
original_session = db_client.async_session
|
||||
|
||||
# Replace the database client's engine and session with test ones
|
||||
test_engine = create_async_engine(test_database)
|
||||
test_session_maker = async_sessionmaker(bind=test_engine)
|
||||
|
||||
db_client.engine = test_engine
|
||||
db_client.async_session = test_session_maker
|
||||
|
||||
yield db_client
|
||||
|
||||
# Restore original database client
|
||||
await test_engine.dispose()
|
||||
db_client.engine = original_engine
|
||||
db_client.async_session = original_session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_client_factory(db_session):
|
||||
"""
|
||||
Factory fixture that creates test clients for specific users.
|
||||
This allows tests to create custom users and test clients on demand.
|
||||
|
||||
Usage:
|
||||
async def test_something(test_client_factory, db_session):
|
||||
# Create a custom user
|
||||
user = await db_session.get_or_create_user_by_provider_id("custom_user_123")
|
||||
|
||||
# Create a test client for this user
|
||||
async with test_client_factory(user) as client:
|
||||
# Use the client in your test
|
||||
response = await client.get("/some/endpoint")
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from api.services.auth.depends import get_user
|
||||
|
||||
@asynccontextmanager
|
||||
async def _create_client_for_user(user):
|
||||
# Create mock auth dependency for this user
|
||||
async def mock_get_user():
|
||||
return user
|
||||
|
||||
# Override the dependency
|
||||
original_override = app.dependency_overrides.get(get_user)
|
||||
app.dependency_overrides[get_user] = mock_get_user
|
||||
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
yield client
|
||||
finally:
|
||||
# Clean up the override
|
||||
if original_override:
|
||||
app.dependency_overrides[get_user] = original_override
|
||||
else:
|
||||
app.dependency_overrides.pop(get_user, None)
|
||||
|
||||
return _create_client_for_user
|
||||
47
api/constants.py
Normal file
47
api/constants.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Absolute path to the project root directory (i.e. the directory containing
|
||||
# the top-level api/ package). Having a single canonical location helps
|
||||
# when constructing file-system paths elsewhere in the codebase.
|
||||
APP_ROOT_DIR: Path = Path(__file__).resolve().parent
|
||||
|
||||
FILLER_SOUND_PROBABILITY = 0.0
|
||||
|
||||
VOICEMAIL_RECORDING_DURATION = 5.0
|
||||
|
||||
# Configuration constants
|
||||
ENABLE_SMART_TURN = os.getenv("ENABLE_SMART_TURN", "false").lower() == "true"
|
||||
ENABLE_TRACING = os.getenv("ENABLE_TRACING", "false").lower() == "true"
|
||||
ENABLE_RNNOISE = os.getenv("ENABLE_RNNOISE", "false").lower() == "true"
|
||||
|
||||
BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", None)
|
||||
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID", None)
|
||||
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN", None)
|
||||
TWILIO_DEFAULT_FROM_NUMBER = os.getenv("TWILIO_FROM_NUMBER", None)
|
||||
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
REDIS_URL = os.environ["REDIS_URL"]
|
||||
|
||||
DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "oss")
|
||||
DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None)
|
||||
MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com")
|
||||
|
||||
# Storage Configuration
|
||||
ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
|
||||
|
||||
# MinIO Configuration
|
||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
|
||||
MINIO_PUBLIC_ENDPOINT = os.getenv("MINIO_PUBLIC_ENDPOINT")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio")
|
||||
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
||||
|
||||
# AWS S3 Configuration
|
||||
S3_BUCKET = os.environ.get("S3_BUCKET")
|
||||
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
|
||||
|
||||
# Sentry configuration
|
||||
ENABLE_SENTRY = os.getenv("ENABLE_SENTRY", "false").lower() == "true"
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||
3
api/db/__init__.py
Normal file
3
api/db/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from api.db.db_client import DBClient
|
||||
|
||||
db_client = DBClient()
|
||||
108
api/db/api_key_client.py
Normal file
108
api/db/api_key_client.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import APIKeyModel
|
||||
from api.utils.api_key import generate_api_key, hash_api_key
|
||||
|
||||
|
||||
class APIKeyClient(BaseDBClient):
|
||||
async def create_api_key(
|
||||
self, organization_id: int, name: str, created_by: Optional[int] = None
|
||||
) -> tuple[APIKeyModel, str]:
|
||||
"""Create a new API key for an organization.
|
||||
|
||||
Returns:
|
||||
Tuple of (APIKeyModel, raw_api_key)
|
||||
"""
|
||||
# Generate a secure random API key
|
||||
raw_api_key, key_hash, key_prefix = generate_api_key()
|
||||
|
||||
async with self.async_session() as session:
|
||||
api_key = APIKeyModel(
|
||||
organization_id=organization_id,
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(api_key)
|
||||
await session.commit()
|
||||
await session.refresh(api_key)
|
||||
|
||||
return api_key, raw_api_key
|
||||
|
||||
async def get_api_keys_by_organization(
|
||||
self, organization_id: int, include_archived: bool = False
|
||||
) -> List[APIKeyModel]:
|
||||
"""Get all API keys for an organization."""
|
||||
async with self.async_session() as session:
|
||||
query = select(APIKeyModel).where(
|
||||
APIKeyModel.organization_id == organization_id
|
||||
)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(APIKeyModel.archived_at.is_(None))
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_api_key_by_hash(self, key_hash: str) -> Optional[APIKeyModel]:
|
||||
"""Get an API key by its hash."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(APIKeyModel).where(
|
||||
and_(
|
||||
APIKeyModel.key_hash == key_hash,
|
||||
APIKeyModel.is_active == True,
|
||||
APIKeyModel.archived_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def validate_api_key(self, raw_api_key: str) -> Optional[APIKeyModel]:
|
||||
"""Validate an API key and return the associated model if valid."""
|
||||
key_hash = hash_api_key(raw_api_key)
|
||||
api_key = await self.get_api_key_by_hash(key_hash)
|
||||
|
||||
if api_key:
|
||||
# Update last_used_at
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async with self.async_session() as session:
|
||||
await session.execute(
|
||||
APIKeyModel.__table__.update()
|
||||
.where(APIKeyModel.id == api_key.id)
|
||||
.values(last_used_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return api_key
|
||||
|
||||
async def archive_api_key(self, api_key_id: int) -> bool:
|
||||
"""Archive an API key (soft delete)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
APIKeyModel.__table__.update()
|
||||
.where(APIKeyModel.id == api_key_id)
|
||||
.values(is_active=False, archived_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def reactivate_api_key(self, api_key_id: int) -> bool:
|
||||
"""Reactivate an archived API key."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
APIKeyModel.__table__.update()
|
||||
.where(APIKeyModel.id == api_key_id)
|
||||
.values(is_active=True, archived_at=None)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
34
api/db/base_client.py
Normal file
34
api/db/base_client.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from api.constants import DATABASE_URL
|
||||
|
||||
|
||||
class BaseDBClient:
|
||||
def __init__(self):
|
||||
self.engine = create_async_engine(DATABASE_URL)
|
||||
self.async_session = async_sessionmaker(bind=self.engine)
|
||||
|
||||
async def execute_raw_query(
|
||||
self, query: str, params: Dict[str, Any] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a raw SQL query and return results as a list of dictionaries.
|
||||
|
||||
Args:
|
||||
query: The SQL query to execute
|
||||
params: Optional dictionary of query parameters
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing the query results
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(text(query), params or {})
|
||||
rows = result.fetchall()
|
||||
if rows:
|
||||
# Convert rows to dictionaries
|
||||
columns = result.keys()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
return []
|
||||
379
api/db/campaign_client.py
Normal file
379
api/db/campaign_client.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel
|
||||
|
||||
|
||||
class CampaignClient(BaseDBClient):
|
||||
async def create_campaign(
|
||||
self,
|
||||
name: str,
|
||||
workflow_id: int,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
user_id: int,
|
||||
organization_id: int,
|
||||
) -> CampaignModel:
|
||||
"""Create a new campaign"""
|
||||
async with self.async_session() as session:
|
||||
campaign = CampaignModel(
|
||||
name=name,
|
||||
workflow_id=workflow_id,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
created_by=user_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
session.add(campaign)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(campaign)
|
||||
return campaign
|
||||
|
||||
async def get_campaigns(
|
||||
self,
|
||||
organization_id: int,
|
||||
) -> list[CampaignModel]:
|
||||
"""Get all campaigns for organization"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(CampaignModel)
|
||||
.where(CampaignModel.organization_id == organization_id)
|
||||
.order_by(CampaignModel.created_at.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_campaign(
|
||||
self,
|
||||
campaign_id: int,
|
||||
organization_id: int,
|
||||
) -> Optional[CampaignModel]:
|
||||
"""Get single campaign by ID, ensuring organization access"""
|
||||
async with self.async_session() as session:
|
||||
query = select(CampaignModel).where(
|
||||
CampaignModel.id == campaign_id,
|
||||
CampaignModel.organization_id == organization_id,
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_campaign_state(
|
||||
self,
|
||||
campaign_id: int,
|
||||
state: str,
|
||||
organization_id: int,
|
||||
) -> CampaignModel:
|
||||
"""Update campaign state (start/pause/resume)"""
|
||||
async with self.async_session() as session:
|
||||
query = select(CampaignModel).where(
|
||||
CampaignModel.id == campaign_id,
|
||||
CampaignModel.organization_id == organization_id,
|
||||
)
|
||||
result = await session.execute(query)
|
||||
campaign = result.scalar_one_or_none()
|
||||
|
||||
if not campaign:
|
||||
raise ValueError(f"Campaign {campaign_id} not found")
|
||||
|
||||
campaign.state = state
|
||||
if state == "running" and not campaign.started_at:
|
||||
campaign.started_at = datetime.now(UTC)
|
||||
elif state in ["completed", "failed"]:
|
||||
campaign.completed_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(campaign)
|
||||
return campaign
|
||||
|
||||
async def update_campaign_progress(
|
||||
self,
|
||||
campaign_id: int,
|
||||
processed_rows: int,
|
||||
failed_rows: int,
|
||||
organization_id: int,
|
||||
) -> None:
|
||||
"""Update campaign progress counters"""
|
||||
async with self.async_session() as session:
|
||||
query = select(CampaignModel).where(
|
||||
CampaignModel.id == campaign_id,
|
||||
CampaignModel.organization_id == organization_id,
|
||||
)
|
||||
result = await session.execute(query)
|
||||
campaign = result.scalar_one_or_none()
|
||||
|
||||
if not campaign:
|
||||
raise ValueError(f"Campaign {campaign_id} not found")
|
||||
|
||||
campaign.processed_rows = processed_rows
|
||||
campaign.failed_rows = failed_rows
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
|
||||
async def get_campaign_runs(
|
||||
self,
|
||||
campaign_id: int,
|
||||
organization_id: int,
|
||||
) -> list[WorkflowRunModel]:
|
||||
"""Get workflow runs for a campaign"""
|
||||
async with self.async_session() as session:
|
||||
# First verify campaign belongs to organization
|
||||
campaign_query = select(CampaignModel).where(
|
||||
CampaignModel.id == campaign_id,
|
||||
CampaignModel.organization_id == organization_id,
|
||||
)
|
||||
campaign_result = await session.execute(campaign_query)
|
||||
campaign = campaign_result.scalar_one_or_none()
|
||||
|
||||
if not campaign:
|
||||
raise ValueError(f"Campaign {campaign_id} not found")
|
||||
|
||||
query = (
|
||||
select(WorkflowRunModel)
|
||||
.where(WorkflowRunModel.campaign_id == campaign_id)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_campaign_by_id(self, campaign_id: int) -> Optional[CampaignModel]:
|
||||
"""Get campaign by ID without organization check (for internal use)"""
|
||||
async with self.async_session() as session:
|
||||
query = select(CampaignModel).where(CampaignModel.id == campaign_id)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_campaign(self, campaign_id: int, **kwargs) -> CampaignModel:
|
||||
"""Update campaign with arbitrary fields"""
|
||||
async with self.async_session() as session:
|
||||
query = select(CampaignModel).where(CampaignModel.id == campaign_id)
|
||||
result = await session.execute(query)
|
||||
campaign = result.scalar_one_or_none()
|
||||
|
||||
if not campaign:
|
||||
raise ValueError(f"Campaign {campaign_id} not found")
|
||||
|
||||
# Update fields
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(campaign, key):
|
||||
setattr(campaign, key, value)
|
||||
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(campaign)
|
||||
return campaign
|
||||
|
||||
# QueuedRun methods
|
||||
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
|
||||
"""Bulk create queued runs"""
|
||||
async with self.async_session() as session:
|
||||
queued_runs = [QueuedRunModel(**data) for data in queued_runs_data]
|
||||
session.add_all(queued_runs)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
|
||||
async def get_queued_runs(
|
||||
self,
|
||||
campaign_id: int,
|
||||
state: str = "queued",
|
||||
limit: int = 10,
|
||||
scheduled_for: Optional[bool] = None,
|
||||
) -> list[QueuedRunModel]:
|
||||
"""Get queued runs for processing, optionally filtering by scheduled status"""
|
||||
async with self.async_session() as session:
|
||||
query = select(QueuedRunModel).where(
|
||||
QueuedRunModel.campaign_id == campaign_id,
|
||||
QueuedRunModel.state == state,
|
||||
)
|
||||
|
||||
# Filter by scheduled status if specified
|
||||
if scheduled_for is True:
|
||||
query = query.where(QueuedRunModel.scheduled_for.isnot(None))
|
||||
elif scheduled_for is False:
|
||||
query = query.where(QueuedRunModel.scheduled_for.is_(None))
|
||||
|
||||
query = query.order_by(QueuedRunModel.created_at).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_queued_run(self, queued_run_id: int, **kwargs) -> QueuedRunModel:
|
||||
"""Update queued run"""
|
||||
async with self.async_session() as session:
|
||||
query = select(QueuedRunModel).where(QueuedRunModel.id == queued_run_id)
|
||||
result = await session.execute(query)
|
||||
queued_run = result.scalar_one_or_none()
|
||||
|
||||
if not queued_run:
|
||||
raise ValueError(f"QueuedRun {queued_run_id} not found")
|
||||
|
||||
# Update fields
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(queued_run, key):
|
||||
setattr(queued_run, key, value)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(queued_run)
|
||||
return queued_run
|
||||
|
||||
async def count_queued_runs(
|
||||
self, campaign_id: int, state: Optional[str] = None
|
||||
) -> int:
|
||||
"""Count queued runs, optionally filtered by state"""
|
||||
async with self.async_session() as session:
|
||||
query = select(func.count(QueuedRunModel.id)).where(
|
||||
QueuedRunModel.campaign_id == campaign_id
|
||||
)
|
||||
if state:
|
||||
query = query.where(QueuedRunModel.state == state)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_workflow_runs_by_campaign(
|
||||
self, campaign_id: int
|
||||
) -> list[WorkflowRunModel]:
|
||||
"""Get all workflow runs for a campaign (internal use)"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowRunModel)
|
||||
.where(WorkflowRunModel.campaign_id == campaign_id)
|
||||
.order_by(WorkflowRunModel.created_at)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# New methods for retry support
|
||||
async def get_scheduled_queued_runs(
|
||||
self, campaign_id: int, scheduled_before: datetime, limit: int = 10
|
||||
) -> list[QueuedRunModel]:
|
||||
"""Get scheduled queued runs that are due for processing"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(QueuedRunModel)
|
||||
.where(
|
||||
QueuedRunModel.campaign_id == campaign_id,
|
||||
QueuedRunModel.state == "queued",
|
||||
QueuedRunModel.scheduled_for.isnot(None),
|
||||
QueuedRunModel.scheduled_for <= scheduled_before,
|
||||
)
|
||||
.order_by(QueuedRunModel.scheduled_for)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create_queued_run(
|
||||
self,
|
||||
campaign_id: int,
|
||||
source_uuid: str,
|
||||
context_variables: dict,
|
||||
state: str = "queued",
|
||||
retry_count: int = 0,
|
||||
parent_queued_run_id: Optional[int] = None,
|
||||
scheduled_for: Optional[datetime] = None,
|
||||
retry_reason: Optional[str] = None,
|
||||
) -> QueuedRunModel:
|
||||
"""Create a single queued run with retry support"""
|
||||
async with self.async_session() as session:
|
||||
queued_run = QueuedRunModel(
|
||||
campaign_id=campaign_id,
|
||||
source_uuid=source_uuid,
|
||||
context_variables=context_variables,
|
||||
state=state,
|
||||
retry_count=retry_count,
|
||||
parent_queued_run_id=parent_queued_run_id,
|
||||
scheduled_for=scheduled_for,
|
||||
retry_reason=retry_reason,
|
||||
)
|
||||
session.add(queued_run)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(queued_run)
|
||||
return queued_run
|
||||
|
||||
async def get_queued_run_by_id(
|
||||
self, queued_run_id: int
|
||||
) -> Optional[QueuedRunModel]:
|
||||
"""Get a queued run by ID"""
|
||||
async with self.async_session() as session:
|
||||
query = select(QueuedRunModel).where(QueuedRunModel.id == queued_run_id)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_campaigns_by_status(self, statuses: list[str]) -> list[CampaignModel]:
|
||||
"""Get campaigns by status"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(CampaignModel)
|
||||
.where(CampaignModel.state.in_(statuses))
|
||||
.order_by(CampaignModel.created_at.desc())
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_queued_runs_count(self, campaign_id: int, states: list[str]) -> int:
|
||||
"""Get count of queued runs for a campaign in specified states"""
|
||||
async with self.async_session() as session:
|
||||
query = select(func.count(QueuedRunModel.id)).where(
|
||||
QueuedRunModel.campaign_id == campaign_id,
|
||||
QueuedRunModel.state.in_(states),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_scheduled_runs_count(
|
||||
self,
|
||||
campaign_id: int,
|
||||
scheduled_before: Optional[datetime] = None,
|
||||
scheduled_after: Optional[datetime] = None,
|
||||
) -> int:
|
||||
"""Get count of scheduled runs for a campaign"""
|
||||
async with self.async_session() as session:
|
||||
conditions = [
|
||||
QueuedRunModel.campaign_id == campaign_id,
|
||||
QueuedRunModel.scheduled_for.isnot(None),
|
||||
QueuedRunModel.state == "queued",
|
||||
]
|
||||
|
||||
if scheduled_before:
|
||||
conditions.append(QueuedRunModel.scheduled_for <= scheduled_before)
|
||||
if scheduled_after:
|
||||
conditions.append(QueuedRunModel.scheduled_for > scheduled_after)
|
||||
|
||||
query = select(func.count(QueuedRunModel.id)).where(*conditions)
|
||||
result = await session.execute(query)
|
||||
return result.scalar() or 0
|
||||
9
api/db/database.py
Normal file
9
api/db/database.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import os
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
async_session = sessionmaker(engine)
|
||||
47
api/db/db_client.py
Normal file
47
api/db/db_client.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from api.db.api_key_client import APIKeyClient
|
||||
from api.db.campaign_client import CampaignClient
|
||||
from api.db.integration_client import IntegrationClient
|
||||
from api.db.looptalk_client import LoopTalkClient
|
||||
from api.db.organization_client import OrganizationClient
|
||||
from api.db.organization_configuration_client import OrganizationConfigurationClient
|
||||
from api.db.organization_usage_client import OrganizationUsageClient
|
||||
from api.db.reports_client import ReportsClient
|
||||
from api.db.user_client import UserClient
|
||||
from api.db.workflow_client import WorkflowClient
|
||||
from api.db.workflow_run_client import WorkflowRunClient
|
||||
from api.db.workflow_template_client import WorkflowTemplateClient
|
||||
|
||||
|
||||
class DBClient(
|
||||
WorkflowClient,
|
||||
WorkflowRunClient,
|
||||
UserClient,
|
||||
OrganizationClient,
|
||||
OrganizationConfigurationClient,
|
||||
OrganizationUsageClient,
|
||||
IntegrationClient,
|
||||
WorkflowTemplateClient,
|
||||
LoopTalkClient,
|
||||
CampaignClient,
|
||||
ReportsClient,
|
||||
APIKeyClient,
|
||||
):
|
||||
"""
|
||||
Unified database client that combines all specialized database operations.
|
||||
|
||||
This client inherits from:
|
||||
- WorkflowClient: handles workflow and workflow definition operations
|
||||
- WorkflowRunClient: handles workflow run operations
|
||||
- UserClient: handles user and user configuration operations
|
||||
- OrganizationClient: handles organization operations
|
||||
- OrganizationConfigurationClient: handles organization configuration operations
|
||||
- OrganizationUsageClient: handles organization usage and quota operations
|
||||
- IntegrationClient: handles integration operations
|
||||
- WorkflowTemplateClient: handles workflow template operations
|
||||
- LoopTalkClient: handles LoopTalk testing operations
|
||||
- CampaignClient: handles campaign operations
|
||||
- ReportsClient: handles reports and analytics operations
|
||||
- APIKeyClient: handles API key operations
|
||||
"""
|
||||
|
||||
pass
|
||||
198
api/db/filters.py
Normal file
198
api/db/filters.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Common filter utilities for database queries."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Integer, and_, cast
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from api.db.models import WorkflowRunModel
|
||||
|
||||
# Mapping of attribute names to database fields
|
||||
ATTRIBUTE_FIELD_MAPPING = {
|
||||
"dateRange": "created_at",
|
||||
"dispositionCode": "gathered_context.mapped_call_disposition",
|
||||
"duration": "usage_info.call_duration_seconds",
|
||||
"status": "is_completed",
|
||||
"tokenUsage": "cost_info.total_cost_usd",
|
||||
"runId": "id",
|
||||
"workflowId": "workflow_id",
|
||||
"callTags": "gathered_context.call_tags",
|
||||
"phoneNumber": "initial_context.phone",
|
||||
}
|
||||
|
||||
|
||||
def apply_workflow_run_filters(
|
||||
base_query,
|
||||
filters: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
"""
|
||||
Apply filters to a workflow run query.
|
||||
|
||||
Supports filtering by:
|
||||
- dateRange: Filter by created_at date range
|
||||
- dispositionCode: Filter by gathered_context.mapped_call_disposition
|
||||
- duration: Filter by usage_info.call_duration_seconds range
|
||||
- status: Filter by is_completed status
|
||||
- tokenUsage: Filter by cost_info.total_cost_usd range
|
||||
- runId: Filter by workflow run ID (exact match)
|
||||
- workflowId: Filter by workflow ID (exact match)
|
||||
- callTags: Filter by gathered_context.call_tags (array of strings)
|
||||
- phoneNumber: Filter by initial_context.phone (text search)
|
||||
|
||||
Args:
|
||||
base_query: The base SQLAlchemy query to apply filters to
|
||||
filters: List of filter dictionaries with structure:
|
||||
{"attribute": "filterName", "type": "filterType", "value": {...}}
|
||||
|
||||
Where type is one of:
|
||||
- "dateRange": Date range filter with {"from": ..., "to": ...}
|
||||
- "multiSelect": Multi-select filter with {"codes": [...]}
|
||||
- "numberRange": Number range filter with {"min": ..., "max": ...}
|
||||
- "number": Exact number filter with {"value": number}
|
||||
- "text": Text search filter with {"value": string}
|
||||
- "radio": Radio/status filter with {"status": ...}
|
||||
- "tags": Tags filter with {"codes": [...]}
|
||||
|
||||
Returns:
|
||||
The query with filters applied
|
||||
"""
|
||||
|
||||
if not filters:
|
||||
return base_query
|
||||
|
||||
filter_conditions = []
|
||||
|
||||
for filter_item in filters:
|
||||
attribute = filter_item.get("attribute")
|
||||
filter_type = filter_item.get("type")
|
||||
value = filter_item.get("value", {})
|
||||
|
||||
# Resolve field from attribute mapping
|
||||
field = ATTRIBUTE_FIELD_MAPPING.get(attribute)
|
||||
if not field:
|
||||
# Skip unknown attributes
|
||||
continue
|
||||
|
||||
# Apply the filter based on provided type
|
||||
if field and filter_type:
|
||||
if filter_type == "number" and field == "id":
|
||||
# Filter by exact workflow run ID
|
||||
if value.get("value") is not None:
|
||||
filter_conditions.append(WorkflowRunModel.id == value["value"])
|
||||
|
||||
elif filter_type == "number" and field == "workflow_id":
|
||||
# Filter by exact workflow ID
|
||||
if value.get("value") is not None:
|
||||
filter_conditions.append(
|
||||
WorkflowRunModel.workflow_id == value["value"]
|
||||
)
|
||||
|
||||
elif filter_type == "dateRange" and field == "created_at":
|
||||
# Same as attribute-based dateRange
|
||||
if value.get("from"):
|
||||
filter_conditions.append(
|
||||
WorkflowRunModel.created_at
|
||||
>= datetime.fromisoformat(value["from"])
|
||||
)
|
||||
if value.get("to"):
|
||||
filter_conditions.append(
|
||||
WorkflowRunModel.created_at
|
||||
<= datetime.fromisoformat(value["to"])
|
||||
)
|
||||
|
||||
elif (
|
||||
filter_type == "multiSelect"
|
||||
and field == "gathered_context.mapped_call_disposition"
|
||||
):
|
||||
codes = value.get("codes", [])
|
||||
if codes:
|
||||
filter_conditions.append(
|
||||
cast(WorkflowRunModel.gathered_context, JSONB)[
|
||||
"mapped_call_disposition"
|
||||
]
|
||||
.as_string()
|
||||
.in_(codes)
|
||||
)
|
||||
|
||||
elif filter_type == "radio" and field == "is_completed":
|
||||
status = value.get("status")
|
||||
if status == "completed":
|
||||
filter_conditions.append(WorkflowRunModel.is_completed == True)
|
||||
elif status == "in_progress":
|
||||
filter_conditions.append(WorkflowRunModel.is_completed == False)
|
||||
|
||||
elif (
|
||||
filter_type in ("tags", "multiSelect")
|
||||
and field == "gathered_context.call_tags"
|
||||
):
|
||||
tags = value.get("codes", [])
|
||||
if tags:
|
||||
filter_conditions.append(
|
||||
cast(WorkflowRunModel.gathered_context, JSONB)[
|
||||
"call_tags"
|
||||
].contains(tags)
|
||||
)
|
||||
|
||||
elif filter_type == "text" and field == "initial_context.phone":
|
||||
# Filter by phone number (contains search)
|
||||
phone = value.get("value", "").strip()
|
||||
if phone:
|
||||
filter_conditions.append(
|
||||
cast(WorkflowRunModel.initial_context, JSONB)["phone"]
|
||||
.as_string()
|
||||
.contains(phone)
|
||||
)
|
||||
|
||||
elif filter_type == "numberRange":
|
||||
min_val = value.get("min")
|
||||
max_val = value.get("max")
|
||||
|
||||
if field == "usage_info.call_duration_seconds":
|
||||
if min_val is not None:
|
||||
filter_conditions.append(
|
||||
cast(
|
||||
cast(WorkflowRunModel.usage_info, JSONB)[
|
||||
"call_duration_seconds"
|
||||
],
|
||||
Integer,
|
||||
)
|
||||
>= min_val
|
||||
)
|
||||
if max_val is not None:
|
||||
filter_conditions.append(
|
||||
cast(
|
||||
cast(WorkflowRunModel.usage_info, JSONB)[
|
||||
"call_duration_seconds"
|
||||
],
|
||||
Integer,
|
||||
)
|
||||
<= max_val
|
||||
)
|
||||
|
||||
elif field == "cost_info.total_cost_usd":
|
||||
if min_val is not None:
|
||||
filter_conditions.append(
|
||||
cast(
|
||||
cast(WorkflowRunModel.cost_info, JSONB)[
|
||||
"total_cost_usd"
|
||||
],
|
||||
Integer,
|
||||
)
|
||||
>= min_val
|
||||
)
|
||||
if max_val is not None:
|
||||
filter_conditions.append(
|
||||
cast(
|
||||
cast(WorkflowRunModel.cost_info, JSONB)[
|
||||
"total_cost_usd"
|
||||
],
|
||||
Integer,
|
||||
)
|
||||
<= max_val
|
||||
)
|
||||
|
||||
if filter_conditions:
|
||||
base_query = base_query.where(and_(*filter_conditions))
|
||||
|
||||
return base_query
|
||||
103
api/db/integration_client.py
Normal file
103
api/db/integration_client.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from typing import List
|
||||
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import IntegrationModel
|
||||
|
||||
|
||||
class IntegrationClient(BaseDBClient):
|
||||
async def get_integrations_by_organization_id(
|
||||
self, organization_id: int
|
||||
) -> list[IntegrationModel]:
|
||||
"""Get all integrations for a specific organization."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(IntegrationModel).where(
|
||||
IntegrationModel.organisation_id == organization_id
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def create_integration(
|
||||
self,
|
||||
integration_id: str,
|
||||
provider: str,
|
||||
organisation_id: int,
|
||||
connection_details: dict,
|
||||
created_by: int = None,
|
||||
is_active: bool = True,
|
||||
) -> IntegrationModel:
|
||||
"""Create a new integration for an organization."""
|
||||
async with self.async_session() as session:
|
||||
new_integration = IntegrationModel(
|
||||
integration_id=integration_id,
|
||||
organisation_id=organisation_id,
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
provider=provider,
|
||||
connection_details=connection_details,
|
||||
)
|
||||
session.add(new_integration)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(new_integration)
|
||||
return new_integration
|
||||
|
||||
async def update_integration_status(
|
||||
self, integration_id: int, is_active: bool
|
||||
) -> IntegrationModel | None:
|
||||
"""Update the active status of an integration."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(IntegrationModel).where(IntegrationModel.id == integration_id)
|
||||
)
|
||||
integration = result.scalars().first()
|
||||
if not integration:
|
||||
return None
|
||||
|
||||
integration.is_active = is_active
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(integration)
|
||||
return integration
|
||||
|
||||
async def update_integration_connection_details(
|
||||
self, integration_id: int, connection_details: dict
|
||||
) -> IntegrationModel | None:
|
||||
"""Update the connection details of an integration."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(IntegrationModel).where(IntegrationModel.id == integration_id)
|
||||
)
|
||||
integration = result.scalars().first()
|
||||
if not integration:
|
||||
return None
|
||||
|
||||
integration.connection_details = connection_details
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(integration)
|
||||
return integration
|
||||
|
||||
async def get_active_integrations_by_organization(
|
||||
self, organization_id: int
|
||||
) -> List[IntegrationModel]:
|
||||
"""Get all active integrations for a specific organization."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(IntegrationModel).where(
|
||||
IntegrationModel.organisation_id == organization_id,
|
||||
IntegrationModel.is_active == True,
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
259
api/db/looptalk_client.py
Normal file
259
api/db/looptalk_client.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import (
|
||||
LoopTalkConversation,
|
||||
LoopTalkTestSession,
|
||||
)
|
||||
|
||||
|
||||
class LoopTalkClient(BaseDBClient):
|
||||
"""Database client for LoopTalk testing operations."""
|
||||
|
||||
async def create_test_session(
|
||||
self,
|
||||
organization_id: int,
|
||||
name: str,
|
||||
actor_workflow_id: int,
|
||||
adversary_workflow_id: int,
|
||||
config: Dict[str, Any],
|
||||
load_test_group_id: Optional[str] = None,
|
||||
test_index: Optional[int] = None,
|
||||
) -> LoopTalkTestSession:
|
||||
"""Create a new LoopTalk test session."""
|
||||
async with self.async_session() as session:
|
||||
test_session = LoopTalkTestSession(
|
||||
organization_id=organization_id,
|
||||
name=name,
|
||||
actor_workflow_id=actor_workflow_id,
|
||||
adversary_workflow_id=adversary_workflow_id,
|
||||
config=config,
|
||||
load_test_group_id=load_test_group_id,
|
||||
test_index=test_index,
|
||||
status="pending",
|
||||
)
|
||||
session.add(test_session)
|
||||
await session.commit()
|
||||
await session.refresh(test_session)
|
||||
return test_session
|
||||
|
||||
async def get_test_session(
|
||||
self, test_session_id: int, organization_id: int
|
||||
) -> Optional[LoopTalkTestSession]:
|
||||
"""Get a test session by ID."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LoopTalkTestSession)
|
||||
.options(
|
||||
selectinload(LoopTalkTestSession.actor_workflow),
|
||||
selectinload(LoopTalkTestSession.adversary_workflow),
|
||||
selectinload(LoopTalkTestSession.conversations),
|
||||
)
|
||||
.where(
|
||||
LoopTalkTestSession.id == test_session_id,
|
||||
LoopTalkTestSession.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_test_sessions(
|
||||
self,
|
||||
organization_id: int,
|
||||
status: Optional[str] = None,
|
||||
load_test_group_id: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[LoopTalkTestSession]:
|
||||
"""List test sessions with optional filtering."""
|
||||
async with self.async_session() as session:
|
||||
query = select(LoopTalkTestSession).where(
|
||||
LoopTalkTestSession.organization_id == organization_id
|
||||
)
|
||||
|
||||
if status:
|
||||
# "active" is a virtual status used by the UI to represent
|
||||
# both "pending" and "running" sessions. Translate it into
|
||||
# the real enum values stored in the database to avoid
|
||||
# invalid enum casting errors (e.g. asyncpg InvalidTextRepresentationError).
|
||||
if status == "active":
|
||||
query = query.where(
|
||||
LoopTalkTestSession.status.in_(["pending", "running"])
|
||||
)
|
||||
else:
|
||||
query = query.where(LoopTalkTestSession.status == status)
|
||||
|
||||
if load_test_group_id:
|
||||
query = query.where(
|
||||
LoopTalkTestSession.load_test_group_id == load_test_group_id
|
||||
)
|
||||
|
||||
query = (
|
||||
query.order_by(LoopTalkTestSession.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def update_test_session_status(
|
||||
self,
|
||||
test_session_id: int,
|
||||
status: str,
|
||||
error: Optional[str] = None,
|
||||
results: Optional[Dict[str, Any]] = None,
|
||||
) -> LoopTalkTestSession:
|
||||
"""Update test session status and related fields."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LoopTalkTestSession).where(
|
||||
LoopTalkTestSession.id == test_session_id
|
||||
)
|
||||
)
|
||||
test_session = result.scalar_one()
|
||||
|
||||
test_session.status = status
|
||||
|
||||
if status == "running":
|
||||
test_session.started_at = datetime.now(UTC)
|
||||
elif status in ["completed", "failed"]:
|
||||
test_session.completed_at = datetime.now(UTC)
|
||||
|
||||
if error:
|
||||
test_session.error = error
|
||||
|
||||
if results:
|
||||
test_session.results = results
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(test_session)
|
||||
return test_session
|
||||
|
||||
async def create_conversation(self, test_session_id: int) -> LoopTalkConversation:
|
||||
"""Create a new conversation for a test session."""
|
||||
async with self.async_session() as session:
|
||||
conversation = LoopTalkConversation(test_session_id=test_session_id)
|
||||
session.add(conversation)
|
||||
await session.commit()
|
||||
await session.refresh(conversation)
|
||||
return conversation
|
||||
|
||||
async def update_conversation(
|
||||
self,
|
||||
conversation_id: int,
|
||||
duration_seconds: Optional[int] = None,
|
||||
actor_recording_url: Optional[str] = None,
|
||||
adversary_recording_url: Optional[str] = None,
|
||||
combined_recording_url: Optional[str] = None,
|
||||
transcript: Optional[Dict[str, Any]] = None,
|
||||
metrics: Optional[Dict[str, Any]] = None,
|
||||
ended_at: Optional[datetime] = None,
|
||||
) -> LoopTalkConversation:
|
||||
"""Update conversation details."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LoopTalkConversation).where(
|
||||
LoopTalkConversation.id == conversation_id
|
||||
)
|
||||
)
|
||||
conversation = result.scalar_one()
|
||||
|
||||
if duration_seconds is not None:
|
||||
conversation.duration_seconds = duration_seconds
|
||||
if actor_recording_url:
|
||||
conversation.actor_recording_url = actor_recording_url
|
||||
if adversary_recording_url:
|
||||
conversation.adversary_recording_url = adversary_recording_url
|
||||
if combined_recording_url:
|
||||
conversation.combined_recording_url = combined_recording_url
|
||||
if transcript:
|
||||
conversation.transcript = transcript
|
||||
if metrics:
|
||||
conversation.metrics = metrics
|
||||
if ended_at:
|
||||
conversation.ended_at = ended_at
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(conversation)
|
||||
return conversation
|
||||
|
||||
# Note: Turn tracking is handled by Langfuse, not stored in our database
|
||||
|
||||
async def create_load_test_group(
|
||||
self,
|
||||
organization_id: int,
|
||||
name_prefix: str,
|
||||
actor_workflow_id: int,
|
||||
adversary_workflow_id: int,
|
||||
config: Dict[str, Any],
|
||||
test_count: int,
|
||||
) -> List[LoopTalkTestSession]:
|
||||
"""Create multiple test sessions for load testing."""
|
||||
load_test_group_id = str(uuid4())
|
||||
test_sessions = []
|
||||
|
||||
async with self.async_session() as session:
|
||||
for i in range(test_count):
|
||||
test_session = LoopTalkTestSession(
|
||||
organization_id=organization_id,
|
||||
name=f"{name_prefix} - Test {i + 1}",
|
||||
actor_workflow_id=actor_workflow_id,
|
||||
adversary_workflow_id=adversary_workflow_id,
|
||||
config=config,
|
||||
load_test_group_id=load_test_group_id,
|
||||
test_index=i,
|
||||
status="pending",
|
||||
)
|
||||
session.add(test_session)
|
||||
test_sessions.append(test_session)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Refresh all sessions
|
||||
for test_session in test_sessions:
|
||||
await session.refresh(test_session)
|
||||
|
||||
return test_sessions
|
||||
|
||||
async def get_load_test_group_stats(
|
||||
self, load_test_group_id: str, organization_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Get statistics for a load test group."""
|
||||
async with self.async_session() as session:
|
||||
# Get all sessions in the group
|
||||
result = await session.execute(
|
||||
select(LoopTalkTestSession).where(
|
||||
LoopTalkTestSession.load_test_group_id == load_test_group_id,
|
||||
LoopTalkTestSession.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
sessions = result.scalars().all()
|
||||
|
||||
# Calculate stats
|
||||
stats = {
|
||||
"total": len(sessions),
|
||||
"pending": sum(1 for s in sessions if s.status == "pending"),
|
||||
"running": sum(1 for s in sessions if s.status == "running"),
|
||||
"completed": sum(1 for s in sessions if s.status == "completed"),
|
||||
"failed": sum(1 for s in sessions if s.status == "failed"),
|
||||
"sessions": [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"status": s.status,
|
||||
"test_index": s.test_index,
|
||||
"created_at": s.created_at,
|
||||
"started_at": s.started_at,
|
||||
"completed_at": s.completed_at,
|
||||
"error": s.error,
|
||||
}
|
||||
for s in sessions
|
||||
],
|
||||
}
|
||||
|
||||
return stats
|
||||
608
api/db/models.py
Normal file
608
api/db/models.py
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from ..enums import IntegrationAction, WorkflowRunMode, WorkflowStatus
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# TODO: remove workflow_defintion after migration, remove nullable workflow_defintion_id from Workflow and Workflowrun
|
||||
|
||||
|
||||
# Association table for many-to-many relationship between users and organizations
|
||||
organization_users_association = Table(
|
||||
"organization_users",
|
||||
Base.metadata,
|
||||
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
|
||||
Column(
|
||||
"organization_id", Integer, ForeignKey("organizations.id"), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserModel(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
provider_id = Column(String, unique=True, index=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
workflows = relationship("WorkflowModel", back_populates="user")
|
||||
selected_organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id"), nullable=True
|
||||
)
|
||||
selected_organization = relationship("OrganizationModel", back_populates="users")
|
||||
organizations = relationship(
|
||||
"OrganizationModel",
|
||||
secondary=organization_users_association,
|
||||
back_populates="users",
|
||||
)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class UserConfigurationModel(Base):
|
||||
__tablename__ = "user_configurations"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
configuration = Column(JSON, nullable=False, default=dict)
|
||||
last_validated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
# New Organization model
|
||||
class OrganizationModel(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
provider_id = Column(String, unique=True, index=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
# Quota fields
|
||||
quota_type = Column(
|
||||
Enum("monthly", "annual", name="quota_type"),
|
||||
nullable=False,
|
||||
default="monthly",
|
||||
server_default=text("'monthly'::quota_type"),
|
||||
)
|
||||
quota_dograh_tokens = Column(
|
||||
Integer, nullable=False, default=0, server_default=text("0")
|
||||
)
|
||||
quota_reset_day = Column(
|
||||
Integer, nullable=False, default=1, server_default=text("1")
|
||||
) # 1-28, only for monthly
|
||||
quota_start_date = Column(DateTime(timezone=True), nullable=True) # Only for annual
|
||||
quota_enabled = Column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false")
|
||||
)
|
||||
|
||||
price_per_second_usd = Column(Float, nullable=True)
|
||||
|
||||
# Relationships
|
||||
users = relationship(
|
||||
"UserModel",
|
||||
secondary=organization_users_association,
|
||||
back_populates="organizations",
|
||||
)
|
||||
integrations = relationship("IntegrationModel", back_populates="organization")
|
||||
usage_cycles = relationship(
|
||||
"OrganizationUsageCycleModel", back_populates="organization"
|
||||
)
|
||||
configurations = relationship(
|
||||
"OrganizationConfigurationModel", back_populates="organization"
|
||||
)
|
||||
api_keys = relationship("APIKeyModel", back_populates="organization")
|
||||
|
||||
|
||||
class APIKeyModel(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name = Column(String, nullable=False)
|
||||
key_hash = Column(String, nullable=False, unique=True, index=True)
|
||||
key_prefix = Column(String, nullable=False) # Store first 8 chars for display
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
archived_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel", back_populates="api_keys")
|
||||
created_by_user = relationship("UserModel")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index("ix_api_keys_organization_id", "organization_id"),
|
||||
Index("ix_api_keys_key_hash", "key_hash"),
|
||||
Index("ix_api_keys_active", "is_active"),
|
||||
)
|
||||
|
||||
|
||||
class OrganizationConfigurationModel(Base):
|
||||
__tablename__ = "organization_configurations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
key = Column(String, nullable=False)
|
||||
value = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel", back_populates="configurations")
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint("organization_id", "key", name="_organization_key_uc"),
|
||||
Index("ix_organization_configurations_organization_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class IntegrationModel(Base):
|
||||
__tablename__ = "integrations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
integration_id = Column(String, nullable=False, index=True) # Nango Connection ID
|
||||
organisation_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||
provider = Column(String, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
connection_details = Column(JSON, nullable=False, default=dict)
|
||||
action = Column(String, nullable=False, default=IntegrationAction.ALL_CALLS.value)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel", back_populates="integrations")
|
||||
|
||||
|
||||
class WorkflowDefinitionModel(Base):
|
||||
__tablename__ = "workflow_definitions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
workflow_hash = Column(String, nullable=False)
|
||||
workflow_json = Column(JSON, nullable=False, default=dict)
|
||||
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=True)
|
||||
is_current = Column(
|
||||
Boolean, default=False, nullable=False, server_default=text("false")
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
# Table constraints and indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"workflow_hash", "workflow_id", name="uq_workflow_hash_workflow_id"
|
||||
),
|
||||
Index("ix_workflow_hash_workflow_id", "workflow_hash", "workflow_id"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
workflow = relationship(
|
||||
"WorkflowModel",
|
||||
back_populates="definitions",
|
||||
foreign_keys=[workflow_id],
|
||||
)
|
||||
workflow_runs = relationship("WorkflowRunModel", back_populates="definition")
|
||||
|
||||
|
||||
class WorkflowModel(Base):
|
||||
__tablename__ = "workflows"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
user = relationship("UserModel", back_populates="workflows")
|
||||
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
|
||||
organization = relationship("OrganizationModel")
|
||||
name = Column(String, index=True, nullable=False)
|
||||
status = Column(
|
||||
Enum(*[status.value for status in WorkflowStatus], name="workflow_status"),
|
||||
nullable=False,
|
||||
default=WorkflowStatus.ACTIVE.value,
|
||||
server_default=text("'active'::workflow_status"),
|
||||
)
|
||||
workflow_definition = Column(JSON, nullable=False, default=dict)
|
||||
template_context_variables = Column(JSON, nullable=False, default=dict)
|
||||
call_disposition_codes = Column(JSON, nullable=False, default=dict)
|
||||
workflow_configurations = Column(
|
||||
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
||||
)
|
||||
runs = relationship("WorkflowRunModel", back_populates="workflow")
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
# All versions / historical definitions of this workflow
|
||||
definitions = relationship(
|
||||
"WorkflowDefinitionModel",
|
||||
back_populates="workflow",
|
||||
foreign_keys="WorkflowDefinitionModel.workflow_id",
|
||||
)
|
||||
|
||||
# Relationship to fetch the current (is_current=True) definition
|
||||
current_definition = relationship(
|
||||
"WorkflowDefinitionModel",
|
||||
primaryjoin=lambda: and_(
|
||||
WorkflowDefinitionModel.workflow_id == WorkflowModel.id,
|
||||
WorkflowDefinitionModel.is_current.is_(True),
|
||||
),
|
||||
uselist=False,
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def current_definition_id(self):
|
||||
"""Return ID of the current workflow definition (helper for backwards-compat)."""
|
||||
current_def = self.__dict__.get("current_definition")
|
||||
if current_def is not None:
|
||||
return current_def.id
|
||||
|
||||
# If relationship is not loaded, we cannot safely access definitions without
|
||||
# risking an implicit lazy load on a detached instance. Return ``None`` in
|
||||
# that scenario so callers can handle the absence explicitly.
|
||||
return None
|
||||
|
||||
@property
|
||||
def workflow_definition_with_fallback(self):
|
||||
"""
|
||||
Get workflow definition with fallback to legacy workflow_definition field.
|
||||
|
||||
Returns:
|
||||
dict: The workflow definition JSON
|
||||
"""
|
||||
# Access the relationship only if it has ALREADY been eagerly loaded on this
|
||||
# instance to avoid triggering an implicit lazy load once the SQLAlchemy
|
||||
# Session has been closed (which would raise a DetachedInstanceError).
|
||||
|
||||
# ``__dict__`` will contain "current_definition" **only** when the attribute
|
||||
# has been populated (e.g. via `selectinload` or an explicit access while
|
||||
# the session was still open). Using ``__dict__.get`` guarantees that we
|
||||
# do not accidentally issue a lazy load query on a detached instance.
|
||||
|
||||
current_definition = self.__dict__.get("current_definition")
|
||||
|
||||
if current_definition is not None:
|
||||
return current_definition.workflow_json
|
||||
|
||||
# Fallback for backwards-compatibility when the relationship is not (yet)
|
||||
# loaded. In this case we fall back to the legacy ``workflow_definition``
|
||||
# column that always contains the most recent definition JSON.
|
||||
logger.warning(
|
||||
f"Workflow {self.id} has no loaded current definition, using workflow_definition as fallback",
|
||||
)
|
||||
return self.workflow_definition
|
||||
|
||||
|
||||
class WorkflowTemplates(Base):
|
||||
__tablename__ = "workflow_templates"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_name = Column(String, nullable=False, index=True)
|
||||
template_description = Column(String, nullable=False, index=True)
|
||||
template_json = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class WorkflowRunModel(Base):
|
||||
__tablename__ = "workflow_runs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
||||
workflow = relationship("WorkflowModel", back_populates="runs")
|
||||
definition_id = Column(
|
||||
Integer, ForeignKey("workflow_definitions.id"), nullable=True
|
||||
)
|
||||
definition = relationship("WorkflowDefinitionModel", back_populates="workflow_runs")
|
||||
mode = Column(
|
||||
Enum(*[mode.value for mode in WorkflowRunMode], name="workflow_run_mode"),
|
||||
nullable=False,
|
||||
)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
recording_url = Column(String, nullable=True)
|
||||
transcript_url = Column(String, nullable=True)
|
||||
# Store storage backend as string enum (s3, minio)
|
||||
storage_backend = Column(
|
||||
Enum("s3", "minio", name="storage_backend"),
|
||||
nullable=False,
|
||||
default="s3",
|
||||
server_default=text("'s3'::storage_backend"),
|
||||
)
|
||||
usage_info = Column(JSON, nullable=False, default=dict)
|
||||
cost_info = Column(JSON, nullable=False, default=dict)
|
||||
initial_context = Column(JSON, nullable=False, default=dict)
|
||||
gathered_context = Column(JSON, nullable=False, default=dict)
|
||||
logs = Column(JSON, nullable=False, default=dict, server_default=text("'{}'::json"))
|
||||
annotations = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
campaign_id = Column(Integer, ForeignKey("campaigns.id"), nullable=True)
|
||||
campaign = relationship("CampaignModel")
|
||||
queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
|
||||
queued_run = relationship("QueuedRunModel", foreign_keys=[queued_run_id])
|
||||
|
||||
|
||||
# LoopTalk Testing Models
|
||||
class LoopTalkTestSession(Base):
|
||||
__tablename__ = "looptalk_test_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
status = Column(
|
||||
Enum("pending", "running", "completed", "failed", name="test_session_status"),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
)
|
||||
|
||||
# Workflow configuration
|
||||
actor_workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
||||
adversary_workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
||||
|
||||
# Load testing configuration
|
||||
load_test_group_id = Column(String, nullable=True, index=True)
|
||||
test_index = Column(Integer, nullable=True)
|
||||
|
||||
# Test metadata
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
results = Column(JSON, nullable=False, default=dict)
|
||||
error = Column(String, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel")
|
||||
actor_workflow = relationship("WorkflowModel", foreign_keys=[actor_workflow_id])
|
||||
adversary_workflow = relationship(
|
||||
"WorkflowModel", foreign_keys=[adversary_workflow_id]
|
||||
)
|
||||
conversations = relationship("LoopTalkConversation", back_populates="test_session")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index("ix_looptalk_test_sessions_org_id", "organization_id"),
|
||||
Index("ix_looptalk_test_sessions_group_id", "load_test_group_id"),
|
||||
Index("ix_looptalk_test_sessions_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
class LoopTalkConversation(Base):
|
||||
__tablename__ = "looptalk_conversations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
test_session_id = Column(
|
||||
Integer, ForeignKey("looptalk_test_sessions.id"), nullable=False
|
||||
)
|
||||
|
||||
# Conversation metadata
|
||||
duration_seconds = Column(Integer, nullable=True)
|
||||
# Note: Turn tracking is handled by Langfuse, not stored here
|
||||
|
||||
# Audio recording URLs
|
||||
actor_recording_url = Column(String, nullable=True)
|
||||
adversary_recording_url = Column(String, nullable=True)
|
||||
combined_recording_url = Column(String, nullable=True)
|
||||
|
||||
# Transcripts (if needed for quick access)
|
||||
transcript = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# Metrics
|
||||
metrics = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
test_session = relationship("LoopTalkTestSession", back_populates="conversations")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (Index("ix_looptalk_conversations_session_id", "test_session_id"),)
|
||||
|
||||
|
||||
class OrganizationUsageCycleModel(Base):
|
||||
"""
|
||||
This model is used to track the usage of Dograh tokens for an organization for a given usage
|
||||
cycle.
|
||||
"""
|
||||
|
||||
__tablename__ = "organization_usage_cycles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||
period_start = Column(DateTime(timezone=True), nullable=False)
|
||||
period_end = Column(DateTime(timezone=True), nullable=False)
|
||||
quota_dograh_tokens = Column(Integer, nullable=False)
|
||||
used_dograh_tokens = Column(Float, nullable=False, default=0)
|
||||
total_duration_seconds = Column(
|
||||
Integer, nullable=False, default=0, server_default=text("0")
|
||||
)
|
||||
# New USD tracking fields
|
||||
used_amount_usd = Column(Float, nullable=True, default=0)
|
||||
quota_amount_usd = Column(Float, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel", back_populates="usage_cycles")
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"organization_id", "period_start", "period_end", name="unique_org_period"
|
||||
),
|
||||
Index("idx_usage_cycles_org_period", "organization_id", "period_end"),
|
||||
)
|
||||
|
||||
|
||||
class CampaignModel(Base):
|
||||
__tablename__ = "campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Source configuration
|
||||
source_type = Column(String, nullable=False, default="google-sheet")
|
||||
source_id = Column(String, nullable=False) # Sheet URL
|
||||
|
||||
# State management
|
||||
state = Column(
|
||||
Enum(
|
||||
"created",
|
||||
"syncing",
|
||||
"running",
|
||||
"paused",
|
||||
"completed",
|
||||
"failed",
|
||||
name="campaign_state",
|
||||
),
|
||||
nullable=False,
|
||||
default="created",
|
||||
)
|
||||
|
||||
# Progress tracking
|
||||
total_rows = Column(Integer, nullable=True)
|
||||
processed_rows = Column(Integer, nullable=False, default=0)
|
||||
failed_rows = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Rate limiting and sync configuration
|
||||
rate_limit_per_second = Column(Integer, nullable=False, default=1)
|
||||
max_retries = Column(Integer, nullable=False, default=0)
|
||||
source_sync_status = Column(String, nullable=False, default="pending")
|
||||
source_last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
source_sync_error = Column(String, nullable=True)
|
||||
|
||||
# Retry configuration for call failures
|
||||
retry_config = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default={
|
||||
"enabled": True,
|
||||
"max_retries": 2,
|
||||
"retry_delay_seconds": 120,
|
||||
"retry_on_busy": True,
|
||||
"retry_on_no_answer": True,
|
||||
"retry_on_voicemail": True,
|
||||
},
|
||||
server_default=text(
|
||||
'\'{"enabled": true, "max_retries": 2, "retry_on_busy": true, "retry_on_no_answer": true, "retry_on_voicemail": true, "retry_delay_seconds": 120}\'::jsonb'
|
||||
),
|
||||
)
|
||||
|
||||
# Orchestrator tracking fields
|
||||
last_batch_scheduled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_activity_at = Column(DateTime(timezone=True), nullable=True)
|
||||
orchestrator_metadata = Column(
|
||||
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel")
|
||||
workflow = relationship("WorkflowModel")
|
||||
created_by_user = relationship("UserModel")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_campaigns_org_id", "organization_id"),
|
||||
Index("ix_campaigns_state", "state"),
|
||||
Index("ix_campaigns_workflow_id", "workflow_id"),
|
||||
# Index for efficient querying of active campaigns
|
||||
Index(
|
||||
"idx_campaigns_active_status",
|
||||
"state",
|
||||
postgresql_where=text("state IN ('syncing', 'running', 'paused')"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class QueuedRunModel(Base):
|
||||
__tablename__ = "queued_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(
|
||||
Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
source_uuid = Column(String, nullable=False)
|
||||
context_variables = Column(JSON, nullable=False, default=dict)
|
||||
state = Column(
|
||||
Enum("queued", "processed", "failed", name="queued_run_state"),
|
||||
nullable=False,
|
||||
default="queued",
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
processed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# New retry-related fields
|
||||
retry_count = Column(Integer, default=0, nullable=False, server_default=text("0"))
|
||||
parent_queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
|
||||
scheduled_for = Column(DateTime(timezone=True), nullable=True)
|
||||
retry_reason = Column(String, nullable=True) # 'busy', 'no_answer', 'voicemail'
|
||||
|
||||
# Relationships
|
||||
campaign = relationship("CampaignModel")
|
||||
parent_queued_run = relationship("QueuedRunModel", remote_side=[id])
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_queued_runs_campaign_state", "campaign_id", "state"),
|
||||
Index("idx_queued_runs_created", "created_at"),
|
||||
Index("idx_queued_runs_source_uuid", "source_uuid"),
|
||||
Index(
|
||||
"idx_queued_runs_scheduled", "scheduled_for"
|
||||
), # New index for scheduled retries
|
||||
# Optimized index for checking queued runs efficiently
|
||||
Index(
|
||||
"idx_queued_runs_campaign_state_optimized",
|
||||
"campaign_id",
|
||||
"state",
|
||||
postgresql_where=text("state = 'queued'"),
|
||||
),
|
||||
# Optimized index for scheduled retries
|
||||
Index(
|
||||
"idx_queued_runs_scheduled_optimized",
|
||||
"campaign_id",
|
||||
"scheduled_for",
|
||||
postgresql_where=text("scheduled_for IS NOT NULL"),
|
||||
),
|
||||
UniqueConstraint(
|
||||
"campaign_id",
|
||||
"source_uuid",
|
||||
"retry_count",
|
||||
name="unique_campaign_source_retry",
|
||||
),
|
||||
)
|
||||
114
api/db/organization_client.py
Normal file
114
api/db/organization_client.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import (
|
||||
APIKeyModel,
|
||||
OrganizationModel,
|
||||
organization_users_association,
|
||||
)
|
||||
from api.utils.api_key import generate_api_key
|
||||
|
||||
|
||||
class OrganizationClient(BaseDBClient):
|
||||
async def get_organization_by_id(
|
||||
self, organization_id: int
|
||||
) -> Optional[OrganizationModel]:
|
||||
"""Get an organization by its ID."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationModel).where(OrganizationModel.id == organization_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_or_create_organization_by_provider_id(
|
||||
self, org_provider_id: str, user_id: int
|
||||
) -> tuple[OrganizationModel, bool]:
|
||||
"""Get an existing organization by provider_id or create a new one.
|
||||
|
||||
Returns:
|
||||
A tuple of (organization, was_created) where was_created is True if the organization
|
||||
was created in this call, False if it already existed.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# First try to get existing organization
|
||||
result = await session.execute(
|
||||
select(OrganizationModel).where(
|
||||
OrganizationModel.provider_id == org_provider_id
|
||||
)
|
||||
)
|
||||
organization = result.scalars().first()
|
||||
|
||||
if organization is None:
|
||||
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
|
||||
# This is atomic and handles race conditions at the database level
|
||||
|
||||
stmt = insert(OrganizationModel.__table__).values(
|
||||
provider_id=org_provider_id, created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"])
|
||||
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
# Check if we actually inserted (rowcount > 0) or if there was a conflict (rowcount == 0)
|
||||
was_created = result.rowcount > 0
|
||||
|
||||
# Now fetch the organization (either the one we just created or the one that existed)
|
||||
result = await session.execute(
|
||||
select(OrganizationModel).where(
|
||||
OrganizationModel.provider_id == org_provider_id
|
||||
)
|
||||
)
|
||||
organization = result.scalars().first()
|
||||
|
||||
if organization is None:
|
||||
# This should never happen, but handle it just in case
|
||||
error_msg = f"Failed to create or fetch organization with provider_id {org_provider_id}"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Only create API key if we actually created the organization
|
||||
if was_created:
|
||||
# Create a default API key for the new organization
|
||||
_, key_hash, key_prefix = generate_api_key()
|
||||
|
||||
api_key = APIKeyModel(
|
||||
organization_id=organization.id,
|
||||
name="Default API Key",
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
is_active=True,
|
||||
created_by=user_id,
|
||||
)
|
||||
session.add(api_key)
|
||||
await session.commit()
|
||||
|
||||
await session.refresh(organization)
|
||||
return organization, was_created
|
||||
return organization, False
|
||||
|
||||
async def add_user_to_organization(
|
||||
self, user_id: int, organization_id: int
|
||||
) -> None:
|
||||
"""Ensure that a user is linked to an organization (many-to-many).
|
||||
|
||||
The association is created only if it does not already exist.
|
||||
Uses INSERT ... ON CONFLICT DO NOTHING to handle race conditions.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
|
||||
# This handles race conditions at the database level
|
||||
|
||||
stmt = insert(organization_users_association).values(
|
||||
user_id=user_id, organization_id=organization_id
|
||||
)
|
||||
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
|
||||
# The primary key constraint on (user_id, organization_id) will trigger the conflict
|
||||
stmt = stmt.on_conflict_do_nothing()
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
96
api/db/organization_configuration_client.py
Normal file
96
api/db/organization_configuration_client.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import OrganizationConfigurationModel
|
||||
|
||||
|
||||
class OrganizationConfigurationClient(BaseDBClient):
|
||||
async def get_configuration(
|
||||
self, organization_id: int, key: str
|
||||
) -> Optional[OrganizationConfigurationModel]:
|
||||
"""Get a specific configuration for an organization by key."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.organization_id == organization_id,
|
||||
OrganizationConfigurationModel.key == key,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_all_configurations(
|
||||
self, organization_id: int
|
||||
) -> list[OrganizationConfigurationModel]:
|
||||
"""Get all configurations for an organization."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.organization_id == organization_id
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def upsert_configuration(
|
||||
self, organization_id: int, key: str, value: Any
|
||||
) -> OrganizationConfigurationModel:
|
||||
"""Create or update a configuration for an organization."""
|
||||
async with self.async_session() as session:
|
||||
# First try to get existing configuration
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.organization_id == organization_id,
|
||||
OrganizationConfigurationModel.key == key,
|
||||
)
|
||||
)
|
||||
config = result.scalars().first()
|
||||
|
||||
if config:
|
||||
# Update existing configuration
|
||||
config.value = value
|
||||
else:
|
||||
# Create new configuration
|
||||
config = OrganizationConfigurationModel(
|
||||
organization_id=organization_id,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
session.add(config)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
async def delete_configuration(self, organization_id: int, key: str) -> bool:
|
||||
"""Delete a configuration for an organization."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.organization_id == organization_id,
|
||||
OrganizationConfigurationModel.key == key,
|
||||
)
|
||||
)
|
||||
config = result.scalars().first()
|
||||
|
||||
if not config:
|
||||
return False
|
||||
|
||||
await session.delete(config)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
return True
|
||||
|
||||
async def get_configuration_value(
|
||||
self, organization_id: int, key: str, default: Any = None
|
||||
) -> Any:
|
||||
"""Get the value of a configuration, returning default if not found."""
|
||||
config = await self.get_configuration(organization_id, key)
|
||||
return config.value if config else default
|
||||
524
api/db/organization_usage_client.py
Normal file
524
api/db/organization_usage_client.py
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import Date, and_, cast, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.filters import apply_workflow_run_filters
|
||||
from api.db.models import (
|
||||
OrganizationModel,
|
||||
OrganizationUsageCycleModel,
|
||||
UserConfigurationModel,
|
||||
UserModel,
|
||||
WorkflowModel,
|
||||
WorkflowRunModel,
|
||||
)
|
||||
from api.schemas.user_configuration import UserConfiguration
|
||||
|
||||
|
||||
class OrganizationUsageClient(BaseDBClient):
|
||||
"""Client for managing organization usage and quota operations."""
|
||||
|
||||
async def get_or_create_current_cycle(
|
||||
self, organization_id: int, session=None
|
||||
) -> OrganizationUsageCycleModel:
|
||||
"""Get or create the current usage cycle for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: The organization ID
|
||||
session: Optional session to use for the operation. If provided,
|
||||
the caller is responsible for committing.
|
||||
"""
|
||||
if session is None:
|
||||
async with self.async_session() as session:
|
||||
return await self._get_or_create_current_cycle_impl(
|
||||
organization_id, session, commit=True
|
||||
)
|
||||
else:
|
||||
return await self._get_or_create_current_cycle_impl(
|
||||
organization_id, session, commit=False
|
||||
)
|
||||
|
||||
async def _get_or_create_current_cycle_impl(
|
||||
self, organization_id: int, session, commit: bool
|
||||
) -> OrganizationUsageCycleModel:
|
||||
"""Internal implementation for get_or_create_current_cycle."""
|
||||
# Get organization to determine quota type
|
||||
org_result = await session.execute(
|
||||
select(OrganizationModel).where(OrganizationModel.id == organization_id)
|
||||
)
|
||||
org = org_result.scalar_one()
|
||||
|
||||
# Calculate current period
|
||||
period_start, period_end = self._calculate_current_period(org)
|
||||
|
||||
# Try to get existing cycle
|
||||
cycle_result = await session.execute(
|
||||
select(OrganizationUsageCycleModel).where(
|
||||
and_(
|
||||
OrganizationUsageCycleModel.organization_id == organization_id,
|
||||
OrganizationUsageCycleModel.period_start == period_start,
|
||||
OrganizationUsageCycleModel.period_end == period_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
cycle = cycle_result.scalar_one_or_none()
|
||||
|
||||
if cycle:
|
||||
return cycle
|
||||
|
||||
# Create new cycle if it doesn't exist
|
||||
stmt = insert(OrganizationUsageCycleModel).values(
|
||||
organization_id=organization_id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
quota_dograh_tokens=org.quota_dograh_tokens,
|
||||
)
|
||||
# Handle concurrent inserts gracefully
|
||||
stmt = stmt.on_conflict_do_nothing(
|
||||
index_elements=["organization_id", "period_start", "period_end"]
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
|
||||
if commit:
|
||||
await session.commit()
|
||||
|
||||
# Fetch the created cycle
|
||||
cycle_result = await session.execute(
|
||||
select(OrganizationUsageCycleModel).where(
|
||||
and_(
|
||||
OrganizationUsageCycleModel.organization_id == organization_id,
|
||||
OrganizationUsageCycleModel.period_start == period_start,
|
||||
OrganizationUsageCycleModel.period_end == period_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
return cycle_result.scalar_one()
|
||||
|
||||
async def check_and_reserve_quota(
|
||||
self, organization_id: int, estimated_tokens: int = 0
|
||||
) -> bool:
|
||||
"""
|
||||
Check if organization has sufficient quota and optionally reserve tokens.
|
||||
Returns True if quota is available, False otherwise.
|
||||
|
||||
This method is fully atomic and safe for concurrent access from multiple processes.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Get organization
|
||||
org_result = await session.execute(
|
||||
select(OrganizationModel).where(OrganizationModel.id == organization_id)
|
||||
)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org or not org.quota_enabled:
|
||||
# No quota enforcement if not enabled
|
||||
return True
|
||||
|
||||
# Get or create current cycle within the same session/transaction
|
||||
cycle = await self._get_or_create_current_cycle_impl(
|
||||
organization_id, session, commit=False
|
||||
)
|
||||
|
||||
# Atomic check and update with row-level lock
|
||||
result = await session.execute(
|
||||
select(OrganizationUsageCycleModel)
|
||||
.where(
|
||||
and_(
|
||||
OrganizationUsageCycleModel.id == cycle.id,
|
||||
OrganizationUsageCycleModel.used_dograh_tokens
|
||||
+ estimated_tokens
|
||||
<= OrganizationUsageCycleModel.quota_dograh_tokens,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=False)
|
||||
)
|
||||
|
||||
cycle_locked = result.scalar_one_or_none()
|
||||
if cycle_locked:
|
||||
# Update the usage atomically
|
||||
cycle_locked.used_dograh_tokens += estimated_tokens
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def update_usage_after_run(
|
||||
self,
|
||||
organization_id: int,
|
||||
actual_tokens: int,
|
||||
duration_seconds: int = 0,
|
||||
charge_usd: float = None,
|
||||
) -> None:
|
||||
"""Update usage after a workflow run completes with actual token count and duration.
|
||||
|
||||
This method is fully atomic and safe for concurrent access from multiple processes.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Get or create current cycle within the same session/transaction
|
||||
cycle = await self._get_or_create_current_cycle_impl(
|
||||
organization_id, session, commit=False
|
||||
)
|
||||
|
||||
# Acquire a row-level lock for atomic update
|
||||
result = await session.execute(
|
||||
select(OrganizationUsageCycleModel)
|
||||
.where(OrganizationUsageCycleModel.id == cycle.id)
|
||||
.with_for_update(skip_locked=False)
|
||||
)
|
||||
cycle_locked = result.scalar_one()
|
||||
|
||||
# Update usage atomically
|
||||
cycle_locked.used_dograh_tokens += actual_tokens
|
||||
cycle_locked.total_duration_seconds += int(round(duration_seconds))
|
||||
|
||||
# Update USD amount if provided
|
||||
if charge_usd is not None:
|
||||
if cycle_locked.used_amount_usd is None:
|
||||
cycle_locked.used_amount_usd = 0
|
||||
cycle_locked.used_amount_usd += charge_usd
|
||||
|
||||
await session.commit()
|
||||
|
||||
async def get_current_usage(self, organization_id: int) -> dict:
|
||||
"""Get current period usage information."""
|
||||
async with self.async_session() as session:
|
||||
# Get organization
|
||||
org_result = await session.execute(
|
||||
select(OrganizationModel).where(OrganizationModel.id == organization_id)
|
||||
)
|
||||
org = org_result.scalar_one()
|
||||
|
||||
# Get or create current cycle within the same session
|
||||
cycle = await self._get_or_create_current_cycle_impl(
|
||||
organization_id, session, commit=False
|
||||
)
|
||||
|
||||
# Calculate next refresh date
|
||||
if org.quota_type == "monthly":
|
||||
next_refresh = cycle.period_end + relativedelta(days=1)
|
||||
else: # annual
|
||||
next_refresh = cycle.period_end + relativedelta(days=1)
|
||||
|
||||
result = {
|
||||
"period_start": cycle.period_start.isoformat(),
|
||||
"period_end": cycle.period_end.isoformat(),
|
||||
"used_dograh_tokens": cycle.used_dograh_tokens,
|
||||
"quota_dograh_tokens": cycle.quota_dograh_tokens,
|
||||
"percentage_used": (
|
||||
round(
|
||||
(cycle.used_dograh_tokens / cycle.quota_dograh_tokens) * 100, 2
|
||||
)
|
||||
if cycle.quota_dograh_tokens > 0
|
||||
else 0
|
||||
),
|
||||
"next_refresh_date": next_refresh.date().isoformat(),
|
||||
"quota_enabled": org.quota_enabled,
|
||||
"total_duration_seconds": cycle.total_duration_seconds,
|
||||
}
|
||||
|
||||
# Add USD fields if organization has pricing
|
||||
if org.price_per_second_usd is not None:
|
||||
result["used_amount_usd"] = cycle.used_amount_usd or 0
|
||||
result["quota_amount_usd"] = cycle.quota_amount_usd
|
||||
result["currency"] = "USD"
|
||||
result["price_per_second_usd"] = org.price_per_second_usd
|
||||
|
||||
# Calculate percentage based on USD if available
|
||||
if cycle.quota_amount_usd and cycle.quota_amount_usd > 0:
|
||||
result["percentage_used"] = round(
|
||||
((cycle.used_amount_usd or 0) / cycle.quota_amount_usd) * 100, 2
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def get_usage_history(
|
||||
self,
|
||||
organization_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
filters: Optional[list[dict]] = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated workflow runs with usage for an organization."""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowRunModel)
|
||||
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
|
||||
.join(UserModel, WorkflowModel.user_id == UserModel.id)
|
||||
.where(
|
||||
UserModel.selected_organization_id == organization_id,
|
||||
WorkflowRunModel.cost_info.isnot(None),
|
||||
)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
)
|
||||
|
||||
# Apply date filters if provided
|
||||
if start_date:
|
||||
query = query.where(WorkflowRunModel.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.where(WorkflowRunModel.created_at <= end_date)
|
||||
|
||||
# Only allow specific filters for usage history endpoint
|
||||
# This ensures security and prevents unexpected filter attributes
|
||||
allowed_filters = {"duration", "dispositionCode", "phoneNumber"}
|
||||
sanitized_filters = []
|
||||
|
||||
if filters:
|
||||
for filter_item in filters:
|
||||
attribute = filter_item.get("attribute")
|
||||
|
||||
# Only process allowed filters
|
||||
if attribute in allowed_filters:
|
||||
sanitized_filters.append(filter_item)
|
||||
|
||||
# Apply filters using the common filter function
|
||||
query = apply_workflow_run_filters(query, sanitized_filters)
|
||||
|
||||
# Get total count
|
||||
count_result = await session.execute(
|
||||
select(func.count()).select_from(query.subquery())
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
results = await session.execute(
|
||||
query.options(joinedload(WorkflowRunModel.workflow))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
runs = results.scalars().all()
|
||||
|
||||
# Format runs
|
||||
formatted_runs = []
|
||||
total_tokens = 0
|
||||
total_duration_seconds = 0
|
||||
for run in runs:
|
||||
if run.cost_info:
|
||||
# Try to get dograh_token_usage first (new format)
|
||||
dograh_tokens = run.cost_info.get("dograh_token_usage", 0)
|
||||
# If not present, calculate from total_cost_usd (old format)
|
||||
if dograh_tokens == 0 and "total_cost_usd" in run.cost_info:
|
||||
dograh_tokens = round(
|
||||
float(run.cost_info["total_cost_usd"]) * 100, 2
|
||||
)
|
||||
# Get call duration
|
||||
call_duration = run.cost_info.get("call_duration_seconds", 0)
|
||||
else:
|
||||
dograh_tokens = 0
|
||||
call_duration = 0
|
||||
total_tokens += dograh_tokens
|
||||
total_duration_seconds += int(round(call_duration))
|
||||
|
||||
# Extract phone number from initial_context
|
||||
phone_number = None
|
||||
if run.initial_context:
|
||||
phone_number = run.initial_context.get("phone_number")
|
||||
|
||||
# Extract disposition from gathered_context
|
||||
disposition = None
|
||||
if run.gathered_context:
|
||||
disposition = run.gathered_context.get("mapped_call_disposition")
|
||||
|
||||
run_data = {
|
||||
"id": run.id,
|
||||
"workflow_id": run.workflow_id,
|
||||
"workflow_name": run.workflow.name if run.workflow else None,
|
||||
"name": run.name,
|
||||
"created_at": run.created_at.isoformat(),
|
||||
"dograh_token_usage": dograh_tokens,
|
||||
"call_duration_seconds": int(round(call_duration)),
|
||||
"recording_url": run.recording_url,
|
||||
"transcript_url": run.transcript_url,
|
||||
"phone_number": phone_number,
|
||||
"disposition": disposition,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
}
|
||||
|
||||
# Add USD cost if available in cost_info
|
||||
if run.cost_info and "charge_usd" in run.cost_info:
|
||||
run_data["charge_usd"] = run.cost_info["charge_usd"]
|
||||
|
||||
formatted_runs.append(run_data)
|
||||
|
||||
return formatted_runs, total_count, total_tokens, total_duration_seconds
|
||||
|
||||
async def get_daily_usage_breakdown(
|
||||
self,
|
||||
organization_id: int,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
price_per_second_usd: float,
|
||||
user_id: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""Get daily usage breakdown for an organization with pricing."""
|
||||
|
||||
async with self.async_session() as session:
|
||||
# Get user timezone if user_id is provided
|
||||
user_timezone = "UTC" # Default timezone
|
||||
if user_id:
|
||||
config_result = await session.execute(
|
||||
select(UserConfigurationModel).where(
|
||||
UserConfigurationModel.user_id == user_id
|
||||
)
|
||||
)
|
||||
config_obj = config_result.scalar_one_or_none()
|
||||
if config_obj and config_obj.configuration:
|
||||
user_config = UserConfiguration.model_validate(
|
||||
config_obj.configuration
|
||||
)
|
||||
if user_config.timezone:
|
||||
user_timezone = user_config.timezone
|
||||
|
||||
# Validate timezone string
|
||||
try:
|
||||
# Test if timezone is valid
|
||||
ZoneInfo(user_timezone)
|
||||
except Exception:
|
||||
# Fallback to UTC if timezone is invalid
|
||||
user_timezone = "UTC"
|
||||
# Query to get daily aggregates
|
||||
# Use AT TIME ZONE to convert to user's timezone before grouping by date
|
||||
date_expr = cast(
|
||||
func.timezone(user_timezone, WorkflowRunModel.created_at), Date
|
||||
)
|
||||
|
||||
daily_usage = await session.execute(
|
||||
select(
|
||||
date_expr.label("date"),
|
||||
func.sum(
|
||||
WorkflowRunModel.cost_info["call_duration_seconds"].as_float()
|
||||
).label("total_seconds"),
|
||||
func.count(WorkflowRunModel.id).label("call_count"),
|
||||
)
|
||||
.join(WorkflowModel, WorkflowModel.id == WorkflowRunModel.workflow_id)
|
||||
.join(UserModel, UserModel.id == WorkflowModel.user_id)
|
||||
.where(
|
||||
UserModel.selected_organization_id == organization_id,
|
||||
WorkflowRunModel.created_at >= start_date,
|
||||
WorkflowRunModel.created_at <= end_date,
|
||||
WorkflowRunModel.is_completed == True,
|
||||
)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr.desc())
|
||||
)
|
||||
|
||||
breakdown = []
|
||||
total_minutes = 0
|
||||
total_cost_usd = 0
|
||||
total_dograh_tokens = 0
|
||||
|
||||
for row in daily_usage:
|
||||
seconds = row.total_seconds or 0
|
||||
minutes = seconds / 60
|
||||
cost_usd = seconds * price_per_second_usd
|
||||
dograh_tokens = cost_usd * 100 # 1 cent = 1 token
|
||||
|
||||
total_minutes += minutes
|
||||
total_cost_usd += cost_usd
|
||||
total_dograh_tokens += dograh_tokens
|
||||
|
||||
breakdown.append(
|
||||
{
|
||||
"date": row.date.isoformat(),
|
||||
"minutes": round(minutes, 1),
|
||||
"cost_usd": round(cost_usd, 2),
|
||||
"dograh_tokens": round(dograh_tokens, 0),
|
||||
"call_count": row.call_count,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"breakdown": breakdown,
|
||||
"total_minutes": round(total_minutes, 1),
|
||||
"total_cost_usd": round(total_cost_usd, 2),
|
||||
"total_dograh_tokens": round(total_dograh_tokens, 0),
|
||||
"currency": "USD",
|
||||
}
|
||||
|
||||
async def update_organization_quota(
|
||||
self,
|
||||
organization_id: int,
|
||||
quota_type: str,
|
||||
quota_dograh_tokens: int,
|
||||
quota_reset_day: Optional[int] = None,
|
||||
quota_start_date: Optional[datetime] = None,
|
||||
) -> OrganizationModel:
|
||||
"""Update organization quota settings."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationModel).where(OrganizationModel.id == organization_id)
|
||||
)
|
||||
org = result.scalar_one()
|
||||
|
||||
org.quota_type = quota_type
|
||||
org.quota_dograh_tokens = quota_dograh_tokens
|
||||
org.quota_enabled = True
|
||||
|
||||
if quota_type == "monthly" and quota_reset_day:
|
||||
org.quota_reset_day = quota_reset_day
|
||||
elif quota_type == "annual" and quota_start_date:
|
||||
org.quota_start_date = quota_start_date
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
return org
|
||||
|
||||
def _calculate_current_period(
|
||||
self, org: OrganizationModel
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""Calculate the current billing period based on organization settings."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if org.quota_type == "monthly":
|
||||
# Find the start of the current billing month
|
||||
reset_day = org.quota_reset_day
|
||||
|
||||
# Handle month boundaries
|
||||
if now.day >= reset_day:
|
||||
period_start = now.replace(
|
||||
day=reset_day, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
else:
|
||||
# Previous month
|
||||
period_start = (now - relativedelta(months=1)).replace(
|
||||
day=reset_day, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
# End is one month later minus 1 second
|
||||
period_end = (
|
||||
period_start + relativedelta(months=1) - relativedelta(seconds=1)
|
||||
)
|
||||
|
||||
else: # annual
|
||||
if not org.quota_start_date:
|
||||
# Default to calendar year
|
||||
period_start = now.replace(
|
||||
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
period_end = (
|
||||
period_start + relativedelta(years=1) - relativedelta(seconds=1)
|
||||
)
|
||||
else:
|
||||
# Find current annual period
|
||||
start_date = org.quota_start_date.replace(tzinfo=timezone.utc)
|
||||
years_diff = now.year - start_date.year
|
||||
|
||||
# Adjust for whether we've passed the anniversary
|
||||
if now.month < start_date.month or (
|
||||
now.month == start_date.month and now.day < start_date.day
|
||||
):
|
||||
years_diff -= 1
|
||||
|
||||
period_start = start_date + relativedelta(years=years_diff)
|
||||
period_end = (
|
||||
period_start + relativedelta(years=1) - relativedelta(seconds=1)
|
||||
)
|
||||
|
||||
return period_start, period_end
|
||||
156
api/db/reports_client.py
Normal file
156
api/db/reports_client.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import String, and_, func, select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import WorkflowModel, WorkflowRunModel
|
||||
|
||||
|
||||
class ReportsClient(BaseDBClient):
|
||||
async def get_workflow_runs_for_daily_report(
|
||||
self,
|
||||
organization_id: int,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
workflow_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Optimized method for daily reports - fetches only required JSON fields.
|
||||
Uses PostgreSQL JSON operators to extract only needed fields from JSON columns.
|
||||
|
||||
Args:
|
||||
organization_id: The organization ID to filter by
|
||||
start_utc: Start datetime in UTC
|
||||
end_utc: End datetime in UTC
|
||||
workflow_id: Optional workflow ID to filter by
|
||||
|
||||
Returns:
|
||||
List of dictionaries with report-specific fields
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Select only the specific JSON fields needed for daily reports
|
||||
# Using PostgreSQL's JSON operators to extract specific fields
|
||||
query = (
|
||||
select(
|
||||
WorkflowRunModel.id,
|
||||
WorkflowRunModel.workflow_id,
|
||||
WorkflowRunModel.created_at,
|
||||
# Extract only specific fields from JSON columns
|
||||
# Use TRIM and REPLACE to remove any quotes from JSON values
|
||||
func.coalesce(
|
||||
func.replace(
|
||||
func.replace(
|
||||
func.cast(
|
||||
WorkflowRunModel.gathered_context[
|
||||
"mapped_call_disposition"
|
||||
],
|
||||
String,
|
||||
),
|
||||
'"',
|
||||
"",
|
||||
),
|
||||
"'",
|
||||
"",
|
||||
),
|
||||
"UNKNOWN",
|
||||
).label("disposition"),
|
||||
func.coalesce(
|
||||
func.replace(
|
||||
func.replace(
|
||||
func.cast(
|
||||
WorkflowRunModel.gathered_context[
|
||||
"customer_phone_number"
|
||||
],
|
||||
String,
|
||||
),
|
||||
'"',
|
||||
"",
|
||||
),
|
||||
"'",
|
||||
"",
|
||||
),
|
||||
func.replace(
|
||||
func.replace(
|
||||
func.cast(
|
||||
WorkflowRunModel.initial_context["phone_number"],
|
||||
String,
|
||||
),
|
||||
'"',
|
||||
"",
|
||||
),
|
||||
"'",
|
||||
"",
|
||||
),
|
||||
"",
|
||||
).label("phone_number"),
|
||||
func.coalesce(
|
||||
func.replace(
|
||||
func.replace(
|
||||
func.cast(
|
||||
WorkflowRunModel.usage_info[
|
||||
"call_duration_seconds"
|
||||
],
|
||||
String,
|
||||
),
|
||||
'"',
|
||||
"",
|
||||
),
|
||||
"'",
|
||||
"",
|
||||
),
|
||||
"0",
|
||||
).label("call_duration_seconds"),
|
||||
WorkflowModel.name.label("workflow_name"),
|
||||
)
|
||||
.select_from(WorkflowRunModel)
|
||||
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
|
||||
.where(
|
||||
and_(
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
WorkflowRunModel.created_at >= start_utc,
|
||||
WorkflowRunModel.created_at <= end_utc,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if workflow_id is not None:
|
||||
query = query.where(WorkflowRunModel.workflow_id == workflow_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"workflow_id": row.workflow_id,
|
||||
"workflow_name": row.workflow_name,
|
||||
"created_at": row.created_at,
|
||||
"gathered_context": {
|
||||
"mapped_call_disposition": row.disposition,
|
||||
"customer_phone_number": row.phone_number, # Also provide it here for compatibility
|
||||
},
|
||||
"usage_info": {"call_duration_seconds": row.call_duration_seconds},
|
||||
"initial_context": {"phone_number": row.phone_number},
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def get_workflows_for_organization(
|
||||
self, organization_id: int
|
||||
) -> List[WorkflowModel]:
|
||||
"""
|
||||
Get all workflows for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: The organization ID
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowModel)
|
||||
.where(WorkflowModel.organization_id == organization_id)
|
||||
.order_by(WorkflowModel.name)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
139
api/db/user_client.py
Normal file
139
api/db/user_client.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import UserConfigurationModel, UserModel
|
||||
from api.schemas.user_configuration import UserConfiguration
|
||||
|
||||
|
||||
class UserClient(BaseDBClient):
|
||||
async def get_or_create_user_by_provider_id(self, provider_id: str) -> UserModel:
|
||||
async with self.async_session() as session:
|
||||
# First try to get existing user
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.provider_id == provider_id)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if user is None:
|
||||
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
|
||||
# This is atomic and handles race conditions at the database level
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
stmt = insert(UserModel.__table__).values(
|
||||
provider_id=provider_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
selected_organization_id=None, # Will be set later
|
||||
is_superuser=False, # Default value
|
||||
)
|
||||
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"])
|
||||
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
# Now fetch the user (either the one we just created or the one that existed)
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.provider_id == provider_id)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if user is None:
|
||||
# This should never happen, but handle it just in case
|
||||
error_msg = (
|
||||
f"Failed to create or fetch user with provider_id {provider_id}"
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
return user
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> UserModel | None:
|
||||
"""Fetch a user by their internal ID."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.id == user_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_user_configurations(self, user_id: int) -> UserConfiguration:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserConfigurationModel).where(
|
||||
UserConfigurationModel.user_id == user_id
|
||||
)
|
||||
)
|
||||
configuration_obj = result.scalars().first()
|
||||
if not configuration_obj:
|
||||
return UserConfiguration()
|
||||
|
||||
return UserConfiguration.model_validate(
|
||||
{
|
||||
**configuration_obj.configuration,
|
||||
"last_validated_at": configuration_obj.last_validated_at,
|
||||
}
|
||||
)
|
||||
|
||||
async def update_user_configuration(
|
||||
self, user_id: int, configuration: UserConfiguration
|
||||
) -> UserConfiguration:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserConfigurationModel).where(
|
||||
UserConfigurationModel.user_id == user_id
|
||||
)
|
||||
)
|
||||
configuration_obj = result.scalars().first()
|
||||
if not configuration_obj:
|
||||
configuration_obj = UserConfigurationModel(
|
||||
user_id=user_id, configuration=configuration.model_dump()
|
||||
)
|
||||
session.add(configuration_obj)
|
||||
else:
|
||||
configuration_obj.configuration = configuration.model_dump()
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(configuration_obj)
|
||||
return UserConfiguration.model_validate(configuration_obj.configuration)
|
||||
|
||||
async def update_user_configuration_last_validated_at(self, user_id: int) -> None:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserConfigurationModel).where(
|
||||
UserConfigurationModel.user_id == user_id
|
||||
)
|
||||
)
|
||||
configuration_obj = result.scalars().first()
|
||||
if not configuration_obj:
|
||||
raise ValueError(f"User configuration with ID {user_id} not found")
|
||||
configuration_obj.last_validated_at = datetime.now()
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(configuration_obj)
|
||||
|
||||
async def update_user_selected_organization(
|
||||
self, user_id: int, organization_id: int
|
||||
) -> None:
|
||||
"""Update the user's selected organization ID."""
|
||||
async with self.async_session() as session:
|
||||
from sqlalchemy import update
|
||||
|
||||
# Use a direct UPDATE statement to avoid race conditions
|
||||
# This is atomic at the database level
|
||||
stmt = (
|
||||
update(UserModel)
|
||||
.where(UserModel.id == user_id)
|
||||
.values(selected_organization_id=organization_id)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise ValueError(f"User with ID {user_id} not found")
|
||||
|
||||
await session.commit()
|
||||
312
api/db/workflow_client.py
Normal file
312
api/db/workflow_client.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import WorkflowDefinitionModel, WorkflowModel, WorkflowRunModel
|
||||
|
||||
|
||||
class WorkflowClient(BaseDBClient):
|
||||
def _generate_workflow_hash(self, workflow_definition: dict) -> str:
|
||||
"""Generate a consistent hash for workflow definition."""
|
||||
# Convert to JSON with sorted keys for consistent hashing
|
||||
json_str = json.dumps(
|
||||
workflow_definition, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
return hashlib.sha256(json_str.encode()).hexdigest()
|
||||
|
||||
async def _get_or_create_workflow_definition(
|
||||
self, workflow_definition: dict, session, workflow_id: int = None
|
||||
) -> WorkflowDefinitionModel:
|
||||
"""Get existing workflow definition by hash or create a new one."""
|
||||
workflow_hash = self._generate_workflow_hash(workflow_definition)
|
||||
|
||||
# Try to find existing definition
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_hash == workflow_hash,
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
)
|
||||
)
|
||||
existing_definition = result.scalars().first()
|
||||
|
||||
if existing_definition:
|
||||
return existing_definition
|
||||
|
||||
# Create new definition if it doesn't exist
|
||||
new_definition = WorkflowDefinitionModel(
|
||||
workflow_hash=workflow_hash,
|
||||
workflow_json=workflow_definition,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
session.add(new_definition)
|
||||
await session.flush() # Flush to get the ID without committing
|
||||
return new_definition
|
||||
|
||||
async def create_workflow(
|
||||
self,
|
||||
name: str,
|
||||
workflow_definition: dict,
|
||||
user_id: int,
|
||||
organization_id: int = None,
|
||||
) -> WorkflowModel:
|
||||
async with self.async_session() as session:
|
||||
try:
|
||||
new_workflow = WorkflowModel(
|
||||
name=name,
|
||||
workflow_definition=workflow_definition, # Keep for backwards compatibility
|
||||
user_id=user_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
session.add(new_workflow)
|
||||
await session.flush() # Flush to get the workflow ID
|
||||
|
||||
# Now get or create workflow definition with the workflow_id
|
||||
definition = await self._get_or_create_workflow_definition(
|
||||
workflow_definition, session, new_workflow.id
|
||||
)
|
||||
|
||||
# Mark this definition as the current one and unset others
|
||||
definition.is_current = True
|
||||
# Set any other definitions for this workflow to not current
|
||||
other_defs_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == new_workflow.id,
|
||||
WorkflowDefinitionModel.id != definition.id,
|
||||
)
|
||||
)
|
||||
for other_def in other_defs_result.scalars().all():
|
||||
other_def.is_current = False
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(new_workflow)
|
||||
return new_workflow
|
||||
|
||||
async def get_all_workflows(
|
||||
self, user_id: int = None, organization_id: int = None, status: str = None
|
||||
) -> list[WorkflowModel]:
|
||||
async with self.async_session() as session:
|
||||
query = select(WorkflowModel).options(
|
||||
selectinload(WorkflowModel.current_definition)
|
||||
)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
query = query.where(WorkflowModel.organization_id == organization_id)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
query = query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
# Filter by status if provided
|
||||
if status:
|
||||
query = query.where(WorkflowModel.status == status)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_workflow(
|
||||
self, workflow_id: int, user_id: int = None, organization_id: int = None
|
||||
) -> WorkflowModel | None:
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
query = query.where(WorkflowModel.organization_id == organization_id)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
query = query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_by_id(self, workflow_id: int) -> WorkflowModel | None:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_workflow(
|
||||
self,
|
||||
workflow_id: int,
|
||||
name: str,
|
||||
workflow_definition: dict | None,
|
||||
template_context_variables: dict | None,
|
||||
workflow_configurations: dict | None,
|
||||
user_id: int = None,
|
||||
organization_id: int = None,
|
||||
) -> WorkflowModel:
|
||||
"""
|
||||
Update an existing workflow in the database.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to update
|
||||
name: The new name for the workflow
|
||||
workflow_definition: The new workflow definition
|
||||
template_context_variables: The template context variables
|
||||
user_id: The user ID (for backwards compatibility)
|
||||
organization_id: The organization ID
|
||||
|
||||
Returns:
|
||||
The updated WorkflowModel
|
||||
|
||||
Raises:
|
||||
ValueError: If the workflow with the given ID is not found
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
query = query.where(WorkflowModel.organization_id == organization_id)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
query = query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
workflow = result.scalars().first()
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
workflow.name = name
|
||||
|
||||
if template_context_variables is not None:
|
||||
workflow.template_context_variables = template_context_variables
|
||||
|
||||
if workflow_configurations is not None:
|
||||
workflow.workflow_configurations = workflow_configurations
|
||||
|
||||
# In case of only name update, the workflow_definition can be None
|
||||
if workflow_definition:
|
||||
# Get or create new workflow definition
|
||||
definition = await self._get_or_create_workflow_definition(
|
||||
workflow_definition, session, workflow_id
|
||||
)
|
||||
|
||||
# Update legacy field for backwards compatibility
|
||||
workflow.workflow_definition = workflow_definition
|
||||
|
||||
# Mark new definition as current and reset others
|
||||
definition.is_current = True
|
||||
other_defs_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.id != definition.id,
|
||||
)
|
||||
)
|
||||
for other_def in other_defs_result.scalars().all():
|
||||
other_def.is_current = False
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(workflow)
|
||||
return workflow
|
||||
|
||||
async def get_workflows_by_ids(
|
||||
self, workflow_ids: list[int], organization_id: int
|
||||
) -> list[WorkflowModel]:
|
||||
"""Get workflows by IDs for a specific organization"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.join(WorkflowModel.user)
|
||||
.where(
|
||||
WorkflowModel.id.in_(workflow_ids),
|
||||
WorkflowModel.user.has(selected_organization_id=organization_id),
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_workflow_name(
|
||||
self, workflow_id: int, user_id: int = None, organization_id: int = None
|
||||
) -> Optional[str]:
|
||||
"""Get just the workflow name by ID"""
|
||||
async with self.async_session() as session:
|
||||
query = select(WorkflowModel.name).where(WorkflowModel.id == workflow_id)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
query = query.where(WorkflowModel.organization_id == organization_id)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
query = query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_workflow_status(
|
||||
self,
|
||||
workflow_id: int,
|
||||
status: str,
|
||||
organization_id: int = None,
|
||||
) -> WorkflowModel:
|
||||
"""
|
||||
Update the status of a workflow.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to update
|
||||
status: The new status (active/archived)
|
||||
organization_id: The organization ID
|
||||
|
||||
Returns:
|
||||
The updated WorkflowModel
|
||||
|
||||
Raises:
|
||||
ValueError: If the workflow is not found
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
|
||||
if organization_id:
|
||||
query = query.where(WorkflowModel.organization_id == organization_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
workflow = result.scalars().first()
|
||||
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
workflow.status = status
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(workflow)
|
||||
return workflow
|
||||
|
||||
async def get_workflow_run_count(self, workflow_id: int) -> int:
|
||||
"""Get the count of runs for a workflow."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(func.count(WorkflowRunModel.id)).where(
|
||||
WorkflowRunModel.workflow_id == workflow_id
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
404
api/db/workflow_run_client.py
Normal file
404
api/db/workflow_run_client.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.filters import apply_workflow_run_filters
|
||||
from api.db.models import (
|
||||
OrganizationModel,
|
||||
UserModel,
|
||||
WorkflowDefinitionModel,
|
||||
WorkflowModel,
|
||||
WorkflowRunModel,
|
||||
)
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
|
||||
|
||||
class WorkflowRunClient(BaseDBClient):
|
||||
async def create_workflow_run(
|
||||
self,
|
||||
name: str,
|
||||
workflow_id: int,
|
||||
mode: str,
|
||||
user_id: int,
|
||||
initial_context: dict = None,
|
||||
campaign_id: int = None,
|
||||
queued_run_id: int = None,
|
||||
) -> WorkflowRunModel:
|
||||
async with self.async_session() as session:
|
||||
# Get workflow and user to check organization
|
||||
workflow = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.options(joinedload(WorkflowModel.user))
|
||||
.where(
|
||||
WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id
|
||||
)
|
||||
)
|
||||
workflow = workflow.scalars().first()
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
# # Check quota if user has an organization
|
||||
# if workflow.user and workflow.user.selected_organization_id:
|
||||
# # Import here to avoid circular dependency
|
||||
# from api.db.organization_usage_client import OrganizationUsageClient
|
||||
|
||||
# usage_client = OrganizationUsageClient()
|
||||
|
||||
# # Check quota (no reservation for now, actual cost will be added after completion)
|
||||
# has_quota = await usage_client.check_and_reserve_quota(
|
||||
# workflow.user.selected_organization_id, estimated_tokens=0
|
||||
# )
|
||||
|
||||
# if not has_quota:
|
||||
# raise ValueError(
|
||||
# "Organization quota exceeded. Please contact your administrator."
|
||||
# )
|
||||
|
||||
# Fetch the current definition for this workflow
|
||||
current_def_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow.id,
|
||||
WorkflowDefinitionModel.is_current == True,
|
||||
)
|
||||
)
|
||||
current_def = current_def_result.scalars().first()
|
||||
|
||||
new_run = WorkflowRunModel(
|
||||
name=name,
|
||||
workflow=workflow,
|
||||
mode=mode,
|
||||
definition_id=current_def.id if current_def else None,
|
||||
initial_context=initial_context or workflow.template_context_variables,
|
||||
campaign_id=campaign_id,
|
||||
queued_run_id=queued_run_id,
|
||||
)
|
||||
session.add(new_run)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(new_run)
|
||||
return new_run
|
||||
|
||||
async def get_all_workflow_runs(self) -> list[WorkflowRunModel]:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(select(WorkflowRunModel))
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_workflow_runs_for_superadmin(
|
||||
self,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
filters: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get paginated workflow runs for superadmin with organization information.
|
||||
Returns tuple of (workflow_runs, total_count).
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Build base query with joins
|
||||
base_query = (
|
||||
select(WorkflowRunModel)
|
||||
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
|
||||
.join(UserModel, WorkflowModel.user_id == UserModel.id)
|
||||
.outerjoin(
|
||||
OrganizationModel,
|
||||
UserModel.selected_organization_id == OrganizationModel.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
base_query = apply_workflow_run_filters(base_query, filters)
|
||||
|
||||
# Count total with filters
|
||||
count_query = base_query.with_only_columns(func.count(WorkflowRunModel.id))
|
||||
count_result = await session.execute(count_query)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Get paginated results with filters
|
||||
result = await session.execute(
|
||||
base_query.options(
|
||||
joinedload(WorkflowRunModel.workflow).joinedload(
|
||||
WorkflowModel.user
|
||||
),
|
||||
joinedload(WorkflowRunModel.workflow)
|
||||
.joinedload(WorkflowModel.user)
|
||||
.joinedload(UserModel.selected_organization),
|
||||
)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
workflow_runs = result.scalars().all()
|
||||
|
||||
# Format the response
|
||||
formatted_runs = []
|
||||
for run in workflow_runs:
|
||||
organization = (
|
||||
run.workflow.user.selected_organization
|
||||
if run.workflow.user
|
||||
else None
|
||||
)
|
||||
formatted_runs.append(
|
||||
{
|
||||
"id": run.id,
|
||||
"name": run.name,
|
||||
"workflow_id": run.workflow_id,
|
||||
"workflow_name": run.workflow.name if run.workflow else None,
|
||||
"user_id": run.workflow.user_id if run.workflow else None,
|
||||
"organization_id": organization.id if organization else None,
|
||||
"organization_name": organization.provider_id
|
||||
if organization
|
||||
else None,
|
||||
"mode": run.mode,
|
||||
"is_completed": run.is_completed,
|
||||
"recording_url": run.recording_url,
|
||||
"transcript_url": run.transcript_url,
|
||||
"usage_info": run.usage_info,
|
||||
"cost_info": run.cost_info,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
"admin_comment": (run.annotations or {}).get("admin_comment"),
|
||||
"admin_comment_ts": (run.annotations or {}).get(
|
||||
"admin_comment_ts"
|
||||
),
|
||||
"created_at": run.created_at,
|
||||
}
|
||||
)
|
||||
|
||||
return formatted_runs, total_count
|
||||
|
||||
async def get_workflow_run(
|
||||
self, run_id: int, user_id: int = None, organization_id: int = None
|
||||
) -> WorkflowRunModel | None:
|
||||
async with self.async_session() as session:
|
||||
query = select(WorkflowRunModel).join(WorkflowRunModel.workflow)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
query = query.where(
|
||||
WorkflowRunModel.id == run_id,
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
query = query.where(
|
||||
WorkflowRunModel.id == run_id,
|
||||
WorkflowModel.user_id == user_id,
|
||||
)
|
||||
else:
|
||||
query = query.where(WorkflowRunModel.id == run_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_run_by_id(self, run_id: int) -> WorkflowRunModel | None:
|
||||
"""Get workflow run by ID without user filtering - for background tasks"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowRunModel)
|
||||
.options(
|
||||
joinedload(WorkflowRunModel.workflow).joinedload(WorkflowModel.user)
|
||||
)
|
||||
.where(WorkflowRunModel.id == run_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_runs_by_workflow_id(
|
||||
self,
|
||||
workflow_id: int,
|
||||
user_id: int = None,
|
||||
organization_id: int = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
filters: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> tuple[list[WorkflowRunResponseSchema], int]:
|
||||
async with self.async_session() as session:
|
||||
# Build base query
|
||||
base_query = (
|
||||
select(WorkflowRunModel)
|
||||
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
|
||||
.where(WorkflowRunModel.workflow_id == workflow_id)
|
||||
)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
base_query = base_query.where(
|
||||
WorkflowModel.organization_id == organization_id
|
||||
)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
base_query = base_query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
# Apply filters
|
||||
base_query = apply_workflow_run_filters(base_query, filters)
|
||||
|
||||
# Count total with filters
|
||||
count_query = base_query.with_only_columns(func.count(WorkflowRunModel.id))
|
||||
count_result = await session.execute(count_query)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Get paginated results with filters
|
||||
result = await session.execute(
|
||||
base_query.order_by(WorkflowRunModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
runs = [
|
||||
WorkflowRunResponseSchema.model_validate(
|
||||
{
|
||||
"id": run.id,
|
||||
"workflow_id": run.workflow_id,
|
||||
"name": run.name,
|
||||
"mode": run.mode,
|
||||
"created_at": run.created_at,
|
||||
"is_completed": run.is_completed,
|
||||
"recording_url": run.recording_url,
|
||||
"transcript_url": run.transcript_url,
|
||||
"cost_info": {
|
||||
"dograh_token_usage": (
|
||||
run.cost_info.get("dograh_token_usage")
|
||||
if run.cost_info
|
||||
and "dograh_token_usage" in run.cost_info
|
||||
else round(
|
||||
float(run.cost_info.get("total_cost_usd", 0)) * 100,
|
||||
2,
|
||||
)
|
||||
if run.cost_info and "total_cost_usd" in run.cost_info
|
||||
else 0
|
||||
),
|
||||
"call_duration_seconds": int(
|
||||
round(run.cost_info.get("call_duration_seconds") or 0)
|
||||
)
|
||||
if run.cost_info
|
||||
else None,
|
||||
}
|
||||
if run.cost_info
|
||||
else None,
|
||||
"definition_id": run.definition_id,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
}
|
||||
)
|
||||
for run in result.scalars().all()
|
||||
]
|
||||
return runs, total_count
|
||||
|
||||
async def update_workflow_run(
|
||||
self,
|
||||
run_id: int,
|
||||
is_completed: bool = False,
|
||||
recording_url: str | None = None,
|
||||
transcript_url: str | None = None,
|
||||
storage_backend: str | None = None,
|
||||
usage_info: dict | None = None,
|
||||
cost_info: dict | None = None,
|
||||
initial_context: dict | None = None,
|
||||
gathered_context: dict | None = None,
|
||||
logs: dict | None = None,
|
||||
) -> WorkflowRunModel:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowRunModel).where(WorkflowRunModel.id == run_id)
|
||||
)
|
||||
run = result.scalars().first()
|
||||
if not run:
|
||||
raise ValueError(f"Workflow run with ID {run_id} not found")
|
||||
if recording_url:
|
||||
run.recording_url = recording_url
|
||||
if transcript_url:
|
||||
run.transcript_url = transcript_url
|
||||
if storage_backend:
|
||||
run.storage_backend = storage_backend
|
||||
if usage_info:
|
||||
run.usage_info = usage_info
|
||||
if cost_info:
|
||||
run.cost_info = cost_info
|
||||
if initial_context:
|
||||
run.initial_context = initial_context
|
||||
if gathered_context:
|
||||
# Lets merge the incoming gathered context keys with the existing ones
|
||||
run.gathered_context = {
|
||||
**run.gathered_context,
|
||||
**gathered_context,
|
||||
}
|
||||
if logs:
|
||||
# Lets merge the incoming logs key with existing ones
|
||||
run.logs = {**run.logs, **logs}
|
||||
if is_completed:
|
||||
run.is_completed = is_completed
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(run)
|
||||
return run
|
||||
|
||||
async def update_admin_comment(
|
||||
self, run_id: int, admin_comment: str
|
||||
) -> WorkflowRunModel:
|
||||
"""Update (or create) the admin comment inside the ``annotations`` JSON column.
|
||||
|
||||
The comment is stored under the key ``admin_comment`` so we do not
|
||||
overwrite any other existing annotations that may be present.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowRunModel).where(WorkflowRunModel.id == run_id)
|
||||
)
|
||||
run = result.scalars().first()
|
||||
if run is None:
|
||||
raise ValueError(f"Workflow run with ID {run_id} not found")
|
||||
|
||||
# Ensure we never mutate a shared dict between instances
|
||||
current_annotations = dict(run.annotations or {})
|
||||
current_annotations["admin_comment"] = admin_comment
|
||||
|
||||
current_annotations["admin_comment_ts"] = datetime.now(
|
||||
timezone.utc
|
||||
).isoformat()
|
||||
run.annotations = current_annotations
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(run)
|
||||
return run
|
||||
|
||||
async def get_workflow_run_with_context(
|
||||
self, workflow_run_id: int
|
||||
) -> Tuple[Optional[WorkflowRunModel], Optional[int]]:
|
||||
"""
|
||||
Get workflow run with all related data and return organization_id.
|
||||
|
||||
Returns:
|
||||
Tuple of (workflow_run, organization_id) or (None, None) if not found
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowRunModel)
|
||||
.options(
|
||||
selectinload(WorkflowRunModel.workflow).selectinload(
|
||||
WorkflowModel.user
|
||||
)
|
||||
)
|
||||
.where(WorkflowRunModel.id == workflow_run_id)
|
||||
)
|
||||
workflow_run = result.scalars().first()
|
||||
|
||||
if not workflow_run:
|
||||
return None, None
|
||||
|
||||
if not workflow_run.workflow or not workflow_run.workflow.user:
|
||||
return workflow_run, None
|
||||
|
||||
organization_id = workflow_run.workflow.user.selected_organization_id
|
||||
return workflow_run, organization_id
|
||||
99
api/db/workflow_template_client.py
Normal file
99
api/db/workflow_template_client.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import WorkflowTemplates
|
||||
|
||||
|
||||
class WorkflowTemplateClient(BaseDBClient):
|
||||
async def get_workflow_template(self, template_id: int) -> WorkflowTemplates | None:
|
||||
"""Get a workflow template by ID."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowTemplates).where(WorkflowTemplates.id == template_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_template_by_name(
|
||||
self, template_name: str
|
||||
) -> WorkflowTemplates | None:
|
||||
"""Get a workflow template by name."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowTemplates).where(
|
||||
WorkflowTemplates.template_name == template_name
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_all_workflow_templates(self) -> list[WorkflowTemplates]:
|
||||
"""Get all workflow templates."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(select(WorkflowTemplates))
|
||||
return result.scalars().all()
|
||||
|
||||
async def create_workflow_template(
|
||||
self, template_name: str, template_description: str, template_json: dict
|
||||
) -> WorkflowTemplates:
|
||||
"""Create a new workflow template."""
|
||||
async with self.async_session() as session:
|
||||
try:
|
||||
new_template = WorkflowTemplates(
|
||||
template_name=template_name,
|
||||
template_description=template_description,
|
||||
template_json=template_json,
|
||||
)
|
||||
session.add(new_template)
|
||||
await session.commit()
|
||||
await session.refresh(new_template)
|
||||
return new_template
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
|
||||
async def update_workflow_template(
|
||||
self,
|
||||
template_id: int,
|
||||
template_name: str | None = None,
|
||||
template_json: dict | None = None,
|
||||
) -> WorkflowTemplates:
|
||||
"""Update an existing workflow template."""
|
||||
async with self.async_session() as session:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(WorkflowTemplates).where(WorkflowTemplates.id == template_id)
|
||||
)
|
||||
template = result.scalars().first()
|
||||
if not template:
|
||||
raise ValueError(
|
||||
f"Workflow template with ID {template_id} not found"
|
||||
)
|
||||
|
||||
if template_name is not None:
|
||||
template.template_name = template_name
|
||||
if template_json is not None:
|
||||
template.template_json = template_json
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(template)
|
||||
return template
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
|
||||
async def delete_workflow_template(self, template_id: int) -> bool:
|
||||
"""Delete a workflow template by ID."""
|
||||
async with self.async_session() as session:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(WorkflowTemplates).where(WorkflowTemplates.id == template_id)
|
||||
)
|
||||
template = result.scalars().first()
|
||||
if not template:
|
||||
return False
|
||||
|
||||
await session.delete(template)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
81
api/enums.py
Normal file
81
api/enums.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class IntegrationAction(Enum):
|
||||
ALL_CALLS = "All Calls"
|
||||
QUALIFIED_CALLS = "Qualified Calls"
|
||||
|
||||
|
||||
class Environment(Enum):
|
||||
LOCAL = "local"
|
||||
PRODUCTION = "production"
|
||||
TEST = "test"
|
||||
|
||||
|
||||
class WorkflowRunMode(Enum):
|
||||
TWILIO = "twilio"
|
||||
STASIS = "stasis"
|
||||
WEBRTC = "webrtc"
|
||||
SMALLWEBRTC = "smallwebrtc"
|
||||
|
||||
# Historical, not used anymore. Don't
|
||||
# use and don't remove
|
||||
VOICE = "VOICE"
|
||||
CHAT = "CHAT"
|
||||
|
||||
|
||||
class StorageBackend(Enum):
|
||||
"""Storage backend enumeration.
|
||||
|
||||
Currently supported backends:
|
||||
- S3: Amazon S3
|
||||
- MINIO: MinIO
|
||||
|
||||
Future extensibility: Additional backends like GCS, Azure can be added by:
|
||||
1. Adding new enum values as strings
|
||||
2. Implementing storage logic in services/storage.py
|
||||
3. Database will automatically support new values via SQLAlchemy Enum type
|
||||
"""
|
||||
|
||||
# Currently implemented backends
|
||||
S3 = "s3" # AWS S3 for cloud deployments
|
||||
MINIO = "minio" # MinIO for local/OSS deployments
|
||||
|
||||
@classmethod
|
||||
def get_current_backend(cls):
|
||||
"""Get current backend based on ENABLE_AWS_S3 flag."""
|
||||
from api.constants import ENABLE_AWS_S3
|
||||
|
||||
if ENABLE_AWS_S3:
|
||||
return cls.S3
|
||||
else:
|
||||
return cls.MINIO
|
||||
|
||||
|
||||
class WorkflowRunStatus(Enum):
|
||||
# historical modes
|
||||
VOICE = "VOICE"
|
||||
CHAT = "CHAT"
|
||||
|
||||
|
||||
class OrganizationConfigurationKey(Enum):
|
||||
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
|
||||
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
|
||||
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
|
||||
TWILIO_PHONE_NUMBERS = "TWILIO_PHONE_NUMBERS"
|
||||
|
||||
|
||||
class WorkflowStatus(Enum):
|
||||
"""Workflow status values"""
|
||||
|
||||
ACTIVE = "active"
|
||||
ARCHIVED = "archived"
|
||||
# Future statuses can be added here like:
|
||||
# DRAFT = "draft"
|
||||
# PAUSED = "paused"
|
||||
|
||||
|
||||
class RedisChannel(Enum):
|
||||
"""Redis pub/sub channel names"""
|
||||
|
||||
CAMPAIGN_EVENTS = "campaign_events"
|
||||
217
api/logging_config.py
Normal file
217
api/logging_config.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
from logging.handlers import QueueHandler, QueueListener
|
||||
|
||||
import loguru
|
||||
from axiom_py import Client
|
||||
from axiom_py.logging import AxiomHandler
|
||||
from pipecat.utils.context import run_id_var, turn_var
|
||||
|
||||
from api.enums import Environment
|
||||
from api.utils.worker import get_worker_id, is_worker_process
|
||||
|
||||
# ----- NEW CODE START -----
|
||||
# Helper to map string log level to Python logging level, adding support for "TRACE"
|
||||
TRACE_LEVEL_NUM = 5 # Below DEBUG (10)
|
||||
|
||||
|
||||
def _get_logging_level(level_name: str) -> int:
|
||||
"""Return numeric logging level for a given level name.
|
||||
|
||||
Supports the standard logging levels as well as the custom ``TRACE`` level
|
||||
used by *loguru*. If ``TRACE`` is requested and not yet defined in the
|
||||
``logging`` module, it will be registered dynamically.
|
||||
"""
|
||||
level_name = level_name.upper()
|
||||
|
||||
# Standard levels are present on the ``logging`` module.
|
||||
if hasattr(logging, level_name):
|
||||
return getattr(logging, level_name)
|
||||
|
||||
# Add support for TRACE (finer-grained than DEBUG)
|
||||
if level_name == "TRACE":
|
||||
if not hasattr(logging, "TRACE"):
|
||||
logging.addLevelName(TRACE_LEVEL_NUM, "TRACE")
|
||||
|
||||
def trace(self, message, *args, **kwargs): # type: ignore[override]
|
||||
if self.isEnabledFor(TRACE_LEVEL_NUM):
|
||||
self._log(TRACE_LEVEL_NUM, message, args, **kwargs)
|
||||
|
||||
logging.Logger.trace = trace # type: ignore[attr-defined]
|
||||
return TRACE_LEVEL_NUM
|
||||
|
||||
# Fallback to DEBUG if an unknown level is provided
|
||||
return logging.DEBUG
|
||||
|
||||
|
||||
# ----- NEW CODE END -----
|
||||
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", Environment.LOCAL.value)
|
||||
ENABLE_TURN_LOGGING = os.getenv("ENABLE_TURN_LOGGING", "false").lower() == "true"
|
||||
|
||||
# Log rotation settings from environment
|
||||
LOG_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB") # e.g., "100 MB", "1 GB"
|
||||
LOG_ROTATION_TIME = os.getenv("LOG_ROTATION_TIME", None) # e.g., "00:00", "12:00"
|
||||
LOG_RETENTION = os.getenv(
|
||||
"LOG_RETENTION", "7 days"
|
||||
) # e.g., "7 days", "1 week", "10 files"
|
||||
LOG_COMPRESSION = os.getenv(
|
||||
"LOG_COMPRESSION", "gz"
|
||||
) # "gz", "bz2", "xz", "tar", "tar.gz", "tar.bz2", "tar.xz", "zip"
|
||||
LOG_FILE_PATH = os.getenv(
|
||||
"LOG_FILE_PATH", None
|
||||
) # If set, write to file instead of stdout
|
||||
|
||||
# Track if logging has been initialized
|
||||
_logging_initialized = False
|
||||
_axiom_listener = None
|
||||
|
||||
|
||||
def inject_run_id(record):
|
||||
"""Inject run_id from context variable into log record"""
|
||||
record["extra"]["run_id"] = run_id_var.get()
|
||||
|
||||
# Only handle turn logging if enabled
|
||||
if ENABLE_TURN_LOGGING:
|
||||
# Get turn number with fallback mechanism
|
||||
turn = turn_var.get()
|
||||
|
||||
# If turn is still 0, try the turn context manager
|
||||
if turn == 0:
|
||||
try:
|
||||
from api.services.pipecat.turn_context import get_turn_context_manager
|
||||
|
||||
turn = get_turn_context_manager().get_turn()
|
||||
except ImportError:
|
||||
# Turn context manager not available
|
||||
pass
|
||||
|
||||
record["extra"]["turn"] = turn
|
||||
else:
|
||||
# Turn logging disabled, use default value
|
||||
record["extra"]["turn"] = 0
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Set up logging for the main application"""
|
||||
global _logging_initialized, _axiom_listener
|
||||
|
||||
# Return early if already initialized
|
||||
if _logging_initialized:
|
||||
return _axiom_listener
|
||||
|
||||
log_level = os.getenv("LOG_LEVEL", "DEBUG").upper()
|
||||
|
||||
# Don't setup logging in test environment
|
||||
if ENVIRONMENT == Environment.TEST.value:
|
||||
return
|
||||
|
||||
# Remove default loguru handler
|
||||
try:
|
||||
loguru.logger.remove(0)
|
||||
except ValueError:
|
||||
# Handler might already be removed
|
||||
pass
|
||||
|
||||
# Patch loguru to inject run_id
|
||||
patched = loguru.logger.patch(inject_run_id)
|
||||
|
||||
# Determine log format
|
||||
if ENABLE_TURN_LOGGING:
|
||||
log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | <level>{level}</level> | [run_id={extra[run_id]}] [turn={extra[turn]}] | {file.name}:{line} | {message}"
|
||||
else:
|
||||
log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | <level>{level}</level> | [run_id={extra[run_id]}] | {file.name}:{line} | {message}"
|
||||
|
||||
# Add handler - either file with rotation or console
|
||||
if LOG_FILE_PATH:
|
||||
# File handler with rotation
|
||||
rotation_config = {}
|
||||
|
||||
# Size-based rotation (e.g., "100 MB", "1 GB")
|
||||
if LOG_ROTATION_SIZE:
|
||||
rotation_config["rotation"] = LOG_ROTATION_SIZE
|
||||
|
||||
# Time-based rotation (e.g., "00:00" for daily at midnight)
|
||||
if LOG_ROTATION_TIME:
|
||||
rotation_config["rotation"] = LOG_ROTATION_TIME
|
||||
|
||||
# If no rotation specified, default to 100 MB
|
||||
if not rotation_config:
|
||||
rotation_config["rotation"] = "100 MB"
|
||||
|
||||
# Retention policy (e.g., "7 days", "10 files")
|
||||
if LOG_RETENTION:
|
||||
rotation_config["retention"] = LOG_RETENTION
|
||||
|
||||
# Compression format
|
||||
if LOG_COMPRESSION and LOG_COMPRESSION.lower() != "none":
|
||||
rotation_config["compression"] = LOG_COMPRESSION
|
||||
|
||||
# Determine the actual log file path
|
||||
actual_log_path = LOG_FILE_PATH
|
||||
|
||||
# If we're in a worker process, append worker ID to the filename
|
||||
if is_worker_process():
|
||||
worker_id = get_worker_id()
|
||||
# Split the path to insert worker ID before extension
|
||||
base_path, ext = os.path.splitext(LOG_FILE_PATH)
|
||||
actual_log_path = f"{base_path}-worker-{worker_id}{ext}"
|
||||
loguru.logger.info(f"Worker {worker_id} will log to: {actual_log_path}")
|
||||
|
||||
patched.add(
|
||||
actual_log_path,
|
||||
format=log_format,
|
||||
level=log_level,
|
||||
colorize=False, # No colors in file logs
|
||||
enqueue=True, # Thread-safe writing
|
||||
**rotation_config,
|
||||
)
|
||||
else:
|
||||
# Console handler (existing behavior)
|
||||
patched.add(
|
||||
sys.stdout,
|
||||
format=log_format,
|
||||
level=log_level,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
# Set up queue-based logging for Axiom
|
||||
log_q = queue.Queue(-1) # infinite size (tweak if needed)
|
||||
queue_handler = QueueHandler(log_q) # puts LogRecord on the queue
|
||||
queue_handler.setLevel(_get_logging_level(log_level))
|
||||
|
||||
# Set up Axiom handler if credentials are available
|
||||
axiom_token = os.environ.get("AXIOM_TOKEN")
|
||||
axiom_org = os.environ.get("AXIOM_ORG")
|
||||
axiom_dataset = os.getenv("AXIOM_LOG_DATASET")
|
||||
|
||||
if axiom_token and axiom_org and axiom_dataset:
|
||||
client = Client(token=axiom_token, org_id=axiom_org)
|
||||
axiom_handler = AxiomHandler(client, axiom_dataset)
|
||||
axiom_handler.setLevel(_get_logging_level(log_level))
|
||||
|
||||
listener = QueueListener(
|
||||
log_q,
|
||||
axiom_handler,
|
||||
respect_handler_level=True,
|
||||
)
|
||||
listener.start()
|
||||
|
||||
patched.add(queue_handler, level=log_level, enqueue=False)
|
||||
|
||||
# Register cleanup
|
||||
atexit.register(listener.stop)
|
||||
|
||||
# Return the listener for manual cleanup if needed
|
||||
loguru.logger = patched
|
||||
_logging_initialized = True
|
||||
_axiom_listener = listener
|
||||
return listener
|
||||
else:
|
||||
# No Axiom logging available
|
||||
loguru.logger = patched
|
||||
_logging_initialized = True
|
||||
return None
|
||||
1
api/native/rnnoise/librnnoise.so
Symbolic link
1
api/native/rnnoise/librnnoise.so
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
librnnoise.so.0.4.1
|
||||
1
api/native/rnnoise/librnnoise.so.0
Symbolic link
1
api/native/rnnoise/librnnoise.so.0
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
librnnoise.so.0.4.1
|
||||
BIN
api/native/rnnoise/librnnoise.so.0.4.1
Executable file
BIN
api/native/rnnoise/librnnoise.so.0.4.1
Executable file
Binary file not shown.
11
api/pytest.ini
Normal file
11
api/pytest.ini
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[pytest]
|
||||
asyncio_mode = strict
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
testpaths = .
|
||||
python_files = test_*.py *_test.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short -s
|
||||
markers =
|
||||
asyncio: mark test as an async test
|
||||
slow: mark test as slow running
|
||||
5
api/requirements.dev.txt
Normal file
5
api/requirements.dev.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mypy==1.15.0
|
||||
ruff==0.11.3
|
||||
pytest==8.3.5
|
||||
pytest-asyncio==0.26.0
|
||||
pre-commit==4.2.0
|
||||
121
api/requirements.txt
Normal file
121
api/requirements.txt
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
aenum==3.1.16
|
||||
aiofiles==24.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.11.16
|
||||
aiosignal==1.3.2
|
||||
alembic==1.15.2
|
||||
amqp==5.3.1
|
||||
annotated-types==0.7.0
|
||||
anyio==4.9.0
|
||||
asyncpg==0.30.0
|
||||
attrs==25.3.0
|
||||
audioop-lts==0.2.1; python_version>='3.13'
|
||||
av==14.2.0
|
||||
backoff==2.2.1
|
||||
billiard==4.2.1
|
||||
certifi==2025.1.31
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1
|
||||
click-repl==0.3.0
|
||||
colorama==0.4.6
|
||||
coloredlogs==15.0.1
|
||||
dataclasses-json==0.6.7
|
||||
deprecation==2.1.0
|
||||
distro==1.9.0
|
||||
docstring_parser==0.16
|
||||
eval_type_backport==0.2.2
|
||||
fastapi==0.115.12
|
||||
filelock==3.18.0
|
||||
flatbuffers==25.2.10
|
||||
frozenlist==1.5.0
|
||||
fsspec==2025.3.2
|
||||
future==1.0.0
|
||||
greenlet==3.1.1
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httpx==0.28.1
|
||||
huggingface-hub==0.30.1
|
||||
humanfriendly==10.0
|
||||
idna==3.10
|
||||
Jinja2==3.1.6
|
||||
jiter==0.9.0
|
||||
jmespath==1.0.1
|
||||
kombu==5.5.2
|
||||
langfuse==3.2.7
|
||||
llvmlite==0.44.0
|
||||
loguru==0.7.3
|
||||
Mako==1.3.9
|
||||
Markdown==3.8
|
||||
MarkupSafe==3.0.2
|
||||
marshmallow==3.26.1
|
||||
mpmath==1.3.0
|
||||
multidict==6.2.0
|
||||
mypy_extensions==1.1.0
|
||||
numba==0.61.2
|
||||
numpy==1.26.4
|
||||
onnxruntime==1.21.0
|
||||
packaging==24.2
|
||||
pillow==11.1.0
|
||||
pipecat-ai[cartesia,deepgram,openai,elevenlabs,groq,google,azure] @ git+https://github.com/dograh-hq/pipecat.git@main
|
||||
prompt_toolkit==3.0.51
|
||||
propcache==0.3.1
|
||||
protobuf==5.29.4
|
||||
psutil==7.0.0
|
||||
pycparser==2.22
|
||||
pydantic==2.10.6
|
||||
pydantic_core==2.27.2
|
||||
PyJWT==2.10.1
|
||||
pyloudnorm==0.1.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.0
|
||||
PyYAML==6.0.2
|
||||
redis==5.2.1
|
||||
regex==2024.11.6
|
||||
requests==2.32.3
|
||||
resampy==0.4.3
|
||||
safetensors==0.5.3
|
||||
scipy==1.15.2
|
||||
setuptools==75.8.0
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
sounddevice==0.5.1
|
||||
soxr==0.5.0.post1
|
||||
SQLAlchemy==2.0.40
|
||||
starlette==0.46.1
|
||||
sympy==1.13.3
|
||||
tokenizers==0.21.1
|
||||
tqdm==4.67.1
|
||||
transformers==4.50.3
|
||||
types-protobuf==4.25.0.20240417
|
||||
typing-inspect==0.9.0
|
||||
typing-inspection==0.4.0
|
||||
typing_extensions==4.13.0
|
||||
tzdata==2025.2
|
||||
urllib3==2.3.0
|
||||
uvicorn==0.34.0
|
||||
vine==5.1.0
|
||||
watchfiles==1.0.4
|
||||
wcwidth==0.2.13
|
||||
websockets==13.1
|
||||
wheel==0.45.1
|
||||
wrapt==1.17.2
|
||||
yarl==1.18.3
|
||||
aiortc==1.11.0
|
||||
opencv-python-headless==4.11.0.86
|
||||
aioboto3==14.3.0
|
||||
arq==0.26.3
|
||||
opentelemetry-sdk==1.33.1
|
||||
opentelemetry-api==1.33.1
|
||||
opentelemetry-exporter-otlp==1.33.1
|
||||
torch==2.7.0
|
||||
torchaudio==2.7.0
|
||||
axiom-py==0.9.0
|
||||
sentry-sdk[fastapi]==2.30.0
|
||||
twilio==9.7.0
|
||||
python-multipart==0.0.20
|
||||
soundfile==0.13.1
|
||||
minio==7.2.7
|
||||
alembic-postgresql-enum==1.8.0
|
||||
0
api/routes/__init__.py
Normal file
0
api/routes/__init__.py
Normal file
347
api/routes/campaign.py
Normal file
347
api/routes/campaign.py
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.campaign.runner import campaign_runner_service
|
||||
|
||||
router = APIRouter(prefix="/campaign")
|
||||
|
||||
|
||||
class CreateCampaignRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
workflow_id: int
|
||||
source_id: str # Sheet URL
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
workflow_id: int
|
||||
workflow_name: str
|
||||
state: str
|
||||
source_type: str
|
||||
source_id: str
|
||||
total_rows: Optional[int]
|
||||
processed_rows: int
|
||||
failed_rows: int
|
||||
created_at: datetime
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
|
||||
|
||||
class CampaignsResponse(BaseModel):
|
||||
campaigns: List[CampaignResponse]
|
||||
|
||||
|
||||
class WorkflowRunResponse(BaseModel):
|
||||
id: int
|
||||
workflow_id: int
|
||||
state: str
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime]
|
||||
|
||||
|
||||
class CampaignProgressResponse(BaseModel):
|
||||
campaign_id: int
|
||||
state: str
|
||||
total_rows: int
|
||||
processed_rows: int
|
||||
failed_calls: int
|
||||
progress_percentage: float
|
||||
source_sync: dict
|
||||
rate_limit: int
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_campaign(
|
||||
request: CreateCampaignRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Create a new campaign"""
|
||||
# Verify workflow exists and belongs to organization
|
||||
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
|
||||
if not workflow_name:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
campaign = await db_client.create_campaign(
|
||||
name=request.name,
|
||||
workflow_id=request.workflow_id,
|
||||
source_type="google-sheet",
|
||||
source_id=request.source_id,
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workflow_id=campaign.workflow_id,
|
||||
workflow_name=workflow_name,
|
||||
state=campaign.state,
|
||||
source_type=campaign.source_type,
|
||||
source_id=campaign.source_id,
|
||||
total_rows=campaign.total_rows,
|
||||
processed_rows=campaign.processed_rows,
|
||||
failed_rows=campaign.failed_rows,
|
||||
created_at=campaign.created_at,
|
||||
started_at=campaign.started_at,
|
||||
completed_at=campaign.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_campaigns(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignsResponse:
|
||||
"""Get campaigns for user's organization"""
|
||||
campaigns = await db_client.get_campaigns(user.selected_organization_id)
|
||||
|
||||
# Get workflow names for all campaigns
|
||||
workflow_ids = list(set(c.workflow_id for c in campaigns))
|
||||
workflows = await db_client.get_workflows_by_ids(
|
||||
workflow_ids, user.selected_organization_id
|
||||
)
|
||||
workflow_map = {w.id: w.name for w in workflows}
|
||||
|
||||
campaign_responses = [
|
||||
CampaignResponse(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
workflow_id=c.workflow_id,
|
||||
workflow_name=workflow_map.get(c.workflow_id, "Unknown"),
|
||||
state=c.state,
|
||||
source_type=c.source_type,
|
||||
source_id=c.source_id,
|
||||
total_rows=c.total_rows,
|
||||
processed_rows=c.processed_rows,
|
||||
failed_rows=c.failed_rows,
|
||||
created_at=c.created_at,
|
||||
started_at=c.started_at,
|
||||
completed_at=c.completed_at,
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
|
||||
return CampaignsResponse(campaigns=campaign_responses)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}")
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Get campaign details"""
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workflow_id=campaign.workflow_id,
|
||||
workflow_name=workflow_name or "Unknown",
|
||||
state=campaign.state,
|
||||
source_type=campaign.source_type,
|
||||
source_id=campaign.source_id,
|
||||
total_rows=campaign.total_rows,
|
||||
processed_rows=campaign.processed_rows,
|
||||
failed_rows=campaign.failed_rows,
|
||||
created_at=campaign.created_at,
|
||||
started_at=campaign.started_at,
|
||||
completed_at=campaign.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/start")
|
||||
async def start_campaign(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Start campaign execution"""
|
||||
# Check if organization has TWILIO_PHONE_NUMBERS configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
|
||||
)
|
||||
|
||||
if (
|
||||
not twilio_config
|
||||
or not twilio_config.value
|
||||
or not twilio_config.value.get("value")
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
|
||||
)
|
||||
|
||||
# Verify campaign exists and belongs to organization
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Start the campaign using the runner service
|
||||
try:
|
||||
await campaign_runner_service.start_campaign(campaign_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Get updated campaign
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workflow_id=campaign.workflow_id,
|
||||
workflow_name=workflow_name or "Unknown",
|
||||
state=campaign.state,
|
||||
source_type=campaign.source_type,
|
||||
source_id=campaign.source_id,
|
||||
total_rows=campaign.total_rows,
|
||||
processed_rows=campaign.processed_rows,
|
||||
failed_rows=campaign.failed_rows,
|
||||
created_at=campaign.created_at,
|
||||
started_at=campaign.started_at,
|
||||
completed_at=campaign.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause")
|
||||
async def pause_campaign(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Pause campaign execution"""
|
||||
# Verify campaign exists and belongs to organization
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Pause the campaign using the runner service
|
||||
try:
|
||||
await campaign_runner_service.pause_campaign(campaign_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Get updated campaign
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workflow_id=campaign.workflow_id,
|
||||
workflow_name=workflow_name or "Unknown",
|
||||
state=campaign.state,
|
||||
source_type=campaign.source_type,
|
||||
source_id=campaign.source_id,
|
||||
total_rows=campaign.total_rows,
|
||||
processed_rows=campaign.processed_rows,
|
||||
failed_rows=campaign.failed_rows,
|
||||
created_at=campaign.created_at,
|
||||
started_at=campaign.started_at,
|
||||
completed_at=campaign.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/runs")
|
||||
async def get_campaign_runs(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> List[WorkflowRunResponse]:
|
||||
"""Get campaign workflow runs"""
|
||||
runs = await db_client.get_campaign_runs(campaign_id, user.selected_organization_id)
|
||||
|
||||
return [
|
||||
WorkflowRunResponse(
|
||||
id=run.id,
|
||||
workflow_id=run.workflow_id,
|
||||
state="completed" if run.is_completed else "running",
|
||||
created_at=run.created_at,
|
||||
completed_at=run.created_at if run.is_completed else None,
|
||||
)
|
||||
for run in runs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/resume")
|
||||
async def resume_campaign(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Resume a paused campaign"""
|
||||
# Check if organization has TWILIO_PHONE_NUMBERS configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
|
||||
)
|
||||
|
||||
if (
|
||||
not twilio_config
|
||||
or not twilio_config.value
|
||||
or not twilio_config.value.get("value")
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
|
||||
)
|
||||
|
||||
# Verify campaign exists and belongs to organization
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Resume the campaign using the runner service
|
||||
try:
|
||||
await campaign_runner_service.resume_campaign(campaign_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Get updated campaign
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workflow_id=campaign.workflow_id,
|
||||
workflow_name=workflow_name or "Unknown",
|
||||
state=campaign.state,
|
||||
source_type=campaign.source_type,
|
||||
source_id=campaign.source_id,
|
||||
total_rows=campaign.total_rows,
|
||||
processed_rows=campaign.processed_rows,
|
||||
failed_rows=campaign.failed_rows,
|
||||
created_at=campaign.created_at,
|
||||
started_at=campaign.started_at,
|
||||
completed_at=campaign.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/progress")
|
||||
async def get_campaign_progress(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignProgressResponse:
|
||||
"""Get current campaign progress and statistics"""
|
||||
# Verify campaign exists and belongs to organization
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Get progress from runner service
|
||||
try:
|
||||
progress = await campaign_runner_service.get_campaign_status(campaign_id)
|
||||
return CampaignProgressResponse(**progress)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
262
api/routes/integration.py
Normal file
262
api/routes/integration.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.integrations.nango import nango_service
|
||||
|
||||
router = APIRouter(prefix="/integration")
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegrationResponse:
|
||||
id: int
|
||||
integration_id: str
|
||||
organisation_id: int
|
||||
created_by: Optional[int]
|
||||
provider: str
|
||||
is_active: bool
|
||||
created_at: str
|
||||
action: str
|
||||
provider_data: dict
|
||||
|
||||
|
||||
class SessionResponse(TypedDict):
|
||||
session_token: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class WebhookResponse(TypedDict):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateIntegrationRequest(BaseModel):
|
||||
selected_files: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class AccessTokenResponse(BaseModel):
|
||||
access_token: Optional[str]
|
||||
refresh_token: Optional[str]
|
||||
expires_at: Optional[str]
|
||||
connection_id: str
|
||||
|
||||
|
||||
def build_integration_response(integration) -> IntegrationResponse:
|
||||
"""Build a standardized integration response with provider-specific data."""
|
||||
provider_data = {}
|
||||
|
||||
if integration.provider == "google-sheet":
|
||||
# For Google Sheets, include selected_files
|
||||
provider_data["selected_files"] = integration.connection_details.get(
|
||||
"selected_files", []
|
||||
)
|
||||
elif integration.provider == "slack":
|
||||
# For Slack, include channel information
|
||||
channel = integration.connection_details.get("connection_config", {}).get(
|
||||
"incoming_webhook.channel"
|
||||
)
|
||||
if channel:
|
||||
provider_data["channel"] = channel
|
||||
|
||||
return IntegrationResponse(
|
||||
id=integration.id,
|
||||
integration_id=integration.integration_id,
|
||||
organisation_id=integration.organisation_id,
|
||||
created_by=integration.created_by,
|
||||
provider=integration.provider,
|
||||
is_active=integration.is_active,
|
||||
created_at=integration.created_at.isoformat(),
|
||||
action=integration.action,
|
||||
provider_data=provider_data,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_integrations(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> list[IntegrationResponse]:
|
||||
"""
|
||||
Get all integrations for the user's selected organization.
|
||||
|
||||
Returns:
|
||||
List of integrations associated with the user's selected organization
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No organization selected for the user"
|
||||
)
|
||||
|
||||
integrations = await db_client.get_integrations_by_organization_id(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
return [build_integration_response(integration) for integration in integrations]
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
async def create_session(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Create a Nango session for the user's selected organization.
|
||||
|
||||
Returns:
|
||||
Session token and ID for the created session
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No organization selected for the user"
|
||||
)
|
||||
|
||||
try:
|
||||
session_data = await nango_service.create_session(
|
||||
user_id=str(user.id), organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
return {
|
||||
"session_token": session_data["data"]["token"],
|
||||
"expires_at": session_data["data"]["expires_at"],
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to create session: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{integration_id}")
|
||||
async def update_integration(
|
||||
integration_id: int,
|
||||
request: UpdateIntegrationRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> IntegrationResponse:
|
||||
"""
|
||||
Update an integration's selected files (for Google Sheets).
|
||||
|
||||
Args:
|
||||
integration_id: The ID of the integration to update
|
||||
request: The update request containing selected files
|
||||
user: The authenticated user
|
||||
|
||||
Returns:
|
||||
Updated integration details
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No organization selected for the user"
|
||||
)
|
||||
|
||||
# Get the integration first to verify ownership
|
||||
integrations = await db_client.get_integrations_by_organization_id(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
integration = next((i for i in integrations if i.id == integration_id), None)
|
||||
if not integration:
|
||||
raise HTTPException(status_code=404, detail="Integration not found")
|
||||
|
||||
# Only allow updating selected_files for google-sheet provider
|
||||
if integration.provider != "google-sheet":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This endpoint only supports updating Google Sheet integrations",
|
||||
)
|
||||
|
||||
# Update the connection_details with the new selected_files
|
||||
updated_connection_details = integration.connection_details.copy()
|
||||
updated_connection_details["selected_files"] = request.selected_files
|
||||
|
||||
# Update the integration
|
||||
updated_integration = await db_client.update_integration_connection_details(
|
||||
integration_id=integration_id, connection_details=updated_connection_details
|
||||
)
|
||||
|
||||
if not updated_integration:
|
||||
raise HTTPException(status_code=500, detail="Failed to update integration")
|
||||
|
||||
return build_integration_response(updated_integration)
|
||||
|
||||
|
||||
@router.get("/{integration_id}/access-token")
|
||||
async def get_integration_access_token(
|
||||
integration_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
Get the latest access token for an integration from Nango.
|
||||
|
||||
Args:
|
||||
integration_id: The ID of the integration
|
||||
user: The authenticated user
|
||||
|
||||
Returns:
|
||||
Dict containing access token and expiration info
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No organization selected for the user"
|
||||
)
|
||||
|
||||
# Get the integration to verify ownership and get connection details
|
||||
integrations = await db_client.get_integrations_by_organization_id(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
integration = next((i for i in integrations if i.id == integration_id), None)
|
||||
if not integration:
|
||||
raise HTTPException(status_code=404, detail="Integration not found")
|
||||
|
||||
try:
|
||||
# Fetch the latest access token from Nango
|
||||
token_data = await nango_service.get_access_token(
|
||||
connection_id=integration.integration_id,
|
||||
provider_config_key=integration.provider,
|
||||
)
|
||||
|
||||
# Extract relevant fields
|
||||
return AccessTokenResponse(
|
||||
access_token=token_data.get("credentials", {}).get("access_token"),
|
||||
refresh_token=token_data.get("credentials", {}).get("refresh_token"),
|
||||
expires_at=token_data.get("credentials", {}).get("expires_at"),
|
||||
connection_id=integration.integration_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get access token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch access token: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhook", include_in_schema=False)
|
||||
async def handle_nango_webhook(
|
||||
request: Request,
|
||||
) -> WebhookResponse:
|
||||
"""
|
||||
Handle Nango integration webhook requests.
|
||||
|
||||
Processes webhook events from Nango when integrations are created/updated
|
||||
and stores the integration details in the database.
|
||||
|
||||
Args:
|
||||
request: The raw FastAPI request object
|
||||
|
||||
Returns:
|
||||
WebhookResponse with status and message
|
||||
"""
|
||||
raw_body = await request.body()
|
||||
|
||||
# Get signature from headers (you may need to adjust the header name)
|
||||
signature = request.headers.get("X-Nango-Signature")
|
||||
|
||||
# Use the nango service to process the webhook
|
||||
result = await nango_service.process_webhook(raw_body, signature)
|
||||
|
||||
return result
|
||||
316
api/routes/looptalk.py
Normal file
316
api/routes/looptalk.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
WebSocket,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.looptalk.orchestrator import LoopTalkTestOrchestrator
|
||||
|
||||
router = APIRouter(prefix="/looptalk")
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class CreateTestSessionRequest(BaseModel):
|
||||
name: str
|
||||
actor_workflow_id: int
|
||||
adversary_workflow_id: int
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StartTestSessionRequest(BaseModel):
|
||||
test_session_id: int
|
||||
|
||||
|
||||
class CreateLoadTestRequest(BaseModel):
|
||||
name_prefix: str
|
||||
actor_workflow_id: int
|
||||
adversary_workflow_id: int
|
||||
test_count: int = Field(ge=1, le=10)
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TestSessionResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
status: str
|
||||
actor_workflow_id: int
|
||||
adversary_workflow_id: int
|
||||
load_test_group_id: Optional[str]
|
||||
test_index: Optional[int]
|
||||
config: Dict[str, Any]
|
||||
results: Optional[Dict[str, Any]]
|
||||
error: Optional[str]
|
||||
created_at: datetime
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: int
|
||||
test_session_id: int
|
||||
duration_seconds: Optional[int]
|
||||
actor_recording_url: Optional[str]
|
||||
adversary_recording_url: Optional[str]
|
||||
combined_recording_url: Optional[str]
|
||||
transcript: Optional[Dict[str, Any]]
|
||||
metrics: Optional[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
ended_at: Optional[datetime]
|
||||
|
||||
|
||||
# Note: Turn tracking is handled by Langfuse, not exposed via API
|
||||
|
||||
|
||||
class LoadTestStatsResponse(BaseModel):
|
||||
total: int
|
||||
pending: int
|
||||
running: int
|
||||
completed: int
|
||||
failed: int
|
||||
sessions: List[Dict[str, Any]]
|
||||
|
||||
|
||||
# Singleton orchestrator instance
|
||||
_orchestrator: Optional[LoopTalkTestOrchestrator] = None
|
||||
|
||||
|
||||
def get_orchestrator() -> LoopTalkTestOrchestrator:
|
||||
"""Get or create the LoopTalk orchestrator instance."""
|
||||
global _orchestrator
|
||||
if _orchestrator is None:
|
||||
_orchestrator = LoopTalkTestOrchestrator(db_client=db_client)
|
||||
return _orchestrator
|
||||
|
||||
|
||||
@router.post("/test-sessions", response_model=TestSessionResponse)
|
||||
async def create_test_session(
|
||||
request: CreateTestSessionRequest, user: UserModel = Depends(get_user)
|
||||
):
|
||||
"""Create a new LoopTalk test session."""
|
||||
|
||||
# Verify user has access to both workflows
|
||||
actor_workflow = await db_client.get_workflow(request.actor_workflow_id, user.id)
|
||||
if not actor_workflow:
|
||||
raise HTTPException(status_code=404, detail="Actor workflow not found")
|
||||
|
||||
adversary_workflow = await db_client.get_workflow(
|
||||
request.adversary_workflow_id, user.id
|
||||
)
|
||||
if not adversary_workflow:
|
||||
raise HTTPException(status_code=404, detail="Adversary workflow not found")
|
||||
|
||||
# Create test session
|
||||
test_session = await db_client.create_test_session(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
actor_workflow_id=request.actor_workflow_id,
|
||||
adversary_workflow_id=request.adversary_workflow_id,
|
||||
config=request.config,
|
||||
)
|
||||
|
||||
return test_session
|
||||
|
||||
|
||||
@router.get("/test-sessions", response_model=List[TestSessionResponse])
|
||||
async def list_test_sessions(
|
||||
status: Optional[str] = None,
|
||||
load_test_group_id: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""List LoopTalk test sessions."""
|
||||
|
||||
test_sessions = await db_client.list_test_sessions(
|
||||
organization_id=user.selected_organization_id,
|
||||
status=status,
|
||||
load_test_group_id=load_test_group_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return test_sessions
|
||||
|
||||
|
||||
@router.get("/test-sessions/{test_session_id}", response_model=TestSessionResponse)
|
||||
async def get_test_session(test_session_id: int, user: UserModel = Depends(get_user)):
|
||||
"""Get a specific test session."""
|
||||
|
||||
test_session = await db_client.get_test_session(
|
||||
test_session_id=test_session_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
if not test_session:
|
||||
raise HTTPException(status_code=404, detail="Test session not found")
|
||||
|
||||
return test_session
|
||||
|
||||
|
||||
@router.post("/test-sessions/{test_session_id}/start")
|
||||
async def start_test_session(
|
||||
test_session_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: UserModel = Depends(get_user),
|
||||
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
|
||||
):
|
||||
"""Start a LoopTalk test session."""
|
||||
|
||||
# Verify test session exists and user has access
|
||||
test_session = await db_client.get_test_session(
|
||||
test_session_id=test_session_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
if not test_session:
|
||||
raise HTTPException(status_code=404, detail="Test session not found")
|
||||
|
||||
if test_session.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Test session is {test_session.status}, not pending",
|
||||
)
|
||||
|
||||
# Start test session in background
|
||||
background_tasks.add_task(
|
||||
orchestrator.start_test_session,
|
||||
test_session_id=test_session_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
return {"message": "Test session starting", "test_session_id": test_session_id}
|
||||
|
||||
|
||||
@router.post("/test-sessions/{test_session_id}/stop")
|
||||
async def stop_test_session(
|
||||
test_session_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
|
||||
):
|
||||
"""Stop a running test session."""
|
||||
|
||||
# Verify test session exists and user has access
|
||||
test_session = await db_client.get_test_session(
|
||||
test_session_id=test_session_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
if not test_session:
|
||||
raise HTTPException(status_code=404, detail="Test session not found")
|
||||
|
||||
if test_session.status != "running":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Test session is {test_session.status}, not running",
|
||||
)
|
||||
|
||||
# Stop test session
|
||||
result = await orchestrator.stop_test_session(test_session_id=test_session_id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/test-sessions/{test_session_id}/conversation")
|
||||
async def get_test_session_conversation(
|
||||
test_session_id: int, user: UserModel = Depends(get_user)
|
||||
):
|
||||
"""Get conversation details for a test session."""
|
||||
|
||||
# Verify test session exists and user has access
|
||||
test_session = await db_client.get_test_session(
|
||||
test_session_id=test_session_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
if not test_session:
|
||||
raise HTTPException(status_code=404, detail="Test session not found")
|
||||
|
||||
# Get conversation
|
||||
if test_session.conversations:
|
||||
conversation = test_session.conversations[
|
||||
0
|
||||
] # For now, one conversation per session
|
||||
|
||||
# Note: Turn details are available in Langfuse, not here
|
||||
return {
|
||||
"conversation": conversation,
|
||||
"message": "Turn details are tracked in Langfuse",
|
||||
}
|
||||
|
||||
return {"conversation": None}
|
||||
|
||||
|
||||
@router.post("/load-tests", response_model=Dict[str, Any])
|
||||
async def create_load_test(
|
||||
request: CreateLoadTestRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: UserModel = Depends(get_user),
|
||||
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
|
||||
):
|
||||
"""Create and start a load test."""
|
||||
|
||||
# Verify user has access to both workflows
|
||||
actor_workflow = await db_client.get_workflow(request.actor_workflow_id, user.id)
|
||||
if not actor_workflow:
|
||||
raise HTTPException(status_code=404, detail="Actor workflow not found")
|
||||
|
||||
adversary_workflow = await db_client.get_workflow(
|
||||
request.adversary_workflow_id, user.id
|
||||
)
|
||||
if not adversary_workflow:
|
||||
raise HTTPException(status_code=404, detail="Adversary workflow not found")
|
||||
|
||||
# Start load test in background
|
||||
result = await orchestrator.start_load_test(
|
||||
organization_id=user.selected_organization_id,
|
||||
name_prefix=request.name_prefix,
|
||||
actor_workflow_id=request.actor_workflow_id,
|
||||
adversary_workflow_id=request.adversary_workflow_id,
|
||||
config=request.config,
|
||||
test_count=request.test_count,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/load-tests/{load_test_group_id}/stats", response_model=LoadTestStatsResponse
|
||||
)
|
||||
async def get_load_test_stats(
|
||||
load_test_group_id: str, user: UserModel = Depends(get_user)
|
||||
):
|
||||
"""Get statistics for a load test group."""
|
||||
|
||||
stats = await db_client.get_load_test_group_stats(
|
||||
load_test_group_id=load_test_group_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/active-tests")
|
||||
async def get_active_tests(
|
||||
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Get information about currently active test sessions."""
|
||||
|
||||
return orchestrator.get_active_test_info()
|
||||
|
||||
|
||||
@router.websocket("/test-sessions/{test_session_id}/audio-stream")
|
||||
async def audio_stream_websocket(
|
||||
websocket: WebSocket,
|
||||
test_session_id: int,
|
||||
role: str = "mixed", # "actor", "adversary", or "mixed"
|
||||
token: Optional[str] = None,
|
||||
):
|
||||
"""WebSocket endpoint for real-time audio streaming from LoopTalk test sessions."""
|
||||
# TODO: to be implemented
|
||||
pass
|
||||
39
api/routes/main.py
Normal file
39
api/routes/main.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from fastapi import APIRouter
|
||||
from loguru import logger
|
||||
|
||||
from api.routes.campaign import router as campaign_router
|
||||
from api.routes.integration import router as integration_router
|
||||
from api.routes.looptalk import router as looptalk_router
|
||||
from api.routes.organization_usage import router as organization_usage_router
|
||||
from api.routes.reports import router as reports_router
|
||||
from api.routes.rtc_offer import router as rtc_offer_router
|
||||
from api.routes.s3_signed_url import router as s3_router
|
||||
from api.routes.service_keys import router as service_keys_router
|
||||
from api.routes.superuser import router as superuser_router
|
||||
from api.routes.twilio import router as twilio_router
|
||||
from api.routes.user import router as user_router
|
||||
from api.routes.workflow import router as workflow_router
|
||||
|
||||
router = APIRouter(
|
||||
tags=["main"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
router.include_router(twilio_router)
|
||||
router.include_router(rtc_offer_router)
|
||||
router.include_router(superuser_router)
|
||||
router.include_router(workflow_router)
|
||||
router.include_router(user_router)
|
||||
router.include_router(campaign_router)
|
||||
router.include_router(integration_router)
|
||||
router.include_router(s3_router)
|
||||
router.include_router(service_keys_router)
|
||||
router.include_router(looptalk_router)
|
||||
router.include_router(organization_usage_router)
|
||||
router.include_router(reports_router)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
logger.debug("Health endpoint called")
|
||||
return {"message": "OK"}
|
||||
180
api/routes/organization_usage.py
Normal file
180
api/routes/organization_usage.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_user
|
||||
|
||||
router = APIRouter(prefix="/organizations")
|
||||
|
||||
|
||||
class CurrentUsageResponse(BaseModel):
|
||||
period_start: str
|
||||
period_end: str
|
||||
used_dograh_tokens: float
|
||||
quota_dograh_tokens: int
|
||||
percentage_used: float
|
||||
next_refresh_date: str
|
||||
quota_enabled: bool
|
||||
total_duration_seconds: int
|
||||
# New USD fields
|
||||
used_amount_usd: Optional[float] = None
|
||||
quota_amount_usd: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
price_per_second_usd: Optional[float] = None
|
||||
|
||||
|
||||
class WorkflowRunUsageResponse(BaseModel):
|
||||
id: int
|
||||
workflow_id: int
|
||||
workflow_name: Optional[str]
|
||||
name: str
|
||||
created_at: str
|
||||
dograh_token_usage: float
|
||||
call_duration_seconds: int
|
||||
recording_url: Optional[str] = None
|
||||
transcript_url: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
disposition: Optional[str] = None
|
||||
initial_context: Optional[Dict[str, Any]] = None
|
||||
gathered_context: Optional[Dict[str, Any]] = None
|
||||
# New USD field
|
||||
charge_usd: Optional[float] = None
|
||||
|
||||
|
||||
class UsageHistoryResponse(BaseModel):
|
||||
runs: List[WorkflowRunUsageResponse]
|
||||
total_dograh_tokens: float
|
||||
total_duration_seconds: int
|
||||
total_count: int
|
||||
page: int
|
||||
limit: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class DailyUsageItem(BaseModel):
|
||||
date: str
|
||||
minutes: float
|
||||
cost_usd: Optional[float] = None
|
||||
dograh_tokens: float
|
||||
call_count: int
|
||||
|
||||
|
||||
class DailyUsageBreakdownResponse(BaseModel):
|
||||
breakdown: List[DailyUsageItem]
|
||||
total_minutes: float
|
||||
total_cost_usd: Optional[float] = None
|
||||
total_dograh_tokens: float
|
||||
currency: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/usage/current-period", response_model=CurrentUsageResponse)
|
||||
async def get_current_period_usage(user: UserModel = Depends(get_user)):
|
||||
"""Get current billing period usage for the user's organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
try:
|
||||
usage = await db_client.get_current_usage(user.selected_organization_id)
|
||||
return usage
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/usage/runs", response_model=UsageHistoryResponse)
|
||||
async def get_usage_history(
|
||||
start_date: Optional[str] = Query(None, description="ISO format date string"),
|
||||
end_date: Optional[str] = Query(None, description="ISO format date string"),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
filters: Optional[str] = Query(None, description="JSON string of filters"),
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Get paginated workflow runs with usage for the organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Parse dates if provided
|
||||
start_dt = datetime.fromisoformat(start_date) if start_date else None
|
||||
end_dt = datetime.fromisoformat(end_date) if end_date else None
|
||||
|
||||
# Parse filters if provided
|
||||
parsed_filters = None
|
||||
if filters:
|
||||
try:
|
||||
parsed_filters = json.loads(filters)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid filters format")
|
||||
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
(
|
||||
runs,
|
||||
total_count,
|
||||
total_tokens,
|
||||
total_duration,
|
||||
) = await db_client.get_usage_history(
|
||||
user.selected_organization_id,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filters=parsed_filters,
|
||||
)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"runs": runs,
|
||||
"total_dograh_tokens": total_tokens,
|
||||
"total_duration_seconds": total_duration,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/usage/daily-breakdown", response_model=DailyUsageBreakdownResponse)
|
||||
async def get_daily_usage_breakdown(
|
||||
days: int = Query(7, ge=1, le=30, description="Number of days to include"),
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Get daily usage breakdown for the last N days. Only available for organizations with pricing."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
try:
|
||||
# Get organization to check if it has pricing
|
||||
org = await db_client.get_organization_by_id(user.selected_organization_id)
|
||||
if not org or org.price_per_second_usd is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Daily breakdown is only available for organizations with pricing configured",
|
||||
)
|
||||
|
||||
# Calculate date range
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days - 1)
|
||||
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Get daily breakdown
|
||||
breakdown = await db_client.get_daily_usage_breakdown(
|
||||
user.selected_organization_id,
|
||||
start_date,
|
||||
end_date,
|
||||
org.price_per_second_usd,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
return breakdown
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
132
api/routes/reports.py
Normal file
132
api/routes/reports.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.reports import DailyReportService
|
||||
|
||||
router = APIRouter(prefix="/organizations/reports")
|
||||
|
||||
|
||||
class DailyReportResponse(BaseModel):
|
||||
date: str
|
||||
timezone: str
|
||||
workflow_id: Optional[int]
|
||||
metrics: Dict[str, int]
|
||||
disposition_distribution: List[Dict[str, Any]]
|
||||
call_duration_distribution: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class WorkflowOption(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class WorkflowRunDetail(BaseModel):
|
||||
phone_number: str
|
||||
disposition: str
|
||||
duration_seconds: float
|
||||
workflow_id: int
|
||||
run_id: int
|
||||
workflow_name: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyReportResponse)
|
||||
async def get_daily_report(
|
||||
date: str = Query(..., description="Date in YYYY-MM-DD format"),
|
||||
timezone: str = Query(..., description="IANA timezone (e.g., 'America/New_York')"),
|
||||
workflow_id: Optional[int] = Query(
|
||||
None, description="Optional workflow ID to filter by"
|
||||
),
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> DailyReportResponse:
|
||||
"""
|
||||
Get daily report for the specified date and timezone.
|
||||
If workflow_id is provided, filters results to that specific workflow.
|
||||
If workflow_id is None, includes all workflows for the organization.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Validate date format
|
||||
try:
|
||||
datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid date format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
report_service = DailyReportService()
|
||||
|
||||
try:
|
||||
report = await report_service.get_daily_report(
|
||||
organization_id=user.selected_organization_id,
|
||||
date=date,
|
||||
timezone=timezone,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
return DailyReportResponse(**report)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/workflows", response_model=List[WorkflowOption])
|
||||
async def get_workflow_options(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> List[WorkflowOption]:
|
||||
"""
|
||||
Get all workflows for the user's organization.
|
||||
Used to populate the workflow selector dropdown in the reports page.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
report_service = DailyReportService()
|
||||
|
||||
workflows = await report_service.get_workflows_for_organization(
|
||||
organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
return [WorkflowOption(**w) for w in workflows]
|
||||
|
||||
|
||||
@router.get("/daily/runs", response_model=List[WorkflowRunDetail])
|
||||
async def get_daily_runs_detail(
|
||||
date: str = Query(..., description="Date in YYYY-MM-DD format"),
|
||||
timezone: str = Query(..., description="IANA timezone (e.g., 'America/New_York')"),
|
||||
workflow_id: Optional[int] = Query(
|
||||
None, description="Optional workflow ID to filter by"
|
||||
),
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> List[WorkflowRunDetail]:
|
||||
"""
|
||||
Get detailed workflow runs for the specified date.
|
||||
Used for CSV export functionality.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Validate date format
|
||||
try:
|
||||
datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid date format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
report_service = DailyReportService()
|
||||
|
||||
try:
|
||||
runs = await report_service.get_daily_runs_detail(
|
||||
organization_id=user.selected_organization_id,
|
||||
date=date,
|
||||
timezone=timezone,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
return [WorkflowRunDetail(**run) for run in runs]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
77
api/routes/rtc_offer.py
Normal file
77
api/routes/rtc_offer.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from loguru import logger
|
||||
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
|
||||
|
||||
router = APIRouter(prefix="/pipecat")
|
||||
|
||||
pcs_map: Dict[str, SmallWebRTCConnection] = {}
|
||||
ice_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
|
||||
class RTCOfferRequest(BaseModel):
|
||||
pc_id: str | None
|
||||
sdp: str
|
||||
type: str
|
||||
workflow_id: int
|
||||
workflow_run_id: int
|
||||
restart_pc: bool = False
|
||||
call_context_vars: dict | None = None
|
||||
|
||||
|
||||
@router.post("/rtc-offer")
|
||||
async def offer(
|
||||
request: RTCOfferRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
pc_id = request.pc_id
|
||||
|
||||
if pc_id and pc_id in pcs_map:
|
||||
# Ensure run_id context is available for logs even when reusing an existing PC.
|
||||
set_current_run_id(request.workflow_run_id)
|
||||
|
||||
pipecat_connection = pcs_map[pc_id]
|
||||
logger.info(f"Reusing existing connection for pc_id: {pc_id}")
|
||||
await pipecat_connection.renegotiate(
|
||||
sdp=request.sdp,
|
||||
type=request.type,
|
||||
restart_pc=request.restart_pc,
|
||||
)
|
||||
else:
|
||||
# Set the run_id *before* creating the SmallWebRTCConnection so that all
|
||||
# async tasks and event-handler coroutines spawned inside the
|
||||
# constructor inherit the correct context variable value. Otherwise the
|
||||
# default ("NA") leaks into the log output produced by those tasks.
|
||||
set_current_run_id(request.workflow_run_id)
|
||||
|
||||
pipecat_connection = SmallWebRTCConnection(ice_servers)
|
||||
await pipecat_connection.initialize(sdp=request.sdp, type=request.type)
|
||||
|
||||
@pipecat_connection.event_handler("closed")
|
||||
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
|
||||
logger.info(
|
||||
f"In pipecat connection closed handler. Popping peer connection pc_id: {webrtc_connection.pc_id} from pcs_map"
|
||||
)
|
||||
pcs_map.pop(webrtc_connection.pc_id, None)
|
||||
|
||||
background_tasks.add_task(
|
||||
run_pipeline_smallwebrtc,
|
||||
pipecat_connection,
|
||||
request.workflow_id,
|
||||
request.workflow_run_id,
|
||||
user.id,
|
||||
request.call_context_vars or {},
|
||||
)
|
||||
|
||||
answer = pipecat_connection.get_answer()
|
||||
pcs_map[answer["pc_id"]] = pipecat_connection
|
||||
|
||||
return answer
|
||||
219
api/routes/s3_signed_url.py
Normal file
219
api/routes/s3_signed_url.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
from typing import Annotated, Any, Dict, Optional, TypedDict
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import StorageBackend
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.storage import get_storage_for_backend, storage_fs
|
||||
|
||||
|
||||
class S3SignedUrlResponse(TypedDict):
|
||||
url: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class FileMetadataResponse(TypedDict):
|
||||
key: str
|
||||
metadata: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
router = APIRouter(prefix="/s3", tags=["s3"])
|
||||
|
||||
|
||||
async def _validate_and_extract_workflow_run_id(
|
||||
key: str, allow_special_paths: bool = False
|
||||
) -> Optional[int]:
|
||||
"""Validate the S3 key format and extract workflow_run_id if present.
|
||||
|
||||
Args:
|
||||
key: S3 object key
|
||||
allow_special_paths: If True, allows looptalk/voicemail paths
|
||||
|
||||
Returns:
|
||||
workflow_run_id if found, None for special paths (when allowed)
|
||||
|
||||
Raises:
|
||||
HTTPException: If key format is invalid
|
||||
"""
|
||||
if key.startswith("transcripts/") and key.endswith(".txt"):
|
||||
run_id_str = key[len("transcripts/") : -4] # strip prefix & suffix
|
||||
elif key.startswith("recordings/") and key.endswith(".wav"):
|
||||
run_id_str = key[len("recordings/") : -4]
|
||||
elif allow_special_paths and (
|
||||
key.startswith("looptalk/") or key.startswith("voicemail_detections/")
|
||||
):
|
||||
# Allow looptalk and voicemail paths for debugging (only if explicitly allowed)
|
||||
return None # Skip validation for these paths
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid key format")
|
||||
|
||||
if not run_id_str.isdigit():
|
||||
raise HTTPException(status_code=400, detail="Invalid workflow_run_id in key")
|
||||
|
||||
return int(run_id_str)
|
||||
|
||||
|
||||
async def _authorize_and_get_workflow_run(
|
||||
run_id: Optional[int], user, require_workflow_run: bool = True
|
||||
) -> Optional[Any]:
|
||||
"""Authorize access to workflow run and retrieve it.
|
||||
|
||||
Args:
|
||||
run_id: Workflow run ID (can be None for special paths)
|
||||
user: Current user from auth
|
||||
require_workflow_run: If True, raises exception when run not found
|
||||
|
||||
Returns:
|
||||
WorkflowRunModel or None
|
||||
|
||||
Raises:
|
||||
HTTPException: If access is denied
|
||||
"""
|
||||
if run_id is None:
|
||||
return None
|
||||
|
||||
workflow_run = None
|
||||
if not user.is_superuser:
|
||||
# Regular users: Use organization_id to check access (security constraint)
|
||||
workflow_run = await db_client.get_workflow_run(
|
||||
run_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow_run and require_workflow_run:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied for this workflow run"
|
||||
)
|
||||
else:
|
||||
# Superusers: Use get_workflow_run_by_id (no user/org constraint needed)
|
||||
workflow_run = await db_client.get_workflow_run_by_id(run_id)
|
||||
|
||||
return workflow_run
|
||||
|
||||
|
||||
@router.get(
|
||||
"/signed-url",
|
||||
response_model=S3SignedUrlResponse,
|
||||
summary="Generate a signed S3 URL",
|
||||
)
|
||||
async def get_signed_url(
|
||||
key: Annotated[str, Query(description="S3 object key")],
|
||||
expires_in: int = 3600,
|
||||
inline: bool = False,
|
||||
user=Depends(get_user),
|
||||
):
|
||||
"""Return a short-lived signed URL for a transcript or recording file stored on S3.
|
||||
|
||||
Access Control:
|
||||
* Superusers can request any key.
|
||||
* Regular users can only request resources belonging to **their** workflow runs.
|
||||
"""
|
||||
|
||||
# Validate key and extract workflow_run_id (don't allow special paths for signed URLs)
|
||||
run_id = await _validate_and_extract_workflow_run_id(key, allow_special_paths=False)
|
||||
if run_id is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid key format")
|
||||
|
||||
# Authorize and get workflow run
|
||||
workflow_run = await _authorize_and_get_workflow_run(run_id, user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Generate the signed URL using the correct storage backend
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
# Use the storage backend recorded when the file was uploaded
|
||||
if (
|
||||
workflow_run
|
||||
and hasattr(workflow_run, "storage_backend")
|
||||
and workflow_run.storage_backend
|
||||
):
|
||||
backend = workflow_run.storage_backend
|
||||
storage = get_storage_for_backend(backend)
|
||||
logger.info(
|
||||
f"DOWNLOAD: Using stored {backend} (value: {backend}) for signed URL generation - workflow_run_id: {run_id}, key: {key}"
|
||||
)
|
||||
else:
|
||||
# Fallback to current storage for legacy records without storage_backend
|
||||
storage = storage_fs
|
||||
current_backend = StorageBackend.get_current_backend()
|
||||
logger.warning(
|
||||
f"DOWNLOAD: No storage_backend found for workflow run {run_id}, falling back to current {current_backend.name} - key: {key}"
|
||||
)
|
||||
|
||||
url = await storage.aget_signed_url(
|
||||
key, expiration=expires_in, force_inline=inline
|
||||
)
|
||||
if not url:
|
||||
raise HTTPException(status_code=500, detail="Failed to generate signed URL")
|
||||
|
||||
# Log successful URL generation
|
||||
backend_info = (
|
||||
f"stored {backend}"
|
||||
if workflow_run
|
||||
and hasattr(workflow_run, "storage_backend")
|
||||
and workflow_run.storage_backend
|
||||
else f"current {StorageBackend.get_current_backend().name}"
|
||||
)
|
||||
logger.info(
|
||||
f"Successfully generated signed URL using {backend_info} - expires in {expires_in}s"
|
||||
)
|
||||
|
||||
return {"url": url, "expires_in": expires_in}
|
||||
except ClientError as exc:
|
||||
logger.error(f"Error generating signed URL: {exc}")
|
||||
raise HTTPException(status_code=500, detail="Failed to generate signed URL")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/file-metadata",
|
||||
response_model=FileMetadataResponse,
|
||||
summary="Get file metadata for debugging",
|
||||
)
|
||||
async def get_file_metadata(
|
||||
key: Annotated[str, Query(description="S3 object key")],
|
||||
user=Depends(get_user),
|
||||
):
|
||||
"""Get file metadata including creation timestamp for debugging.
|
||||
|
||||
Access Control:
|
||||
* Superusers can request any key.
|
||||
* Regular users can only request resources belonging to **their** workflow runs.
|
||||
"""
|
||||
|
||||
# Validate key and extract workflow_run_id (allow special paths for metadata)
|
||||
run_id = await _validate_and_extract_workflow_run_id(key, allow_special_paths=True)
|
||||
|
||||
# Authorize and get workflow run (for special paths, run_id might be None)
|
||||
workflow_run = await _authorize_and_get_workflow_run(
|
||||
run_id, user, require_workflow_run=False
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Get file metadata using the correct storage backend
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
# Use the storage backend recorded when the file was uploaded
|
||||
if (
|
||||
workflow_run
|
||||
and hasattr(workflow_run, "storage_backend")
|
||||
and workflow_run.storage_backend
|
||||
):
|
||||
backend = workflow_run.storage_backend
|
||||
storage = get_storage_for_backend(backend)
|
||||
logger.info(
|
||||
f"METADATA: Using stored {backend} for metadata request - key: {key}"
|
||||
)
|
||||
else:
|
||||
# Fallback to current storage for legacy records or looptalk/voicemail files
|
||||
storage = storage_fs
|
||||
current_backend = StorageBackend.get_current_backend()
|
||||
logger.warning(
|
||||
f"METADATA: No storage_backend found, using current {current_backend.name} for metadata request - key: {key}"
|
||||
)
|
||||
|
||||
metadata = await storage.aget_file_metadata(key)
|
||||
return {"key": key, "metadata": metadata}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error getting file metadata: {exc}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get file metadata")
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue