mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
Initial Commit 🚀 🚀
This commit is contained in:
commit
4f2a629340
444 changed files with 76863 additions and 0 deletions
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")
|
||||
141
api/routes/service_keys.py
Normal file
141
api/routes/service_keys.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.constants import DEPLOYMENT_MODE
|
||||
from api.db.models import UserModel
|
||||
from api.schemas.service_key import (
|
||||
CreateServiceKeyRequest,
|
||||
CreateServiceKeyResponse,
|
||||
ServiceKeyResponse,
|
||||
)
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/user/service-keys", response_model=List[ServiceKeyResponse])
|
||||
async def get_service_keys(
|
||||
include_archived: bool = False,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Get all service keys for the user's organization."""
|
||||
try:
|
||||
# For OSS mode, use provider_id as created_by
|
||||
# For authenticated mode, use organization_id
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
service_keys = await mps_service_key_client.get_service_keys(
|
||||
created_by=str(user.provider_id),
|
||||
include_archived=include_archived,
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
service_keys = await mps_service_key_client.get_service_keys(
|
||||
organization_id=user.selected_organization_id,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
|
||||
return [ServiceKeyResponse.model_validate(key) for key in service_keys]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get service keys: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve service keys")
|
||||
|
||||
|
||||
@router.post("/user/service-keys", response_model=CreateServiceKeyResponse)
|
||||
async def create_service_key(
|
||||
request: CreateServiceKeyRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Create a new service key for the user's organization."""
|
||||
try:
|
||||
# For OSS mode, don't pass organization_id
|
||||
# For authenticated mode, pass organization_id
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
result = await mps_service_key_client.create_service_key(
|
||||
name=request.name,
|
||||
created_by=str(user.provider_id),
|
||||
expires_in_days=request.expires_in_days or 90,
|
||||
description=f"Service key: {request.name}",
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
result = await mps_service_key_client.create_service_key(
|
||||
name=request.name,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=str(user.provider_id),
|
||||
expires_in_days=request.expires_in_days or 90,
|
||||
description=f"Service key for organization {user.selected_organization_id}",
|
||||
)
|
||||
|
||||
return CreateServiceKeyResponse.model_validate(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create service key: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create service key: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/user/service-keys/{service_key_id}")
|
||||
async def archive_service_key(
|
||||
service_key_id: str, # Changed from int to str since MPS uses string IDs
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Archive a service key."""
|
||||
try:
|
||||
# For OSS mode, use provider_id as created_by for validation
|
||||
# For authenticated mode, use organization_id for validation
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
success = await mps_service_key_client.archive_service_key(
|
||||
key_id=service_key_id,
|
||||
created_by=str(user.provider_id),
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
success = await mps_service_key_client.archive_service_key(
|
||||
key_id=service_key_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Service key not found, already archived, or access denied",
|
||||
)
|
||||
|
||||
return {"message": "Service key archived successfully"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to archive service key: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to archive service key: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/user/service-keys/{service_key_id}/reactivate")
|
||||
async def reactivate_service_key(
|
||||
service_key_id: str, # Changed from int to str since MPS uses string IDs
|
||||
user: UserModel = Depends(get_user), # Kept for consistency but not used
|
||||
):
|
||||
"""
|
||||
Reactivate an archived service key.
|
||||
|
||||
Note: This endpoint is provided for API compatibility but service key
|
||||
reactivation is not supported by MPS. Once archived, a service key
|
||||
cannot be reactivated and a new key must be created instead.
|
||||
"""
|
||||
# MPS does not support reactivation of archived service keys
|
||||
raise HTTPException(
|
||||
status_code=501, # Not Implemented
|
||||
detail="Service key reactivation is not supported. Once a service key is archived, it cannot be reactivated. Please create a new service key instead.",
|
||||
)
|
||||
45
api/routes/stasis_rtp.py
Normal file
45
api/routes/stasis_rtp.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import random
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_ari_stasis
|
||||
from api.services.telephony.stasis_rtp_connection import StasisRTPConnection
|
||||
|
||||
|
||||
async def on_stasis_call(call: StasisRTPConnection, call_context_vars: dict):
|
||||
workflow_id = call_context_vars.get("workflow_id") or call_context_vars.get(
|
||||
"campaign_id"
|
||||
)
|
||||
user_id = call_context_vars.get("user_id")
|
||||
|
||||
assert workflow_id is not None
|
||||
assert user_id is not None
|
||||
|
||||
try:
|
||||
workflow_id = int(workflow_id)
|
||||
user_id = int(user_id)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid workflow ID or user ID: {workflow_id} or {user_id}")
|
||||
return
|
||||
|
||||
workflow_run_name = f"WR-ARI-{random.randint(1000, 9999)}"
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name, workflow_id, WorkflowRunMode.STASIS.value, user_id
|
||||
)
|
||||
|
||||
set_current_run_id(workflow_run.id)
|
||||
|
||||
# Store the workflow_run_id in the connection for later use
|
||||
call.workflow_run_id = workflow_run.id
|
||||
|
||||
# Connect channelID with Workflow run ID in logs
|
||||
logger.info(
|
||||
f"channelID: {call.caller_channel_id} run_id: {workflow_run.id} "
|
||||
f"Received call for workflow ID {workflow_id}, user ID {user_id}"
|
||||
)
|
||||
await run_pipeline_ari_stasis(
|
||||
call, workflow_id, workflow_run.id, user_id, call_context_vars
|
||||
)
|
||||
185
api/routes/superuser.py
Normal file
185
api/routes/superuser.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_superuser
|
||||
from api.services.auth.stack_auth import stackauth
|
||||
|
||||
router = APIRouter(prefix="/superuser", tags=["superuser"])
|
||||
|
||||
|
||||
class ImpersonateRequest(BaseModel):
|
||||
"""Request payload for superadmin impersonation.
|
||||
|
||||
Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
|
||||
provided, ``provider_user_id`` takes precedence.
|
||||
"""
|
||||
|
||||
provider_user_id: str | None = None
|
||||
user_id: int | None = None
|
||||
|
||||
|
||||
class ImpersonateResponse(BaseModel):
|
||||
refresh_token: str
|
||||
access_token: str
|
||||
|
||||
|
||||
class SuperuserWorkflowRunResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
workflow_id: int
|
||||
workflow_name: Optional[str]
|
||||
user_id: Optional[int]
|
||||
organization_id: Optional[int]
|
||||
organization_name: Optional[str]
|
||||
mode: str
|
||||
is_completed: bool
|
||||
recording_url: Optional[str]
|
||||
transcript_url: Optional[str]
|
||||
usage_info: Optional[dict]
|
||||
cost_info: Optional[dict]
|
||||
initial_context: Optional[dict]
|
||||
gathered_context: Optional[dict]
|
||||
admin_comment: Optional[str]
|
||||
admin_comment_ts: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SuperuserWorkflowRunsListResponse(BaseModel):
|
||||
workflow_runs: List[SuperuserWorkflowRunResponse]
|
||||
total_count: int
|
||||
page: int
|
||||
limit: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
@router.post("/impersonate")
|
||||
async def impersonate(
|
||||
request: ImpersonateRequest, user: UserModel = Depends(get_superuser)
|
||||
) -> ImpersonateResponse:
|
||||
"""Impersonate a user as a super-admin.
|
||||
Internally, Stack Auth requires the **provider user ID** (a UUID-ish string)
|
||||
to create an impersonation session.
|
||||
"""
|
||||
|
||||
provider_user_id: str | None = request.provider_user_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fallback: resolve provider_user_id from internal ``user_id``
|
||||
# ------------------------------------------------------------------
|
||||
if provider_user_id is None:
|
||||
if request.user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Either 'provider_user_id' or 'user_id' must be provided.",
|
||||
)
|
||||
|
||||
db_user = await db_client.get_user_by_id(request.user_id)
|
||||
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {request.user_id} not found.",
|
||||
)
|
||||
|
||||
provider_user_id = db_user.provider_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Call Stack Auth to create the impersonation session
|
||||
# ------------------------------------------------------------------
|
||||
session = await stackauth.impersonate(provider_user_id)
|
||||
|
||||
return ImpersonateResponse(
|
||||
refresh_token=session["refresh_token"],
|
||||
access_token=session["access_token"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workflow-runs")
|
||||
async def get_workflow_runs(
|
||||
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
|
||||
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
||||
user: UserModel = Depends(get_superuser),
|
||||
) -> SuperuserWorkflowRunsListResponse:
|
||||
"""
|
||||
Get paginated list of all workflow runs with organization information.
|
||||
Requires superuser privileges.
|
||||
|
||||
Filters should be provided as a JSON-encoded array of filter criteria.
|
||||
Example: [{"field": "id", "type": "number", "value": {"value": 680}}]
|
||||
"""
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Parse filters if provided
|
||||
filter_criteria = None
|
||||
if filters:
|
||||
try:
|
||||
filter_criteria = json.loads(filters)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid filter format")
|
||||
|
||||
workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin(
|
||||
limit=limit, offset=offset, filters=filter_criteria
|
||||
)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit # Ceiling division
|
||||
|
||||
return SuperuserWorkflowRunsListResponse(
|
||||
workflow_runs=[SuperuserWorkflowRunResponse(**run) for run in workflow_runs],
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
limit=limit,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
# ------------------ Admin Comment ------------------
|
||||
|
||||
|
||||
class AdminCommentRequest(BaseModel):
|
||||
admin_comment: str
|
||||
|
||||
|
||||
class AdminCommentResponse(BaseModel):
|
||||
success: bool
|
||||
admin_comment: str
|
||||
admin_comment_ts: datetime
|
||||
|
||||
|
||||
# ------------------ Routes ------------------
|
||||
|
||||
|
||||
@router.post("/workflow-runs/{run_id}/comment", response_model=AdminCommentResponse)
|
||||
async def set_admin_comment(
|
||||
run_id: int,
|
||||
request: AdminCommentRequest,
|
||||
user: UserModel = Depends(get_superuser),
|
||||
):
|
||||
"""Add or update an *admin-only* comment for a workflow run.
|
||||
|
||||
The comment is stored inside the ``annotations`` JSON column under the
|
||||
``admin_comment`` key so that it does not interfere with any other
|
||||
annotations recorded by the system.
|
||||
"""
|
||||
|
||||
await db_client.update_admin_comment(
|
||||
run_id=run_id, admin_comment=request.admin_comment
|
||||
)
|
||||
|
||||
# Fetch the updated run to get the timestamp from annotations
|
||||
updated_run = await db_client.get_workflow_run_by_id(run_id)
|
||||
admin_comment_ts = None
|
||||
if updated_run and updated_run.annotations:
|
||||
admin_comment_ts = updated_run.annotations.get("admin_comment_ts")
|
||||
|
||||
return AdminCommentResponse(
|
||||
success=True,
|
||||
admin_comment=request.admin_comment,
|
||||
admin_comment_ts=admin_comment_ts,
|
||||
)
|
||||
258
api/routes/twilio.py
Normal file
258
api/routes/twilio.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import json
|
||||
import random
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
|
||||
from loguru import logger
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.enums import OrganizationConfigurationKey, WorkflowRunMode
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.campaign.call_dispatcher import campaign_call_dispatcher
|
||||
from api.services.campaign.campaign_event_publisher import (
|
||||
get_campaign_event_publisher,
|
||||
)
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_twilio
|
||||
from api.services.telephony.twilio import TwilioService
|
||||
|
||||
router = APIRouter(prefix="/twilio")
|
||||
|
||||
|
||||
class InitiateCallRequest(BaseModel):
|
||||
workflow_id: int
|
||||
workflow_run_id: int | None = None
|
||||
|
||||
|
||||
class TwilioStatusCallbackRequest(BaseModel):
|
||||
CallSid: str
|
||||
CallStatus: str
|
||||
From: Optional[str] = None
|
||||
To: Optional[str] = None
|
||||
Direction: Optional[str] = None
|
||||
Duration: Optional[str] = None
|
||||
CallDuration: Optional[str] = None
|
||||
RecordingUrl: Optional[str] = None
|
||||
RecordingSid: Optional[str] = None
|
||||
Timestamp: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/initiate-call")
|
||||
async def initiate_call(
|
||||
request: InitiateCallRequest, user: UserModel = Depends(get_user)
|
||||
):
|
||||
# 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.",
|
||||
)
|
||||
|
||||
user_configuration = await db_client.get_user_configurations(user.id)
|
||||
|
||||
workflow_run_id = request.workflow_run_id
|
||||
|
||||
if not workflow_run_id:
|
||||
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
request.workflow_id,
|
||||
WorkflowRunMode.TWILIO.value,
|
||||
initial_context={
|
||||
"phone_number": user_configuration.test_phone_number,
|
||||
},
|
||||
user_id=user.id,
|
||||
)
|
||||
workflow_run_id = workflow_run.id
|
||||
else:
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=400, detail="Workflow run not found")
|
||||
workflow_run_name = workflow_run.name
|
||||
|
||||
if user_configuration.test_phone_number:
|
||||
await TwilioService().initiate_call(
|
||||
to_number=user_configuration.test_phone_number,
|
||||
url_args={
|
||||
"workflow_id": request.workflow_id,
|
||||
"user_id": user.id,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
},
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
return {
|
||||
"message": f"Call initiated successfully with run name {workflow_run_name}"
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Test phone number not set")
|
||||
|
||||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def start_call(workflow_id: int, user_id: int, workflow_run_id: int):
|
||||
twiml_content = await TwilioService().get_start_call_twiml(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
return HTMLResponse(content=twiml_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
):
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
# "connected" (ignore)
|
||||
msg = json.loads(await websocket.receive_text())
|
||||
if msg.get("event") != "connected":
|
||||
raise RuntimeError("Expected connected message first")
|
||||
|
||||
# "start" – this has everything we need
|
||||
start_msg = await websocket.receive_text()
|
||||
|
||||
# set the run context
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
logger.debug(f"Received start message: {start_msg}")
|
||||
|
||||
start_msg = json.loads(start_msg)
|
||||
if start_msg.get("event") != "start":
|
||||
raise RuntimeError("Expected start message second")
|
||||
|
||||
try:
|
||||
stream_sid = start_msg["start"]["streamSid"]
|
||||
call_sid = start_msg["start"]["callSid"]
|
||||
except KeyError:
|
||||
logger.error(
|
||||
"Missing callSID and streamSID in start message. Closing connection."
|
||||
)
|
||||
await websocket.close(code=4400, reason="Missing or bad start message")
|
||||
return
|
||||
|
||||
# Run your Pipecat bot
|
||||
await run_pipeline_twilio(
|
||||
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Twilio WebSocket connection: {e}")
|
||||
await websocket.close(1011, "Internal server error")
|
||||
|
||||
|
||||
@router.post("/status-callback/{workflow_run_id}", include_in_schema=False)
|
||||
async def status_callback(
|
||||
request: Request,
|
||||
workflow_run_id: int,
|
||||
x_twilio_signature: Annotated[
|
||||
Optional[str], Header(alias="X-Twilio-Signature")
|
||||
] = None,
|
||||
CallSid: str = Form(...),
|
||||
CallStatus: str = Form(...),
|
||||
From: Optional[str] = Form(None),
|
||||
To: Optional[str] = Form(None),
|
||||
Direction: Optional[str] = Form(None),
|
||||
Duration: Optional[str] = Form(None),
|
||||
CallDuration: Optional[str] = Form(None),
|
||||
RecordingUrl: Optional[str] = Form(None),
|
||||
RecordingSid: Optional[str] = Form(None),
|
||||
Timestamp: Optional[str] = Form(None),
|
||||
):
|
||||
"""Handle Twilio status callbacks for call lifecycle events."""
|
||||
try:
|
||||
# TODO: Implement Twilio signature verification
|
||||
|
||||
# Create callback data object
|
||||
callback_data = {
|
||||
"CallSid": CallSid,
|
||||
"CallStatus": CallStatus,
|
||||
"From": From,
|
||||
"To": To,
|
||||
"Direction": Direction,
|
||||
"Duration": Duration,
|
||||
"CallDuration": CallDuration,
|
||||
"RecordingUrl": RecordingUrl,
|
||||
"RecordingSid": RecordingSid,
|
||||
"Timestamp": Timestamp,
|
||||
}
|
||||
|
||||
# Remove None values for cleaner logging
|
||||
callback_data = {k: v for k, v in callback_data.items() if v is not None}
|
||||
|
||||
logger.info(
|
||||
f"Received Twilio status callback for workflow_run_id {workflow_run_id}: {CallStatus}"
|
||||
)
|
||||
|
||||
# Get the current workflow run
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.error(f"Workflow run {workflow_run_id} not found for callback")
|
||||
return {"status": "error", "message": "Workflow run not found"}
|
||||
|
||||
callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
|
||||
|
||||
# Add new callback log entry to logs
|
||||
callback_log = {
|
||||
"status": CallStatus,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": callback_data,
|
||||
}
|
||||
callback_logs.append(callback_log)
|
||||
|
||||
# Update the workflow run with the new logs
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id, logs={"twilio_status_callbacks": callback_logs}
|
||||
)
|
||||
|
||||
# Release concurrent slot when call ends (for any terminal status)
|
||||
terminal_statuses = ["completed", "busy", "no-answer", "failed", "canceled"]
|
||||
if CallStatus.lower() in terminal_statuses and workflow_run.campaign_id:
|
||||
# Release the concurrent slot for this call
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
|
||||
# Check if retry is needed for campaign calls
|
||||
if (
|
||||
CallStatus.lower() in ["busy", "no-answer", "failed"]
|
||||
and workflow_run.campaign_id
|
||||
):
|
||||
# Lets retry for busy and no-answer
|
||||
if CallStatus.lower() in ["busy", "no-answer"]:
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_retry_needed(
|
||||
workflow_run_id=workflow_run_id,
|
||||
reason=CallStatus.lower().replace(
|
||||
"-", "_"
|
||||
), # Convert no-answer to no_answer
|
||||
campaign_id=workflow_run.campaign_id,
|
||||
queued_run_id=workflow_run.queued_run_id,
|
||||
)
|
||||
|
||||
# Update workflow run with appropriate tags
|
||||
call_tags = workflow_run.gathered_context.get("call_tags", [])
|
||||
call_tags.extend(["not_connected", f"twilio_{CallStatus.lower()}"])
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
is_completed=True,
|
||||
gathered_context={
|
||||
"call_tags": call_tags,
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Callback processed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Twilio status callback: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
276
api/routes/user.py
Normal file
276
api/routes/user.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, TypedDict, Union
|
||||
|
||||
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
|
||||
from api.services.configuration.check_validity import (
|
||||
APIKeyStatusResponse,
|
||||
UserConfigurationValidator,
|
||||
)
|
||||
from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS
|
||||
from api.services.configuration.masking import mask_user_config
|
||||
from api.services.configuration.merge import merge_user_configurations
|
||||
from api.services.configuration.registry import REGISTRY, ServiceType
|
||||
|
||||
router = APIRouter(prefix="/user")
|
||||
|
||||
|
||||
class AuthUserResponse(TypedDict):
|
||||
id: int
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
class DefaultConfigurationsResponse(TypedDict):
|
||||
llm: dict[str, dict]
|
||||
tts: dict[str, dict]
|
||||
stt: dict[str, dict]
|
||||
default_providers: dict[str, str]
|
||||
|
||||
|
||||
@router.get("/configurations/defaults")
|
||||
async def get_default_configurations() -> DefaultConfigurationsResponse:
|
||||
configurations = {
|
||||
"llm": {
|
||||
provider: model_cls.model_json_schema()
|
||||
for provider, model_cls in REGISTRY[ServiceType.LLM].items()
|
||||
},
|
||||
"tts": {
|
||||
provider: model_cls.model_json_schema()
|
||||
for provider, model_cls in REGISTRY[ServiceType.TTS].items()
|
||||
},
|
||||
"stt": {
|
||||
provider: model_cls.model_json_schema()
|
||||
for provider, model_cls in REGISTRY[ServiceType.STT].items()
|
||||
},
|
||||
"default_providers": DEFAULT_SERVICE_PROVIDERS,
|
||||
}
|
||||
return configurations
|
||||
|
||||
|
||||
@router.get("/auth/user")
|
||||
async def get_auth_user(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> AuthUserResponse:
|
||||
return {
|
||||
"id": user.id,
|
||||
"is_superuser": user.is_superuser,
|
||||
}
|
||||
|
||||
|
||||
class UserConfigurationRequestResponseSchema(BaseModel):
|
||||
llm: dict[str, Union[str, float]] | None = None
|
||||
tts: dict[str, Union[str, float]] | None = None
|
||||
stt: dict[str, Union[str, float]] | None = None
|
||||
test_phone_number: str | None = None
|
||||
timezone: str | None = None
|
||||
organization_pricing: dict[str, Union[float, str, bool]] | None = None
|
||||
|
||||
|
||||
@router.get("/configurations/user")
|
||||
async def get_user_configurations(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> UserConfigurationRequestResponseSchema:
|
||||
user_configurations = await db_client.get_user_configurations(user.id)
|
||||
masked_config = mask_user_config(user_configurations)
|
||||
|
||||
# Add organization pricing info if available
|
||||
if user.selected_organization_id:
|
||||
org = await db_client.get_organization_by_id(user.selected_organization_id)
|
||||
if org and org.price_per_second_usd is not None:
|
||||
masked_config["organization_pricing"] = {
|
||||
"price_per_second_usd": org.price_per_second_usd,
|
||||
"currency": "USD",
|
||||
"billing_enabled": True,
|
||||
}
|
||||
|
||||
return masked_config
|
||||
|
||||
|
||||
@router.put("/configurations/user")
|
||||
async def update_user_configurations(
|
||||
request: UserConfigurationRequestResponseSchema,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> UserConfigurationRequestResponseSchema:
|
||||
existing_config = await db_client.get_user_configurations(user.id)
|
||||
|
||||
incoming_dict = request.model_dump(exclude_none=True)
|
||||
|
||||
# Remove organization_pricing from incoming dict as it's read-only
|
||||
incoming_dict.pop("organization_pricing", None)
|
||||
|
||||
# Merge via helper
|
||||
user_configurations = merge_user_configurations(existing_config, incoming_dict)
|
||||
|
||||
try:
|
||||
validator = UserConfigurationValidator()
|
||||
await validator.validate(user_configurations)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=e.args[0])
|
||||
|
||||
user_configurations = await db_client.update_user_configuration(
|
||||
user.id, user_configurations
|
||||
)
|
||||
|
||||
# Return masked version of updated config
|
||||
masked_config = mask_user_config(user_configurations)
|
||||
|
||||
# Add organization pricing info if available
|
||||
if user.selected_organization_id:
|
||||
org = await db_client.get_organization_by_id(user.selected_organization_id)
|
||||
if org and org.price_per_second_usd is not None:
|
||||
masked_config["organization_pricing"] = {
|
||||
"price_per_second_usd": org.price_per_second_usd,
|
||||
"currency": "USD",
|
||||
"billing_enabled": True,
|
||||
}
|
||||
|
||||
return masked_config
|
||||
|
||||
|
||||
@router.get("/configurations/user/validate")
|
||||
async def validate_user_configurations(
|
||||
validity_ttl_seconds: int = Query(default=60, ge=0, le=86400),
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> APIKeyStatusResponse:
|
||||
configurations = await db_client.get_user_configurations(user.id)
|
||||
|
||||
if (
|
||||
configurations.last_validated_at
|
||||
and configurations.last_validated_at
|
||||
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
|
||||
):
|
||||
validator = UserConfigurationValidator()
|
||||
try:
|
||||
status = await validator.validate(configurations)
|
||||
await db_client.update_user_configuration_last_validated_at(user.id)
|
||||
return status
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=e.args[0])
|
||||
else:
|
||||
return {"status": []}
|
||||
|
||||
|
||||
# API Key Management Endpoints
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
key_prefix: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_used_at: Optional[datetime] = None
|
||||
archived_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CreateAPIKeyRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class CreateAPIKeyResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
key_prefix: str
|
||||
api_key: str # Only returned when creating a new key
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@router.get("/api-keys")
|
||||
async def get_api_keys(
|
||||
include_archived: bool = Query(default=False),
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> List[APIKeyResponse]:
|
||||
"""Get all API keys for the user's selected organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
api_keys = await db_client.get_api_keys_by_organization(
|
||||
user.selected_organization_id, include_archived=include_archived
|
||||
)
|
||||
|
||||
return [
|
||||
APIKeyResponse(
|
||||
id=key.id,
|
||||
name=key.name,
|
||||
key_prefix=key.key_prefix,
|
||||
is_active=key.is_active,
|
||||
created_at=key.created_at,
|
||||
last_used_at=key.last_used_at,
|
||||
archived_at=key.archived_at,
|
||||
)
|
||||
for key in api_keys
|
||||
]
|
||||
|
||||
|
||||
@router.post("/api-keys")
|
||||
async def create_api_key(
|
||||
request: CreateAPIKeyRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CreateAPIKeyResponse:
|
||||
"""Create a new API key for the user's selected organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
api_key, raw_key = await db_client.create_api_key(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
created_by=user.id,
|
||||
)
|
||||
|
||||
return CreateAPIKeyResponse(
|
||||
id=api_key.id,
|
||||
name=api_key.name,
|
||||
key_prefix=api_key.key_prefix,
|
||||
api_key=raw_key,
|
||||
created_at=api_key.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api-keys/{api_key_id}")
|
||||
async def archive_api_key(
|
||||
api_key_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> dict:
|
||||
"""Archive an API key (soft delete)."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Verify the API key belongs to the user's organization
|
||||
api_keys = await db_client.get_api_keys_by_organization(
|
||||
user.selected_organization_id, include_archived=True
|
||||
)
|
||||
if not any(key.id == api_key_id for key in api_keys):
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
success = await db_client.archive_api_key(api_key_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to archive API key")
|
||||
|
||||
return {"success": True, "message": "API key archived successfully"}
|
||||
|
||||
|
||||
@router.put("/api-keys/{api_key_id}/reactivate")
|
||||
async def reactivate_api_key(
|
||||
api_key_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> dict:
|
||||
"""Reactivate an archived API key."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Verify the API key belongs to the user's organization
|
||||
api_keys = await db_client.get_api_keys_by_organization(
|
||||
user.selected_organization_id, include_archived=True
|
||||
)
|
||||
if not any(key.id == api_key_id for key in api_keys):
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
success = await db_client.reactivate_api_key(api_key_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to reactivate API key")
|
||||
|
||||
return {"success": True, "message": "API key reactivated successfully"}
|
||||
665
api/routes/workflow.py
Normal file
665
api/routes/workflow.py
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from api.constants import DEPLOYMENT_MODE
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.db.workflow_template_client import WorkflowTemplateClient
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.errors import ItemKind, WorkflowError
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
|
||||
router = APIRouter(prefix="/workflow")
|
||||
|
||||
|
||||
class ValidateWorkflowResponse(BaseModel):
|
||||
is_valid: bool
|
||||
errors: list[WorkflowError]
|
||||
|
||||
|
||||
class WorkflowResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
workflow_definition: dict
|
||||
current_definition_id: int | None
|
||||
template_context_variables: dict | None = None
|
||||
call_disposition_codes: dict | None = None
|
||||
total_runs: int | None = None
|
||||
workflow_configurations: dict | None = None
|
||||
|
||||
|
||||
class WorkflowTemplateResponse(BaseModel):
|
||||
id: int
|
||||
template_name: str
|
||||
template_description: str
|
||||
template_json: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateWorkflowRequest(BaseModel):
|
||||
name: str
|
||||
workflow_definition: dict
|
||||
|
||||
|
||||
class DuplicateTemplateRequest(BaseModel):
|
||||
template_id: int
|
||||
workflow_name: str
|
||||
|
||||
|
||||
class UpdateWorkflowRequest(BaseModel):
|
||||
name: str
|
||||
workflow_definition: dict | None = None
|
||||
template_context_variables: dict | None = None
|
||||
workflow_configurations: dict | None = None
|
||||
|
||||
|
||||
class UpdateWorkflowStatusRequest(BaseModel):
|
||||
status: str # "active" or "archived"
|
||||
|
||||
|
||||
class CreateWorkflowRunRequest(BaseModel):
|
||||
mode: str
|
||||
name: str
|
||||
|
||||
|
||||
class CreateWorkflowRunResponse(BaseModel):
|
||||
id: int
|
||||
workflow_id: int
|
||||
name: str
|
||||
mode: str
|
||||
created_at: datetime
|
||||
definition_id: int
|
||||
initial_context: dict | None = None
|
||||
|
||||
|
||||
class CreateWorkflowTemplateRequest(BaseModel):
|
||||
call_type: Literal["INBOUND", "OUTBOUND"]
|
||||
use_case: str
|
||||
activity_description: str
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/validate")
|
||||
async def validate_workflow(
|
||||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> ValidateWorkflowResponse:
|
||||
"""
|
||||
Validate all nodes in a workflow to ensure they have required fields.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to validate
|
||||
user: The authenticated user
|
||||
|
||||
Returns:
|
||||
Object indicating if workflow is valid and any invalid nodes/edges
|
||||
"""
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
if workflow is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
errors: list[WorkflowError] = []
|
||||
|
||||
# Get workflow definition from WorkflowDefinition table, fallback to workflow_definition field
|
||||
workflow_definition = workflow.workflow_definition_with_fallback
|
||||
|
||||
# ----------- DTO Validation ------------
|
||||
dto: Optional[ReactFlowDTO] = None
|
||||
|
||||
try:
|
||||
dto = ReactFlowDTO.model_validate(workflow_definition)
|
||||
except ValidationError as exc:
|
||||
errors.extend(_transform_schema_errors(exc, workflow_definition))
|
||||
|
||||
# ----------- Graph Validation if DTO is valid ------------
|
||||
try:
|
||||
if dto:
|
||||
WorkflowGraph(dto)
|
||||
except ValueError as e:
|
||||
errors.extend(e.args[0])
|
||||
|
||||
if errors:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=ValidateWorkflowResponse(is_valid=False, errors=errors).model_dump(),
|
||||
)
|
||||
|
||||
return ValidateWorkflowResponse(is_valid=True, errors=[])
|
||||
|
||||
|
||||
def _transform_schema_errors(
|
||||
exc: ValidationError, workflow_definition: dict
|
||||
) -> list[WorkflowError]:
|
||||
out: list[WorkflowError] = []
|
||||
|
||||
for err in exc.errors():
|
||||
loc = err["loc"]
|
||||
idx = workflow_definition[loc[0]][loc[1]]["id"]
|
||||
|
||||
kind: ItemKind = ItemKind.node if loc[0] == "nodes" else ItemKind.edge
|
||||
|
||||
out.append(
|
||||
WorkflowError(
|
||||
kind=kind,
|
||||
id=idx,
|
||||
field=".".join(str(p) for p in err["loc"][2:]) or None,
|
||||
message=err["msg"].capitalize(),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/create/definition")
|
||||
async def create_workflow(
|
||||
request: CreateWorkflowRequest, user: UserModel = Depends(get_user)
|
||||
) -> WorkflowResponse:
|
||||
"""
|
||||
Create a new workflow from the client
|
||||
|
||||
Args:
|
||||
request: The create workflow request
|
||||
user: The user to create the workflow for
|
||||
"""
|
||||
workflow = await db_client.create_workflow(
|
||||
request.name,
|
||||
request.workflow_definition,
|
||||
user.id,
|
||||
user.selected_organization_id,
|
||||
)
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/create/template")
|
||||
async def create_workflow_from_template(
|
||||
request: CreateWorkflowTemplateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowResponse:
|
||||
"""
|
||||
Create a new workflow from a natural language template request.
|
||||
|
||||
This endpoint:
|
||||
1. Uses mps_service_key_client to call MPS workflow API
|
||||
2. Passes organization ID (authenticated mode) or created_by (OSS mode)
|
||||
3. Creates the workflow in the database
|
||||
|
||||
Args:
|
||||
request: The template creation request with call_type, use_case, and activity_description
|
||||
user: The authenticated user
|
||||
|
||||
Returns:
|
||||
The created workflow
|
||||
|
||||
Raises:
|
||||
HTTPException: If MPS API call fails
|
||||
"""
|
||||
try:
|
||||
# Call MPS API to generate workflow using the client
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
workflow_data = await mps_service_key_client.call_workflow_api(
|
||||
call_type=request.call_type,
|
||||
use_case=request.use_case,
|
||||
activity_description=request.activity_description,
|
||||
created_by=str(user.provider_id),
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
workflow_data = await mps_service_key_client.call_workflow_api(
|
||||
call_type=request.call_type,
|
||||
use_case=request.use_case,
|
||||
activity_description=request.activity_description,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
# Create the workflow in our database
|
||||
workflow = await db_client.create_workflow(
|
||||
name=workflow_data.get("name", f"{request.use_case} - {request.call_type}"),
|
||||
workflow_definition=workflow_data.get("workflow_definition", {}),
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"MPS API error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code if hasattr(e, "response") else 500,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating workflow from template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"An unexpected error occurred: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
class WorkflowSummaryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
@router.get("/fetch")
|
||||
async def get_workflows(
|
||||
user: UserModel = Depends(get_user),
|
||||
status: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by status - can be single value (active/archived) or comma-separated (active,archived)",
|
||||
),
|
||||
) -> List[WorkflowResponse]:
|
||||
"""Get all workflows for the authenticated user's organization"""
|
||||
# Handle comma-separated status values
|
||||
if status and "," in status:
|
||||
# Split comma-separated values and fetch workflows for each status
|
||||
status_list = [s.strip() for s in status.split(",")]
|
||||
all_workflows = []
|
||||
for status_value in status_list:
|
||||
workflows = await db_client.get_all_workflows(
|
||||
organization_id=user.selected_organization_id, status=status_value
|
||||
)
|
||||
all_workflows.extend(workflows)
|
||||
workflows = all_workflows
|
||||
else:
|
||||
# Single status or no status filter
|
||||
workflows = await db_client.get_all_workflows(
|
||||
organization_id=user.selected_organization_id, status=status
|
||||
)
|
||||
|
||||
# Get run counts for each workflow
|
||||
workflow_responses = []
|
||||
for workflow in workflows:
|
||||
run_count = await db_client.get_workflow_run_count(workflow.id)
|
||||
workflow_responses.append(
|
||||
{
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
"total_runs": run_count,
|
||||
}
|
||||
)
|
||||
|
||||
return workflow_responses
|
||||
|
||||
|
||||
@router.get("/fetch/{workflow_id}")
|
||||
async def get_workflow(
|
||||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowResponse:
|
||||
"""Get a single workflow by ID"""
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if workflow is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_workflows_summary(
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> List[WorkflowSummaryResponse]:
|
||||
"""Get minimal workflow information (id and name only) for all workflows"""
|
||||
workflows = await db_client.get_all_workflows(
|
||||
organization_id=user.selected_organization_id
|
||||
)
|
||||
return [
|
||||
WorkflowSummaryResponse(id=workflow.id, name=workflow.name)
|
||||
for workflow in workflows
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{workflow_id}/status")
|
||||
async def update_workflow_status(
|
||||
workflow_id: int,
|
||||
request: UpdateWorkflowStatusRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowResponse:
|
||||
"""
|
||||
Update the status of a workflow (e.g., archive/unarchive).
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to update
|
||||
request: The status update request
|
||||
|
||||
Returns:
|
||||
The updated workflow
|
||||
"""
|
||||
try:
|
||||
workflow = await db_client.update_workflow_status(
|
||||
workflow_id=workflow_id,
|
||||
status=request.status,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
run_count = await db_client.get_workflow_run_count(workflow.id)
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
"total_runs": run_count,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{workflow_id}")
|
||||
async def update_workflow(
|
||||
workflow_id: int,
|
||||
request: UpdateWorkflowRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowResponse:
|
||||
"""
|
||||
Update an existing workflow.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to update
|
||||
request: The update request containing the new name and workflow definition
|
||||
|
||||
Returns:
|
||||
The updated workflow
|
||||
|
||||
Raises:
|
||||
HTTPException: If the workflow is not found or if there's a database error
|
||||
"""
|
||||
try:
|
||||
workflow = await db_client.update_workflow(
|
||||
workflow_id=workflow_id,
|
||||
name=request.name,
|
||||
workflow_definition=request.workflow_definition,
|
||||
template_context_variables=request.template_context_variables,
|
||||
workflow_configurations=request.workflow_configurations,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/runs")
|
||||
async def create_workflow_run(
|
||||
workflow_id: int,
|
||||
request: CreateWorkflowRunRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CreateWorkflowRunResponse:
|
||||
"""
|
||||
Create a new workflow run when the user decides to execute the workflow via chat or voice
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to run
|
||||
request: The create workflow run request
|
||||
user: The user to create the workflow run for
|
||||
"""
|
||||
run = await db_client.create_workflow_run(
|
||||
request.name, workflow_id, request.mode, user.id
|
||||
)
|
||||
return {
|
||||
"id": run.id,
|
||||
"workflow_id": run.workflow_id,
|
||||
"name": run.name,
|
||||
"mode": run.mode,
|
||||
"created_at": run.created_at,
|
||||
"definition_id": run.definition_id,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/runs/{run_id}")
|
||||
async def get_workflow_run(
|
||||
workflow_id: int, run_id: int, user: UserModel = Depends(get_user)
|
||||
) -> WorkflowRunResponseSchema:
|
||||
run = await db_client.get_workflow_run(
|
||||
run_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Workflow run not found")
|
||||
return {
|
||||
"id": run.id,
|
||||
"workflow_id": run.workflow_id,
|
||||
"name": run.name,
|
||||
"mode": run.mode,
|
||||
"is_completed": run.is_completed,
|
||||
"transcript_url": run.transcript_url,
|
||||
"recording_url": run.recording_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"))
|
||||
)
|
||||
if run.cost_info
|
||||
else None,
|
||||
}
|
||||
if run.cost_info
|
||||
else None,
|
||||
"created_at": run.created_at,
|
||||
"definition_id": run.definition_id,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
}
|
||||
|
||||
|
||||
class WorkflowRunsResponse(BaseModel):
|
||||
runs: List[WorkflowRunResponseSchema]
|
||||
total_count: int
|
||||
page: int
|
||||
limit: int
|
||||
total_pages: int
|
||||
applied_filters: Optional[List[dict]] = None
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/runs")
|
||||
async def get_workflow_runs(
|
||||
workflow_id: int,
|
||||
page: int = 1,
|
||||
limit: int = 50,
|
||||
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowRunsResponse:
|
||||
"""
|
||||
Get workflow runs with optional filtering.
|
||||
|
||||
Filters should be provided as a JSON-encoded array of filter criteria.
|
||||
Example: [{"attribute": "dateRange", "value": {"from": "2024-01-01", "to": "2024-01-31"}}]
|
||||
"""
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Parse filters if provided
|
||||
filter_criteria = []
|
||||
if filters:
|
||||
try:
|
||||
filter_criteria = json.loads(filters)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid filter format")
|
||||
|
||||
# Restrict allowed filter attributes for regular users
|
||||
allowed_attributes = {
|
||||
"dateRange",
|
||||
"dispositionCode",
|
||||
"duration",
|
||||
"status",
|
||||
"tokenUsage",
|
||||
}
|
||||
for filter_item in filter_criteria:
|
||||
attribute = filter_item.get("attribute")
|
||||
if attribute and attribute not in allowed_attributes:
|
||||
raise HTTPException(
|
||||
status_code=403, detail=f"Invalid attribute '{attribute}'"
|
||||
)
|
||||
|
||||
# Apply filters if any
|
||||
if filter_criteria:
|
||||
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
|
||||
workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filters=filter_criteria,
|
||||
)
|
||||
else:
|
||||
# Use existing logic for unfiltered results
|
||||
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
|
||||
workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return WorkflowRunsResponse(
|
||||
runs=runs,
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
limit=limit,
|
||||
total_pages=total_pages,
|
||||
applied_filters=filter_criteria if filter_criteria else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def get_workflow_templates() -> List[WorkflowTemplateResponse]:
|
||||
"""
|
||||
Get all available workflow templates.
|
||||
|
||||
Returns:
|
||||
List of workflow templates
|
||||
"""
|
||||
template_client = WorkflowTemplateClient()
|
||||
templates = await template_client.get_all_workflow_templates()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": template.id,
|
||||
"template_name": template.template_name,
|
||||
"template_description": template.template_description,
|
||||
"template_json": template.template_json,
|
||||
"created_at": template.created_at,
|
||||
}
|
||||
for template in templates
|
||||
]
|
||||
|
||||
|
||||
@router.post("/templates/duplicate")
|
||||
async def duplicate_workflow_template(
|
||||
request: DuplicateTemplateRequest, user: UserModel = Depends(get_user)
|
||||
) -> WorkflowResponse:
|
||||
"""
|
||||
Duplicate a workflow template to create a new workflow for the user.
|
||||
|
||||
Args:
|
||||
request: The duplicate template request
|
||||
user: The authenticated user
|
||||
|
||||
Returns:
|
||||
The newly created workflow
|
||||
"""
|
||||
template_client = WorkflowTemplateClient()
|
||||
template = await template_client.get_workflow_template(request.template_id)
|
||||
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Workflow template with id {request.template_id} not found",
|
||||
)
|
||||
|
||||
# Create a new workflow from the template
|
||||
workflow = await db_client.create_workflow(
|
||||
request.workflow_name,
|
||||
template.template_json,
|
||||
user.id,
|
||||
user.selected_organization_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": workflow.workflow_definition_with_fallback,
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue