mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
feat: add redial option in campaigns
This commit is contained in:
parent
79116e6af2
commit
7fab959e26
14 changed files with 998 additions and 58 deletions
|
|
@ -183,6 +183,10 @@ class CampaignResponse(BaseModel):
|
|||
max_concurrency: Optional[int] = None
|
||||
schedule_config: Optional[ScheduleConfigResponse] = None
|
||||
circuit_breaker: Optional[CircuitBreakerConfigResponse] = None
|
||||
executed_count: int = 0
|
||||
total_queued_count: int = 0
|
||||
parent_campaign_id: Optional[int] = None
|
||||
redialed_campaign_id: Optional[int] = None
|
||||
|
||||
|
||||
class CampaignsResponse(BaseModel):
|
||||
|
|
@ -223,7 +227,12 @@ class CampaignProgressResponse(BaseModel):
|
|||
# Default retry config for campaigns
|
||||
|
||||
|
||||
def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse:
|
||||
def _build_campaign_response(
|
||||
campaign,
|
||||
workflow_name: str,
|
||||
executed_count: int = 0,
|
||||
total_queued_count: int = 0,
|
||||
) -> CampaignResponse:
|
||||
"""Build a CampaignResponse from a campaign model."""
|
||||
# Get retry_config from campaign or use defaults
|
||||
retry_config = (
|
||||
|
|
@ -236,6 +245,8 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse:
|
|||
max_concurrency = None
|
||||
schedule_config = None
|
||||
circuit_breaker_config = CircuitBreakerConfigResponse()
|
||||
parent_campaign_id = None
|
||||
redialed_campaign_id = None
|
||||
if campaign.orchestrator_metadata:
|
||||
max_concurrency = campaign.orchestrator_metadata.get("max_concurrency")
|
||||
sc = campaign.orchestrator_metadata.get("schedule_config")
|
||||
|
|
@ -248,6 +259,10 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse:
|
|||
cb = campaign.orchestrator_metadata.get("circuit_breaker")
|
||||
if cb:
|
||||
circuit_breaker_config = CircuitBreakerConfigResponse(**cb)
|
||||
parent_campaign_id = campaign.orchestrator_metadata.get("parent_campaign_id")
|
||||
redialed_campaign_id = campaign.orchestrator_metadata.get(
|
||||
"redialed_campaign_id"
|
||||
)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
|
|
@ -267,9 +282,20 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse:
|
|||
max_concurrency=max_concurrency,
|
||||
schedule_config=schedule_config,
|
||||
circuit_breaker=circuit_breaker_config,
|
||||
executed_count=executed_count,
|
||||
total_queued_count=total_queued_count,
|
||||
parent_campaign_id=parent_campaign_id,
|
||||
redialed_campaign_id=redialed_campaign_id,
|
||||
)
|
||||
|
||||
|
||||
async def _get_campaign_stats(campaign_id: int) -> tuple[int, int]:
|
||||
"""Return (executed_count, total_queued_count) for a campaign."""
|
||||
stats_map = await db_client.get_queued_runs_stats_for_campaigns([campaign_id])
|
||||
s = stats_map.get(campaign_id, {})
|
||||
return s.get("executed", 0), s.get("total", 0)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_campaign(
|
||||
request: CreateCampaignRequest,
|
||||
|
|
@ -374,8 +400,17 @@ async def get_campaigns(
|
|||
)
|
||||
workflow_map = {w.id: w.name for w in workflows}
|
||||
|
||||
stats_map = await db_client.get_queued_runs_stats_for_campaigns(
|
||||
[c.id for c in campaigns]
|
||||
)
|
||||
|
||||
campaign_responses = [
|
||||
_build_campaign_response(c, workflow_map.get(c.workflow_id, "Unknown"))
|
||||
_build_campaign_response(
|
||||
c,
|
||||
workflow_map.get(c.workflow_id, "Unknown"),
|
||||
executed_count=stats_map.get(c.id, {}).get("executed", 0),
|
||||
total_queued_count=stats_map.get(c.id, {}).get("total", 0),
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
|
||||
|
|
@ -394,7 +429,10 @@ async def get_campaign(
|
|||
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
|
||||
return _build_campaign_response(campaign, workflow_name or "Unknown")
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
return _build_campaign_response(
|
||||
campaign, workflow_name or "Unknown", executed, total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/start")
|
||||
|
|
@ -435,7 +473,10 @@ async def start_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 _build_campaign_response(campaign, workflow_name or "Unknown")
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
return _build_campaign_response(
|
||||
campaign, workflow_name or "Unknown", executed, total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause")
|
||||
|
|
@ -459,7 +500,10 @@ async def pause_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 _build_campaign_response(campaign, workflow_name or "Unknown")
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
return _build_campaign_response(
|
||||
campaign, workflow_name or "Unknown", executed, total
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{campaign_id}")
|
||||
|
|
@ -519,7 +563,10 @@ async def update_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 _build_campaign_response(campaign, workflow_name or "Unknown")
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
return _build_campaign_response(
|
||||
campaign, workflow_name or "Unknown", executed, total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/runs")
|
||||
|
|
@ -586,6 +633,101 @@ async def get_campaign_runs(
|
|||
)
|
||||
|
||||
|
||||
class RedialCampaignRequest(BaseModel):
|
||||
name: Optional[str] = Field(
|
||||
None, min_length=1, max_length=255, description="Name for the redial campaign"
|
||||
)
|
||||
retry_on_voicemail: bool = True
|
||||
retry_on_no_answer: bool = True
|
||||
retry_on_busy: bool = True
|
||||
retry_config: Optional[RetryConfigRequest] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_at_least_one_reason(self):
|
||||
if not (
|
||||
self.retry_on_voicemail or self.retry_on_no_answer or self.retry_on_busy
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one of retry_on_voicemail, retry_on_no_answer, "
|
||||
"retry_on_busy must be true"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/redial")
|
||||
async def redial_campaign(
|
||||
campaign_id: int,
|
||||
request: RedialCampaignRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Create a new campaign that re-dials unique subscribers from a completed
|
||||
campaign whose latest call resulted in voicemail, no-answer, or busy.
|
||||
|
||||
The new campaign is created in 'created' state with queued_runs pre-seeded
|
||||
from the parent's original initial contexts. A campaign can be redialed at
|
||||
most once.
|
||||
"""
|
||||
parent = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
if parent.state != "completed":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Only completed campaigns can be redialed (current state: {parent.state})",
|
||||
)
|
||||
|
||||
parent_meta = parent.orchestrator_metadata or {}
|
||||
if parent_meta.get("redialed_campaign_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This campaign has already been redialed",
|
||||
)
|
||||
|
||||
candidates = await db_client.get_redial_candidates(
|
||||
campaign_id=parent.id,
|
||||
include_voicemail=request.retry_on_voicemail,
|
||||
include_no_answer=request.retry_on_no_answer,
|
||||
include_busy=request.retry_on_busy,
|
||||
)
|
||||
if not candidates:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No subscribers match the selected redial criteria",
|
||||
)
|
||||
|
||||
queued_runs_data = [
|
||||
{
|
||||
"campaign_id": 0, # replaced inside create_redial_campaign
|
||||
"source_uuid": c["source_uuid"],
|
||||
"context_variables": c["context_variables"],
|
||||
"state": "queued",
|
||||
}
|
||||
for c in candidates
|
||||
]
|
||||
|
||||
retry_config = (
|
||||
request.retry_config.model_dump()
|
||||
if request.retry_config
|
||||
else parent.retry_config
|
||||
)
|
||||
new_name = request.name or f"{parent.name} (Redial)"
|
||||
|
||||
try:
|
||||
child = await db_client.create_redial_campaign(
|
||||
parent_campaign=parent,
|
||||
new_name=new_name,
|
||||
retry_config=retry_config,
|
||||
queued_runs_data=queued_runs_data,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
workflow_name = await db_client.get_workflow_name(child.workflow_id, user.id)
|
||||
executed, total = await _get_campaign_stats(child.id)
|
||||
return _build_campaign_response(child, workflow_name or "Unknown", executed, total)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/resume")
|
||||
async def resume_campaign(
|
||||
campaign_id: int,
|
||||
|
|
@ -624,7 +766,10 @@ async def resume_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 _build_campaign_response(campaign, workflow_name or "Unknown")
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
return _build_campaign_response(
|
||||
campaign, workflow_name or "Unknown", executed, total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/progress")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from datetime import datetime
|
|||
from typing import List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
|
@ -16,6 +17,7 @@ from api.db.workflow_template_client import WorkflowTemplateClient
|
|||
from api.enums import CallType, PostHogEvent, StorageBackend
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.campaign.report import generate_workflow_report_csv
|
||||
from api.services.configuration.check_validity import UserConfigurationValidator
|
||||
from api.services.configuration.masking import (
|
||||
mask_workflow_definition,
|
||||
|
|
@ -1002,6 +1004,37 @@ async def get_workflow_runs(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/report")
|
||||
async def download_workflow_report(
|
||||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
start_date: Optional[datetime] = Query(
|
||||
None, description="Filter runs created on or after this datetime (ISO 8601)"
|
||||
),
|
||||
end_date: Optional[datetime] = Query(
|
||||
None, description="Filter runs created on or before this datetime (ISO 8601)"
|
||||
),
|
||||
) -> StreamingResponse:
|
||||
"""Download a CSV report of completed runs for a workflow."""
|
||||
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"
|
||||
)
|
||||
|
||||
output, filename = await generate_workflow_report_csv(
|
||||
workflow_id, start_date=start_date, end_date=end_date
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def get_workflow_templates() -> List[WorkflowTemplateResponse]:
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue