mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
fix: number pool initialization in multi telephony setup
If there are multiple telephony configurations, the form number should be initialized from the campaigns given telephonic configuration rather than the organization default telephonic configuration.
This commit is contained in:
parent
81a363b06e
commit
6d93be3ef6
31 changed files with 1105 additions and 238 deletions
|
|
@ -15,11 +15,11 @@ 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.report import generate_campaign_report_csv
|
||||
from api.services.campaign.runner import campaign_runner_service
|
||||
from api.services.campaign.source_sync import CampaignSourceSyncService
|
||||
from api.services.campaign.source_sync_factory import get_sync_service
|
||||
from api.services.quota_service import check_dograh_quota
|
||||
from api.services.reports import generate_campaign_report_csv
|
||||
from api.services.storage import storage_fs
|
||||
|
||||
router = APIRouter(prefix="/campaign")
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ from datetime import datetime, timedelta
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.constants import DEPLOYMENT_MODE
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
from api.services.reports import generate_usage_runs_report_csv
|
||||
|
||||
router = APIRouter(prefix="/organizations")
|
||||
|
||||
|
|
@ -47,7 +49,13 @@ class WorkflowRunUsageResponse(BaseModel):
|
|||
call_duration_seconds: int
|
||||
recording_url: Optional[str] = None
|
||||
transcript_url: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
phone_number: Optional[str] = Field(
|
||||
default=None,
|
||||
deprecated=True,
|
||||
description="Deprecated. Use caller_number and called_number instead.",
|
||||
)
|
||||
caller_number: Optional[str] = None
|
||||
called_number: Optional[str] = None
|
||||
call_type: Optional[str] = None
|
||||
disposition: Optional[str] = None
|
||||
initial_context: Optional[Dict[str, Any]] = None
|
||||
|
|
@ -129,13 +137,55 @@ async def get_mps_credits(user: UserModel = Depends(get_user)):
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
FILTERS_DESCRIPTION = """\
|
||||
JSON-encoded array of filter objects. Each object has the shape:
|
||||
|
||||
```json
|
||||
{ "attribute": "<name>", "type": "<type>", "value": <value> }
|
||||
```
|
||||
|
||||
Supported `attribute` / `type` / `value` combinations:
|
||||
|
||||
| attribute | type | value shape | matches |
|
||||
|-----------------|---------------|----------------------------------------------|------------------------------------------------------|
|
||||
| `runId` | `number` | `{ "value": 12345 }` | exact run id |
|
||||
| `workflowId` | `number` | `{ "value": 42 }` | exact agent (workflow) id |
|
||||
| `campaignId` | `number` | `{ "value": 7 }` | exact campaign id |
|
||||
| `callerNumber` | `text` | `{ "value": "415555" }` | substring match on `initial_context.caller_number` |
|
||||
| `calledNumber` | `text` | `{ "value": "9911848" }` | substring match on `initial_context.called_number` |
|
||||
| `dispositionCode` | `multiSelect` | `{ "codes": ["XFER", "DNC"] }` | any of the codes in `gathered_context.mapped_call_disposition` |
|
||||
| `duration` | `numberRange` | `{ "min": 60, "max": 300 }` | call duration (seconds), inclusive bounds |
|
||||
|
||||
Unknown attributes and unsupported `type` values are silently ignored.
|
||||
|
||||
Date filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.
|
||||
"""
|
||||
|
||||
|
||||
@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"),
|
||||
start_date: Optional[str] = Query(
|
||||
None,
|
||||
description="ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.",
|
||||
examples=["2026-04-01T00:00:00Z"],
|
||||
),
|
||||
end_date: Optional[str] = Query(
|
||||
None,
|
||||
description="ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.",
|
||||
examples=["2026-05-01T00:00:00Z"],
|
||||
),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
filters: Optional[str] = Query(None, description="JSON string of filters"),
|
||||
filters: Optional[str] = Query(
|
||||
None,
|
||||
description=FILTERS_DESCRIPTION,
|
||||
examples=[
|
||||
'[{"attribute":"callerNumber","type":"text","value":{"value":"415555"}}]',
|
||||
'[{"attribute":"campaignId","type":"number","value":{"value":7}},'
|
||||
'{"attribute":"duration","type":"numberRange","value":{"min":60,"max":300}}]',
|
||||
'[{"attribute":"dispositionCode","type":"multiSelect","value":{"codes":["XFER","DNC"]}}]',
|
||||
],
|
||||
),
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Get paginated workflow runs with usage for the organization."""
|
||||
|
|
@ -185,6 +235,50 @@ async def get_usage_history(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/usage/runs/report")
|
||||
async def download_usage_runs_report(
|
||||
start_date: Optional[str] = Query(
|
||||
None,
|
||||
description="ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.",
|
||||
),
|
||||
end_date: Optional[str] = Query(
|
||||
None,
|
||||
description="ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.",
|
||||
),
|
||||
filters: Optional[str] = Query(
|
||||
None,
|
||||
description=FILTERS_DESCRIPTION,
|
||||
),
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> StreamingResponse:
|
||||
"""Download a CSV of runs matching the same filters as `/usage/runs`."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
start_dt = datetime.fromisoformat(start_date) if start_date else None
|
||||
end_dt = datetime.fromisoformat(end_date) if end_date else None
|
||||
|
||||
parsed_filters = None
|
||||
if filters:
|
||||
try:
|
||||
parsed_filters = json.loads(filters)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid filters format")
|
||||
|
||||
output, filename = await generate_usage_runs_report_csv(
|
||||
user.selected_organization_id,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt,
|
||||
filters=parsed_filters,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@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"),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ class InitiateCallRequest(BaseModel):
|
|||
# Optional explicit telephony config to use for the test call. If omitted,
|
||||
# falls back to the user's per-user default (when set), then the org default.
|
||||
telephony_configuration_id: int | None = None
|
||||
# Optional caller-ID phone number to dial out from. Must belong to the
|
||||
# resolved telephony configuration; otherwise the provider picks one.
|
||||
from_phone_number_id: int | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -173,11 +176,29 @@ async def initiate_call(
|
|||
|
||||
keywords = {"workflow_id": request.workflow_id, "user_id": user.id}
|
||||
|
||||
# Resolve optional caller-ID. The config has already been validated against
|
||||
# the user's organization, so filtering by config_id is sufficient for
|
||||
# tenant isolation.
|
||||
from_number: str | None = None
|
||||
if request.from_phone_number_id is not None:
|
||||
if telephony_configuration_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="from_phone_number_id_requires_telephony_configuration",
|
||||
)
|
||||
phone_row = await db_client.get_phone_number_for_config(
|
||||
request.from_phone_number_id, telephony_configuration_id
|
||||
)
|
||||
if not phone_row or not phone_row.is_active:
|
||||
raise HTTPException(status_code=400, detail="from_phone_number_not_found")
|
||||
from_number = phone_row.address_normalized
|
||||
|
||||
# Initiate call via provider
|
||||
result = await provider.initiate_call(
|
||||
to_number=phone_number,
|
||||
webhook_url=webhook_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
from_number=from_number,
|
||||
**keywords,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ from api.enums import CallType, PostHogEvent, StorageBackend
|
|||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.sdk_expose import sdk_expose
|
||||
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,
|
||||
|
|
@ -28,6 +27,7 @@ from api.services.configuration.masking import (
|
|||
from api.services.configuration.resolve import resolve_effective_config
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
from api.services.posthog_client import capture_event
|
||||
from api.services.reports import generate_workflow_report_csv
|
||||
from api.services.storage import storage_fs
|
||||
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
|
||||
from api.services.workflow.duplicate import duplicate_workflow
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue