mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: add csv upload functionality for OSS (#29)
feat: add csv upload functionality chore: remove redundant arq-worker from docker-compose
This commit is contained in:
parent
2633ff0a2a
commit
3babb5ced6
26 changed files with 941 additions and 234 deletions
1
.dockerignore
Normal file
1
.dockerignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
api/.env
|
||||
|
|
@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \
|
|||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy and install requirements
|
||||
COPY requirements.txt .
|
||||
COPY api/requirements.txt .
|
||||
|
||||
# Install dependencies to user directory for easy copying
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt && \
|
||||
|
|
@ -53,7 +53,8 @@ ENV PYTHONDONTWRITEBYTECODE=1
|
|||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Copy application code
|
||||
COPY . ./api
|
||||
COPY ./api ./api
|
||||
COPY ./scripts/start_services.sh ./scripts/start_services.sh
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
|
|
@ -61,4 +62,4 @@ ENV PYTHONPATH=/app
|
|||
EXPOSE 8000
|
||||
|
||||
# Run the FastAPI app with uvicorn
|
||||
CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["bash", "-c", "./scripts/start_services.sh && tail -f ./logs/latest/*.log"]
|
||||
32
api/app.py
32
api/app.py
|
|
@ -2,29 +2,22 @@
|
|||
|
||||
import sentry_sdk
|
||||
|
||||
from api.constants import ENABLE_SENTRY, REDIS_URL, SENTRY_DSN
|
||||
from api.constants import DEPLOYMENT_MODE, ENABLE_TELEMETRY, REDIS_URL, SENTRY_DSN
|
||||
from api.logging_config import ENVIRONMENT, setup_logging
|
||||
|
||||
# Set up logging and get the listener for cleanup
|
||||
logging_queue_listener = setup_logging()
|
||||
setup_logging()
|
||||
|
||||
|
||||
if ENABLE_SENTRY:
|
||||
if not SENTRY_DSN:
|
||||
print(
|
||||
"Warning: ENABLE_SENTRY is true but SENTRY_DSN is not configured. Sentry disabled."
|
||||
)
|
||||
else:
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
# Add data like request headers and IP for users,
|
||||
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
||||
send_default_pii=True,
|
||||
environment=ENVIRONMENT,
|
||||
)
|
||||
print(f"Sentry initialized in environment: {ENVIRONMENT}")
|
||||
else:
|
||||
print(f"Sentry disabled (ENABLE_SENTRY=false)")
|
||||
if SENTRY_DSN and (
|
||||
DEPLOYMENT_MODE != "oss" or (DEPLOYMENT_MODE == "oss" and ENABLE_TELEMETRY)
|
||||
):
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
send_default_pii=True,
|
||||
environment=ENVIRONMENT,
|
||||
)
|
||||
print(f"Sentry initialized in environment: {ENVIRONMENT}")
|
||||
|
||||
|
||||
import asyncio
|
||||
|
|
@ -91,9 +84,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
await redis.aclose()
|
||||
|
||||
if logging_queue_listener is not None:
|
||||
logging_queue_listener.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Dograh API",
|
||||
|
|
|
|||
|
|
@ -43,5 +43,9 @@ S3_BUCKET = os.environ.get("S3_BUCKET")
|
|||
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
|
||||
|
||||
# Sentry configuration
|
||||
ENABLE_SENTRY = os.getenv("ENABLE_SENTRY", "false").lower() == "true"
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||
|
||||
|
||||
ENABLE_ARI_STASIS = os.getenv("ENABLE_ARI_STASIS", "false").lower() == "true"
|
||||
SERIALIZE_LOG_OUTPUT = os.getenv("SERIALIZE_LOG_OUTPUT", "false").lower() == "true"
|
||||
ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import sys
|
|||
|
||||
import loguru
|
||||
|
||||
from api.constants import SERIALIZE_LOG_OUTPUT
|
||||
from api.enums import Environment
|
||||
from api.utils.worker import get_worker_id, is_worker_process
|
||||
from pipecat.utils.context import run_id_var, turn_var
|
||||
|
|
@ -88,7 +89,7 @@ def setup_logging():
|
|||
patched.add(
|
||||
actual_log_path,
|
||||
level=log_level,
|
||||
serialize=True, # Use JSON serialization for structured logs
|
||||
serialize=SERIALIZE_LOG_OUTPUT, # Use JSON serialization for structured logs
|
||||
enqueue=True, # Thread-safe writing
|
||||
backtrace=True, # Include full traceback in exceptions
|
||||
diagnose=False, # Don't include local variables in traceback for security
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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
|
||||
from api.services.storage import storage_fs
|
||||
|
||||
router = APIRouter(prefix="/campaign")
|
||||
|
||||
|
|
@ -16,7 +17,8 @@ router = APIRouter(prefix="/campaign")
|
|||
class CreateCampaignRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
workflow_id: int
|
||||
source_id: str # Sheet URL
|
||||
source_type: str = Field(..., pattern="^(google-sheet|csv)$")
|
||||
source_id: str # Google Sheet URL or CSV file key
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
|
|
@ -74,7 +76,7 @@ async def create_campaign(
|
|||
campaign = await db_client.create_campaign(
|
||||
name=request.name,
|
||||
workflow_id=request.workflow_id,
|
||||
source_type="google-sheet",
|
||||
source_type=request.source_type,
|
||||
source_id=request.source_id,
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
|
|
@ -174,14 +176,10 @@ async def start_campaign(
|
|||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if (
|
||||
not twilio_config
|
||||
or not twilio_config.value
|
||||
or not twilio_config.value.get("value")
|
||||
):
|
||||
if not twilio_config or not twilio_config.value:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
|
||||
detail="You must configure telephony first by going to APP_URL/configure-telephony",
|
||||
)
|
||||
|
||||
# Verify campaign exists and belongs to organization
|
||||
|
|
@ -286,14 +284,10 @@ async def resume_campaign(
|
|||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if (
|
||||
not twilio_config
|
||||
or not twilio_config.value
|
||||
or not twilio_config.value.get("value")
|
||||
):
|
||||
if not twilio_config or not twilio_config.value:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
|
||||
detail="You must configure telephony first by going to APP_URL/configure-telephony",
|
||||
)
|
||||
|
||||
# Verify campaign exists and belongs to organization
|
||||
|
|
@ -345,3 +339,59 @@ async def get_campaign_progress(
|
|||
return CampaignProgressResponse(**progress)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
class CampaignSourceDownloadResponse(BaseModel):
|
||||
download_url: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/source-download-url")
|
||||
async def get_campaign_source_download_url(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignSourceDownloadResponse:
|
||||
"""Get presigned download URL for campaign CSV source file
|
||||
|
||||
Only works for CSV source type. For Google Sheets, use the source_id directly.
|
||||
Validates that the campaign belongs to the user's organization for security.
|
||||
"""
|
||||
# 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")
|
||||
|
||||
# Only generate download URL for CSV files
|
||||
if campaign.source_type != "csv":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Download URL only available for CSV sources. This campaign uses {campaign.source_type}",
|
||||
)
|
||||
|
||||
# Verify the file key belongs to the user's organization
|
||||
# File key format: campaigns/{org_id}/{uuid}_{filename}.csv
|
||||
if not campaign.source_id.startswith(f"campaigns/{user.selected_organization_id}/"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Access denied: Source file does not belong to your organization",
|
||||
)
|
||||
|
||||
# Generate presigned download URL
|
||||
try:
|
||||
download_url = await storage_fs.aget_signed_url(
|
||||
campaign.source_id,
|
||||
expiration=3600, # 1 hour
|
||||
)
|
||||
|
||||
if not download_url:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to generate download URL"
|
||||
)
|
||||
|
||||
return CampaignSourceDownloadResponse(
|
||||
download_url=download_url, expires_in=3600
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to generate download URL: {str(e)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import re
|
||||
import uuid
|
||||
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 pydantic import BaseModel, Field
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import StorageBackend
|
||||
|
|
@ -20,6 +23,20 @@ class FileMetadataResponse(TypedDict):
|
|||
metadata: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
class PresignedUploadUrlRequest(BaseModel):
|
||||
file_name: str = Field(..., pattern=r".*\.csv$", description="CSV filename")
|
||||
file_size: int = Field(
|
||||
..., gt=0, le=10_485_760, description="File size in bytes (max 10MB)"
|
||||
)
|
||||
content_type: str = Field(default="text/csv", description="File content type")
|
||||
|
||||
|
||||
class PresignedUploadUrlResponse(BaseModel):
|
||||
upload_url: str
|
||||
file_key: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
router = APIRouter(prefix="/s3", tags=["s3"])
|
||||
|
||||
|
||||
|
|
@ -217,3 +234,65 @@ async def get_file_metadata(
|
|||
except Exception as exc:
|
||||
logger.error(f"Error getting file metadata: {exc}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get file metadata")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/presigned-upload-url",
|
||||
response_model=PresignedUploadUrlResponse,
|
||||
summary="Generate a presigned URL for direct CSV upload",
|
||||
)
|
||||
async def get_presigned_upload_url(
|
||||
request: PresignedUploadUrlRequest,
|
||||
user=Depends(get_user),
|
||||
):
|
||||
"""Generate a presigned PUT URL for direct CSV file upload to S3/MinIO.
|
||||
|
||||
This endpoint enables browser-to-storage uploads without passing through the backend
|
||||
|
||||
Access Control:
|
||||
* All authenticated users can upload CSV files scoped to their organization.
|
||||
* Files are stored with organization-scoped keys for multi-tenancy.
|
||||
|
||||
Returns:
|
||||
* upload_url: Presigned URL (valid for 15 minutes) for PUT request
|
||||
* file_key: Unique storage key to use as source_id in campaign creation
|
||||
* expires_in: URL expiration time in seconds
|
||||
"""
|
||||
|
||||
# Sanitize filename - remove special chars, keep only alphanumeric, dash, underscore, and dot
|
||||
sanitized_name = re.sub(r"[^a-zA-Z0-9._-]", "_", request.file_name)
|
||||
|
||||
# Generate unique file key: campaigns/{org_id}/{uuid}_{filename}.csv
|
||||
file_key = (
|
||||
f"campaigns/{user.selected_organization_id}/{uuid.uuid4()}_{sanitized_name}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Generate presigned PUT URL using current storage backend
|
||||
upload_url = await storage_fs.aget_presigned_put_url(
|
||||
file_path=file_key,
|
||||
expiration=900, # 15 minutes
|
||||
content_type=request.content_type,
|
||||
max_size=request.file_size,
|
||||
)
|
||||
|
||||
if not upload_url:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to generate presigned upload URL"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Generated presigned upload URL for user {user.id}, org {user.selected_organization_id}, file_key: {file_key}"
|
||||
)
|
||||
|
||||
return PresignedUploadUrlResponse(
|
||||
upload_url=upload_url,
|
||||
file_key=file_key,
|
||||
expires_in=900,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error generating presigned upload URL: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to generate presigned upload URL"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ for final completion after 1 hour of inactivity and handles retry events.
|
|||
|
||||
from api.logging_config import setup_logging
|
||||
|
||||
logging_queue_listener = setup_logging()
|
||||
setup_logging()
|
||||
|
||||
|
||||
import asyncio
|
||||
|
|
@ -495,7 +495,7 @@ class CampaignOrchestrator:
|
|||
if self._pubsub:
|
||||
try:
|
||||
await self._pubsub.unsubscribe(RedisChannel.CAMPAIGN_EVENTS.value)
|
||||
await self._pubsub.close()
|
||||
await self._pubsub.aclose()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing pubsub: {e}")
|
||||
|
||||
|
|
@ -538,16 +538,12 @@ async def main():
|
|||
if shutdown_task in done:
|
||||
logger.info("Shutdown signal received, stopping orchestrator...")
|
||||
orchestrator._running = False
|
||||
# Wait for orchestrator to finish gracefully
|
||||
# Cancel the orchestrator task immediately since it may be blocked
|
||||
orchestrator_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(orchestrator_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Orchestrator shutdown timeout, cancelling...")
|
||||
orchestrator_task.cancel()
|
||||
try:
|
||||
await orchestrator_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await orchestrator_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Orchestrator task cancelled successfully")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
|
|
|
|||
|
|
@ -31,19 +31,3 @@ class CampaignSourceSyncService(ABC):
|
|||
f"Getting credentials for org {organization_id}, source {source_type}"
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def get_sync_service(source_type: str) -> CampaignSourceSyncService:
|
||||
"""Returns appropriate sync service based on source type"""
|
||||
from .sources.google_sheets import GoogleSheetsSyncService
|
||||
|
||||
services = {
|
||||
"google-sheet": GoogleSheetsSyncService,
|
||||
# Add more as needed: "hubspot": HubSpotSyncService,
|
||||
}
|
||||
|
||||
service_class = services.get(source_type)
|
||||
if not service_class:
|
||||
raise ValueError(f"Unknown source type: {source_type}")
|
||||
|
||||
return service_class()
|
||||
|
|
|
|||
19
api/services/campaign/source_sync_factory.py
Normal file
19
api/services/campaign/source_sync_factory.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from api.services.campaign.source_sync import CampaignSourceSyncService
|
||||
from api.services.campaign.sources.csv import CSVSyncService
|
||||
from api.services.campaign.sources.google_sheets import GoogleSheetsSyncService
|
||||
|
||||
|
||||
def get_sync_service(source_type: str) -> CampaignSourceSyncService:
|
||||
"""Returns appropriate sync service based on source type"""
|
||||
|
||||
services = {
|
||||
"google-sheet": GoogleSheetsSyncService,
|
||||
"csv": CSVSyncService,
|
||||
# Add more as needed: "hubspot": HubSpotSyncService,
|
||||
}
|
||||
|
||||
service_class = services.get(source_type)
|
||||
if not service_class:
|
||||
raise ValueError(f"Unknown source type: {source_type}")
|
||||
|
||||
return service_class()
|
||||
140
api/services/campaign/sources/csv.py
Normal file
140
api/services/campaign/sources/csv.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import csv
|
||||
import hashlib
|
||||
from io import StringIO
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.campaign.source_sync import CampaignSourceSyncService
|
||||
from api.services.storage import storage_fs
|
||||
|
||||
|
||||
class CSVSyncService(CampaignSourceSyncService):
|
||||
"""Implementation for CSV file synchronization"""
|
||||
|
||||
async def sync_source_data(self, campaign_id: int) -> int:
|
||||
"""
|
||||
Fetches data from CSV file in S3/MinIO and creates queued_runs
|
||||
"""
|
||||
# Get campaign
|
||||
campaign = await db_client.get_campaign_by_id(campaign_id)
|
||||
if not campaign:
|
||||
raise ValueError(f"Campaign {campaign_id} not found")
|
||||
|
||||
# 1. Get download URL using internal endpoint (for container-to-container access)
|
||||
file_key = campaign.source_id
|
||||
signed_url = await storage_fs.aget_signed_url(
|
||||
file_key, expiration=3600, use_internal_endpoint=True
|
||||
)
|
||||
|
||||
if not signed_url:
|
||||
raise ValueError(f"Failed to generate download URL for file: {file_key}")
|
||||
|
||||
# 2. Download CSV file
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(signed_url)
|
||||
response.raise_for_status()
|
||||
csv_content = response.text
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to download CSV file: {e} for url: {signed_url}")
|
||||
raise ValueError(f"Failed to download CSV file from storage: {str(e)}")
|
||||
|
||||
# 3. Parse CSV
|
||||
csv_data = self._parse_csv(csv_content)
|
||||
|
||||
if not csv_data or len(csv_data) < 2:
|
||||
logger.warning(f"No data found in CSV for campaign {campaign_id}")
|
||||
return 0
|
||||
|
||||
headers = csv_data[0] # First row is headers
|
||||
rows = csv_data[1:] # Rest is data
|
||||
|
||||
# 4. Create hash of file_key for consistent source_uuid prefix
|
||||
file_hash = hashlib.md5(file_key.encode()).hexdigest()[:8]
|
||||
|
||||
# 5. Convert to queued_runs
|
||||
queued_runs = []
|
||||
for idx, row_values in enumerate(rows, 1):
|
||||
# Pad row to match headers length
|
||||
padded_row = row_values + [""] * (len(headers) - len(row_values))
|
||||
|
||||
# Create context variables dict
|
||||
context_vars = dict(zip(headers, padded_row))
|
||||
|
||||
# Skip if no phone number
|
||||
if not context_vars.get("phone_number"):
|
||||
logger.debug(f"Skipping row {idx}: no phone_number")
|
||||
continue
|
||||
|
||||
# Generate unique source UUID: csv_{hash(source_id)}_row_{idx}
|
||||
source_uuid = f"csv_{file_hash}_row_{idx}"
|
||||
|
||||
queued_runs.append(
|
||||
{
|
||||
"campaign_id": campaign_id,
|
||||
"source_uuid": source_uuid,
|
||||
"context_variables": context_vars,
|
||||
"state": "queued",
|
||||
}
|
||||
)
|
||||
|
||||
# 6. Bulk insert
|
||||
if queued_runs:
|
||||
await db_client.bulk_create_queued_runs(queued_runs)
|
||||
logger.info(
|
||||
f"Created {len(queued_runs)} queued runs for campaign {campaign_id}"
|
||||
)
|
||||
|
||||
# 7. Update campaign total_rows
|
||||
await db_client.update_campaign(
|
||||
campaign_id=campaign_id,
|
||||
total_rows=len(queued_runs),
|
||||
source_sync_status="completed",
|
||||
)
|
||||
|
||||
return len(queued_runs)
|
||||
|
||||
def _parse_csv(self, csv_content: str) -> List[List[str]]:
|
||||
"""Parse CSV content into rows"""
|
||||
try:
|
||||
csv_file = StringIO(csv_content)
|
||||
reader = csv.reader(csv_file)
|
||||
return list(reader)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse CSV: {e}")
|
||||
raise ValueError(f"Invalid CSV format: {str(e)}")
|
||||
|
||||
async def validate_source_schema(self, source_config: Dict[str, Any]) -> bool:
|
||||
"""Validate that required columns exist in CSV"""
|
||||
required_columns = ["phone_number", "first_name", "last_name"]
|
||||
|
||||
file_key = source_config.get("source_id")
|
||||
if not file_key:
|
||||
return False
|
||||
|
||||
# Get download URL using internal endpoint
|
||||
signed_url = await storage_fs.aget_signed_url(
|
||||
file_key, expiration=3600, use_internal_endpoint=True
|
||||
)
|
||||
if not signed_url:
|
||||
return False
|
||||
|
||||
# Download just enough to get headers
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(signed_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Get just the first line for headers
|
||||
first_line = response.text.split("\n")[0]
|
||||
csv_file = StringIO(first_line)
|
||||
reader = csv.reader(csv_file)
|
||||
headers = next(reader, [])
|
||||
|
||||
return all(col in headers for col in required_columns)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate CSV schema: {e}")
|
||||
return False
|
||||
|
|
@ -33,13 +33,19 @@ class BaseFileSystem(ABC):
|
|||
|
||||
@abstractmethod
|
||||
async def aget_signed_url(
|
||||
self, file_path: str, expiration: int = 3600
|
||||
self,
|
||||
file_path: str,
|
||||
expiration: int = 3600,
|
||||
force_inline: bool = False,
|
||||
use_internal_endpoint: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Generate a signed URL for temporary access to a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
expiration: URL expiration time in seconds (default: 1 hour)
|
||||
force_inline: Force inline display (browser preview vs download)
|
||||
use_internal_endpoint: Use internal endpoint (for container-to-container access)
|
||||
|
||||
Returns:
|
||||
Optional[str]: Signed URL if successful, None otherwise
|
||||
|
|
@ -58,3 +64,24 @@ class BaseFileSystem(ABC):
|
|||
Contains: size, created_at, modified_at, etag, etc.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def aget_presigned_put_url(
|
||||
self,
|
||||
file_path: str,
|
||||
expiration: int = 900,
|
||||
content_type: str = "text/csv",
|
||||
max_size: int = 10_485_760,
|
||||
) -> Optional[str]:
|
||||
"""Generate a presigned PUT URL for direct file upload.
|
||||
|
||||
Args:
|
||||
file_path: Path where the file should be uploaded
|
||||
expiration: URL expiration time in seconds (default: 15 minutes)
|
||||
content_type: MIME type of the file (default: text/csv)
|
||||
max_size: Maximum file size in bytes (default: 10MB)
|
||||
|
||||
Returns:
|
||||
Optional[str]: Presigned PUT URL if successful, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -48,17 +48,28 @@ class MinioFileSystem(BaseFileSystem):
|
|||
if not self.client.bucket_exists(self.bucket_name):
|
||||
self.client.make_bucket(self.bucket_name)
|
||||
|
||||
# Set anonymous download policy for local development
|
||||
# This allows unsigned URLs to work
|
||||
# Set public read/write policy for local development
|
||||
# This allows:
|
||||
# 1. Anonymous downloads (s3:GetObject)
|
||||
# 2. Anonymous uploads (s3:PutObject) - bypasses presigned URL signature issues
|
||||
# 3. List bucket contents (s3:ListBucket) for debugging
|
||||
# Note: This is set on every initialization to ensure policy is correct
|
||||
# WARNING: Only use in local development, not production!
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
||||
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": [f"arn:aws:s3:::{self.bucket_name}"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -97,14 +108,19 @@ class MinioFileSystem(BaseFileSystem):
|
|||
return False
|
||||
|
||||
async def aget_signed_url(
|
||||
self, file_path: str, expiration: int = 3600, force_inline: bool = False
|
||||
self,
|
||||
file_path: str,
|
||||
expiration: int = 3600,
|
||||
force_inline: bool = False,
|
||||
use_internal_endpoint: bool = False,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
# For MinIO in local development, return unsigned URLs
|
||||
# This avoids signature mismatch issues when endpoint differs
|
||||
# MinIO must be configured to allow anonymous read access
|
||||
protocol = "https" if self.secure else "http"
|
||||
url = f"{protocol}://{self.public_endpoint}/{self.bucket_name}/{file_path}"
|
||||
endpoint = self.endpoint if use_internal_endpoint else self.public_endpoint
|
||||
url = f"{protocol}://{endpoint}/{self.bucket_name}/{file_path}"
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating MinIO URL: {e}")
|
||||
|
|
@ -128,3 +144,29 @@ class MinioFileSystem(BaseFileSystem):
|
|||
}
|
||||
except S3Error:
|
||||
return None
|
||||
|
||||
async def aget_presigned_put_url(
|
||||
self,
|
||||
file_path: str,
|
||||
expiration: int = 900,
|
||||
content_type: str = "text/csv",
|
||||
max_size: int = 10_485_760,
|
||||
) -> Optional[str]:
|
||||
"""Generate an unsigned URL for direct file upload.
|
||||
|
||||
For local MinIO development with anonymous upload enabled, we return
|
||||
a simple unsigned URL instead of a presigned URL. This avoids signature
|
||||
mismatch issues when the internal endpoint (minio:9000) differs from
|
||||
the public endpoint (localhost:9000).
|
||||
|
||||
The bucket policy allows anonymous s3:PutObject, so no signature is needed.
|
||||
"""
|
||||
try:
|
||||
# Return unsigned URL for anonymous upload
|
||||
protocol = "https" if self.secure else "http"
|
||||
url = f"{protocol}://{self.public_endpoint}/{self.bucket_name}/{file_path}"
|
||||
logger.debug(f"Generated unsigned upload URL: {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating MinIO upload URL: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ class S3FileSystem(BaseFileSystem):
|
|||
return False
|
||||
|
||||
async def aget_signed_url(
|
||||
self, file_path: str, expiration: int = 3600, force_inline: bool = False
|
||||
self,
|
||||
file_path: str,
|
||||
expiration: int = 3600,
|
||||
force_inline: bool = False,
|
||||
use_internal_endpoint: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Generate a presigned GET url for the given object.
|
||||
|
||||
|
|
@ -97,3 +101,28 @@ class S3FileSystem(BaseFileSystem):
|
|||
}
|
||||
except ClientError:
|
||||
return None
|
||||
|
||||
async def aget_presigned_put_url(
|
||||
self,
|
||||
file_path: str,
|
||||
expiration: int = 900,
|
||||
content_type: str = "text/csv",
|
||||
max_size: int = 10_485_760,
|
||||
) -> Optional[str]:
|
||||
"""Generate a presigned PUT URL for direct file upload."""
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
url = await s3_client.generate_presigned_url(
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": file_path,
|
||||
"ContentType": content_type,
|
||||
},
|
||||
ExpiresIn=expiration,
|
||||
)
|
||||
return url
|
||||
except ClientError:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -426,16 +426,7 @@ async def setup_ari_client_supervisor(
|
|||
|
||||
This is a drop-in replacement for the asyncari-based function.
|
||||
Uses AsyncARIClient instead of asyncari.
|
||||
|
||||
If the *ENABLE_ARI_STASIS* environment variable is not set to ``"true"``
|
||||
(case-insensitive) the function returns ``None`` and no supervisor is
|
||||
launched.
|
||||
"""
|
||||
|
||||
if os.getenv("ENABLE_ARI_STASIS", "false").lower() != "true":
|
||||
logger.info("ARI Stasis integration disabled via environment variable")
|
||||
return None
|
||||
|
||||
logger.info("Starting ARI Client Supervisor with AsyncARIClient")
|
||||
|
||||
supervisor = _ARIClientManagerSupervisor(on_channel_start, on_channel_end)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import signal
|
|||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from api.constants import REDIS_URL
|
||||
from api.constants import ENABLE_ARI_STASIS, REDIS_URL
|
||||
|
||||
# --- Add logging setup before importing loguru ---
|
||||
from api.logging_config import setup_logging
|
||||
|
|
@ -32,7 +32,7 @@ from api.services.telephony.stasis_event_protocol import (
|
|||
parse_command,
|
||||
)
|
||||
|
||||
logging_queue_listener = setup_logging()
|
||||
setup_logging()
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
import redis.exceptions
|
||||
|
|
@ -601,9 +601,13 @@ class ARIManager:
|
|||
|
||||
async def run(self):
|
||||
"""Main run loop for ARI Manager."""
|
||||
self._running = True
|
||||
if not ENABLE_ARI_STASIS:
|
||||
logger.info("ARI Stasis integration disabled via environment variable")
|
||||
return
|
||||
|
||||
# Setup ARI connection with supervisor
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
self._ari_client_supervisor = await setup_ari_client_supervisor(
|
||||
self.on_channel_start, self.on_channel_end
|
||||
|
|
@ -737,9 +741,6 @@ async def main():
|
|||
pass
|
||||
finally:
|
||||
await redis.aclose()
|
||||
# --- Ensure Axiom logging listener is stopped gracefully ---
|
||||
if logging_queue_listener is not None:
|
||||
logging_queue_listener.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from api.constants import REDIS_URL
|
|||
from api.logging_config import setup_logging
|
||||
from api.tasks.function_names import FunctionNames
|
||||
|
||||
logging_queue_listener = setup_logging()
|
||||
setup_logging()
|
||||
|
||||
# Now import ARQ and task dependencies
|
||||
from arq import create_pool
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from api.services.campaign.campaign_event_protocol import BatchFailedEvent
|
|||
from api.services.campaign.campaign_event_publisher import (
|
||||
get_campaign_event_publisher,
|
||||
)
|
||||
from api.services.campaign.source_sync import get_sync_service
|
||||
from api.services.campaign.source_sync_factory import get_sync_service
|
||||
|
||||
|
||||
async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ services:
|
|||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_API_CORS_ALLOW_ORIGIN: "*"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000" # Bind to localhost explicitly
|
||||
- "127.0.0.1:9001:9001"
|
||||
|
|
@ -90,8 +91,8 @@ services:
|
|||
# LANGFUSE_HOST: "https://langfuse.dograh.com"
|
||||
|
||||
# Sentry
|
||||
ENABLE_SETRY: "false"
|
||||
SENTRY_DSN: ""
|
||||
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
|
||||
SENTRY_DSN: "https://3acdb63d5f1f70430953353b82de61e0@o4509486225096704.ingest.us.sentry.io/4510152922693632"
|
||||
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
|
@ -104,12 +105,6 @@ services:
|
|||
condition: service_healthy
|
||||
cloudflared:
|
||||
condition: service_started
|
||||
command: >
|
||||
bash -c "
|
||||
cd /app/api &&
|
||||
alembic upgrade head &&
|
||||
uvicorn api.app:app --host 0.0.0.0 --port 8000
|
||||
"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
|
|
@ -123,49 +118,6 @@ services:
|
|||
networks:
|
||||
- app-network
|
||||
|
||||
arq-worker:
|
||||
image: ${REGISTRY:-dograhai}/dograh-api:latest
|
||||
volumes:
|
||||
- shared-tmp:/tmp
|
||||
environment:
|
||||
# Core application config
|
||||
ENVIRONMENT: "local"
|
||||
LOG_LEVEL: "INFO"
|
||||
|
||||
# Database configuration (using containerized postgres)
|
||||
DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# Redis configuration (using containerized redis)
|
||||
REDIS_URL: "redis://:redissecret@redis:6379"
|
||||
|
||||
# Storage configuration - using local MinIO
|
||||
ENABLE_AWS_S3: "false"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
MINIO_ACCESS_KEY: "minioadmin"
|
||||
MINIO_SECRET_KEY: "minioadmin"
|
||||
MINIO_BUCKET: "voice-audio"
|
||||
MINIO_SECURE: "false"
|
||||
|
||||
# Sentry
|
||||
ENABLE_SETRY: "false"
|
||||
SENTRY_DSN: ""
|
||||
command: >
|
||||
bash -c "
|
||||
cd /app/api &&
|
||||
python -m arq api.tasks.arq.WorkerSettings
|
||||
"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
ui:
|
||||
image: ${REGISTRY:-dograhai}/dograh-ui:latest
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -4,18 +4,22 @@
|
|||
set -e # Exit on error
|
||||
|
||||
### CONFIGURATION #############################################################
|
||||
ENV_FILE="api/.env"
|
||||
RUN_DIR="run"
|
||||
BASE_LOG_DIR="/home/ubuntu/dograh/logs" # Base logs directory (same as start_services.sh)
|
||||
|
||||
# Determine BASE_DIR as parent of the scripts directory
|
||||
BASE_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
||||
ENV_FILE="$BASE_DIR/api/.env"
|
||||
RUN_DIR="$BASE_DIR/run"
|
||||
BASE_LOG_DIR="$BASE_DIR/logs" # Base logs directory (same as start_services.sh)
|
||||
LATEST_LINK="$BASE_LOG_DIR/latest" # Symlink to latest logs (same as start_services.sh)
|
||||
VENV_PATH="/home/ubuntu/dograh/venv"
|
||||
VENV_PATH="$BASE_DIR/venv"
|
||||
HEALTH_CHECK_ENDPOINT="/api/v1/health" # Adjust as needed
|
||||
MAX_WAIT_SECONDS=310 # Max wait for graceful shutdown (5 minutes + 10 seconds grace)
|
||||
|
||||
# Load environment
|
||||
set -a && . "$ENV_FILE" && set +a
|
||||
|
||||
cd /home/ubuntu/dograh/app
|
||||
cd "$BASE_DIR"
|
||||
|
||||
### FUNCTIONS ##################################################################
|
||||
|
||||
|
|
@ -166,10 +170,13 @@ start_new_uvicorn_workers() {
|
|||
|
||||
log_info "Starting uvicorn with $FASTAPI_WORKERS workers on port $new_port"
|
||||
log_info "Logs: $LOG_FILE_PATH"
|
||||
|
||||
# Start in new process group with setsid (same as start_services.sh)
|
||||
# Each service gets its own LOG_FILE_PATH environment variable
|
||||
setsid nohup bash -c "LOG_FILE_PATH='$LOG_FILE_PATH' uvicorn api.app:app --host 0.0.0.0 --port $new_port --workers $FASTAPI_WORKERS" >/dev/null 2>&1 &
|
||||
|
||||
# Start in background (same pattern as start_services.sh)
|
||||
(
|
||||
cd "$BASE_DIR"
|
||||
export LOG_FILE_PATH="$log_dir/uvicorn-rollover-${timestamp}-${script_pid}.log"
|
||||
exec uvicorn api.app:app --host 0.0.0.0 --port $new_port --workers $FASTAPI_WORKERS >>"$LOG_FILE_PATH" 2>&1
|
||||
) &
|
||||
|
||||
local new_pid=$!
|
||||
echo "$new_pid" > "$RUN_DIR/uvicorn_new.pid"
|
||||
|
|
|
|||
|
|
@ -1,70 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
# start_services.sh
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
### CONFIGURATION #############################################################
|
||||
ENV_FILE="api/.env"
|
||||
RUN_DIR="run" # where we keep *.pid
|
||||
BASE_LOG_DIR="/home/ubuntu/dograh/logs" # base logs directory
|
||||
###############################################################################
|
||||
### CONFIGURATION
|
||||
###############################################################################
|
||||
|
||||
# Determine BASE_DIR as parent of the scripts directory
|
||||
BASE_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
||||
ENV_FILE="$BASE_DIR/api/.env"
|
||||
RUN_DIR="$BASE_DIR/run" # Where we keep *.pid
|
||||
BASE_LOG_DIR="$BASE_DIR/logs" # Base logs directory
|
||||
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
LOG_DIR="$BASE_LOG_DIR/$TIMESTAMP" # timestamped log directory
|
||||
LATEST_LINK="$BASE_LOG_DIR/latest" # symlink to latest logs
|
||||
VENV_PATH="/home/ubuntu/dograh/venv"
|
||||
LOG_DIR="$BASE_LOG_DIR/$TIMESTAMP" # Timestamped log directory
|
||||
LATEST_LINK="$BASE_LOG_DIR/latest" # Symlink to latest logs
|
||||
VENV_PATH="$BASE_DIR/venv"
|
||||
|
||||
ARQ_WORKERS=${ARQ_WORKERS:-1}
|
||||
|
||||
# Log startup
|
||||
echo "Starting Dograh Services at $(date)"
|
||||
cd "$BASE_DIR"
|
||||
echo "Starting Dograh Services at $(date) in BASE_DIR: ${BASE_DIR}"
|
||||
|
||||
### 1) Load environment vars so that configurations like FASTAPI_WORKERS are loaded
|
||||
set -a && . "$ENV_FILE" && set +a
|
||||
###############################################################################
|
||||
### 1) Load environment variables
|
||||
###############################################################################
|
||||
|
||||
cd /home/ubuntu/dograh/app
|
||||
|
||||
if [[ -z "${FASTAPI_PORT:-}" ]]; then
|
||||
echo "Error: FASTAPI_PORT environment variable is not set."
|
||||
exit 1
|
||||
# Load environment from a file if it exists
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a && . "$ENV_FILE" && set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${FASTAPI_WORKERS:-}" ]]; then
|
||||
echo "Error: FASTAPI_WORKERS environment variable is not set."
|
||||
exit 1
|
||||
fi
|
||||
FASTAPI_PORT=${FASTAPI_PORT:-8000}
|
||||
FASTAPI_WORKERS=${FASTAPI_WORKERS:-1}
|
||||
|
||||
# map "service name" → "command to run"
|
||||
declare -A SERVICES=(
|
||||
[ari_manager]="python -m api.services.telephony.ari_manager"
|
||||
[campaign_orchestrator]="python -m api.services.campaign.campaign_orchestrator"
|
||||
[uvicorn]="uvicorn api.app:app --host 0.0.0.0 --port $FASTAPI_PORT --workers $FASTAPI_WORKERS"
|
||||
###############################################################################
|
||||
### 2) Define services
|
||||
###############################################################################
|
||||
|
||||
# Map "service name" → "command to run"
|
||||
# Using arrays for bash 3.2 compatibility
|
||||
SERVICE_NAMES=(
|
||||
"ari_manager"
|
||||
"campaign_orchestrator"
|
||||
"uvicorn"
|
||||
)
|
||||
|
||||
# Add ARQ workers dynamically based on ARQ_WORKERS environment variable
|
||||
SERVICE_COMMANDS=(
|
||||
"python -m api.services.telephony.ari_manager"
|
||||
"python -m api.services.campaign.campaign_orchestrator"
|
||||
"uvicorn api.app:app --host 0.0.0.0 --port $FASTAPI_PORT --workers $FASTAPI_WORKERS"
|
||||
)
|
||||
|
||||
# Add ARQ workers dynamically
|
||||
for ((i=1; i<=ARQ_WORKERS; i++)); do
|
||||
SERVICES[arq$i]="python -m arq api.tasks.arq.WorkerSettings --custom-log-dict api.tasks.arq.LOG_CONFIG"
|
||||
SERVICE_NAMES+=("arq$i")
|
||||
SERVICE_COMMANDS+=("python -m arq api.tasks.arq.WorkerSettings --custom-log-dict api.tasks.arq.LOG_CONFIG")
|
||||
done
|
||||
|
||||
### 2) Activate virtual environment #########################################
|
||||
source ${VENV_PATH}/bin/activate
|
||||
###############################################################################
|
||||
### 3) Activate virtual environment
|
||||
###############################################################################
|
||||
|
||||
if [[ -d "$VENV_PATH" && -f "$VENV_PATH/bin/activate" ]]; then
|
||||
source "$VENV_PATH/bin/activate"
|
||||
echo "Virtual environment activated: $VENV_PATH"
|
||||
else
|
||||
echo "Warning: Virtual environment not found at $VENV_PATH"
|
||||
echo "Continuing without virtual environment activation..."
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
### 4) Stop old services
|
||||
###############################################################################
|
||||
|
||||
### 3) Stop old services (only via PID files) #################################
|
||||
mkdir -p "$RUN_DIR"
|
||||
for name in "${!SERVICES[@]}"; do
|
||||
for name in "${SERVICE_NAMES[@]}"; do
|
||||
pidfile="$RUN_DIR/$name.pid"
|
||||
|
||||
if [[ -f $pidfile ]]; then
|
||||
oldpid=$(<"$pidfile")
|
||||
if kill -0 "$oldpid"; then
|
||||
|
||||
if kill -0 "$oldpid" 2>/dev/null; then
|
||||
echo "Stopping $name (PID $oldpid and its process group)…"
|
||||
|
||||
# Kill the entire process group (negative PID)
|
||||
# First try SIGTERM
|
||||
kill -TERM -"$oldpid" || kill -TERM "$oldpid" || true
|
||||
kill -TERM -"$oldpid" 2>/dev/null || kill -TERM "$oldpid" 2>/dev/null || true
|
||||
sleep 4
|
||||
# If still running, use SIGKILL
|
||||
if kill -0 "$oldpid"; then
|
||||
|
||||
if kill -0 "$oldpid" 2>/dev/null; then
|
||||
echo "⚠️ $name did not exit cleanly, forcing stop..."
|
||||
kill -KILL -"$oldpid" || kill -KILL "$oldpid" || true
|
||||
kill -KILL -"$oldpid" 2>/dev/null || kill -KILL "$oldpid" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$pidfile"
|
||||
else
|
||||
echo "No PID file for $name, skipping stop."
|
||||
|
|
@ -74,14 +105,19 @@ done
|
|||
# Clean up any port tracking files for uvicorn
|
||||
rm -f "$RUN_DIR/uvicorn.port" "$RUN_DIR/uvicorn_new.port" "$RUN_DIR/uvicorn_old.pid"
|
||||
|
||||
### 4) Run migrations #########################################################
|
||||
alembic -c api/alembic.ini upgrade head
|
||||
###############################################################################
|
||||
### 5) Run migrations
|
||||
###############################################################################
|
||||
|
||||
### 5) Prepare logs ###########################################################
|
||||
mkdir -p "$BASE_LOG_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
alembic -c "$BASE_DIR/api/alembic.ini" upgrade head
|
||||
|
||||
# Remove old symlink if it exists and create new one
|
||||
###############################################################################
|
||||
### 6) Prepare logs
|
||||
###############################################################################
|
||||
|
||||
mkdir -p "$BASE_LOG_DIR" "$LOG_DIR"
|
||||
|
||||
# Remove old symlink and create a new one
|
||||
if [[ -L "$LATEST_LINK" ]]; then
|
||||
rm "$LATEST_LINK"
|
||||
fi
|
||||
|
|
@ -90,33 +126,37 @@ ln -s "$TIMESTAMP" "$LATEST_LINK"
|
|||
echo "Log directory: $LOG_DIR"
|
||||
echo "Latest symlink: $LATEST_LINK -> $TIMESTAMP"
|
||||
|
||||
### 7) Start services #########################################################
|
||||
for name in "${!SERVICES[@]}"; do
|
||||
cmd=${SERVICES[$name]}
|
||||
###############################################################################
|
||||
### 7) Start services
|
||||
###############################################################################
|
||||
|
||||
for i in "${!SERVICE_NAMES[@]}"; do
|
||||
name="${SERVICE_NAMES[$i]}"
|
||||
cmd="${SERVICE_COMMANDS[$i]}"
|
||||
echo "→ Starting $name"
|
||||
|
||||
# Export LOG_FILE_PATH for this specific service
|
||||
export LOG_FILE_PATH="$LOG_DIR/$name.log"
|
||||
|
||||
# Start in new process group with setsid
|
||||
# Each service gets its own LOG_FILE_PATH environment variable
|
||||
setsid nohup bash -c "LOG_FILE_PATH='$LOG_DIR/$name.log' $cmd" >/dev/null 2>&1 &
|
||||
|
||||
# Get the PID of the setsid process
|
||||
|
||||
(
|
||||
cd "$BASE_DIR"
|
||||
export LOG_FILE_PATH="$LOG_DIR/$name.log"
|
||||
exec $cmd >>"$LOG_DIR/$name.log" 2>&1
|
||||
) &
|
||||
|
||||
pid=$!
|
||||
echo $pid >"$RUN_DIR/$name.pid"
|
||||
|
||||
# For uvicorn, also save the port for rolling updates
|
||||
echo " Started with PID $pid"
|
||||
|
||||
if [[ "$name" == "uvicorn" ]]; then
|
||||
echo "$FASTAPI_PORT" >"$RUN_DIR/uvicorn.port"
|
||||
fi
|
||||
done
|
||||
disown -a
|
||||
|
||||
### 8) Summary #################################################################
|
||||
###############################################################################
|
||||
### 8) Summary
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "──────────────────────────────────────────────────"
|
||||
for name in "${!SERVICES[@]}"; do
|
||||
for name in "${SERVICE_NAMES[@]}"; do
|
||||
pid=$(<"$RUN_DIR/$name.pid")
|
||||
echo "✓ $name (PID $pid) → $LOG_DIR/$name.log"
|
||||
done
|
||||
|
|
|
|||
139
ui/src/app/campaigns/CsvUploadSelector.tsx
Normal file
139
ui/src/app/campaigns/CsvUploadSelector.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface CsvUploadSelectorProps {
|
||||
accessToken: string;
|
||||
onFileUploaded: (fileKey: string, fileName: string) => void;
|
||||
selectedFileName?: string;
|
||||
}
|
||||
|
||||
interface PresignedUploadUrlResponse {
|
||||
upload_url: string;
|
||||
file_key: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export default function CsvUploadSelector({ accessToken, onFileUploaded, selectedFileName }: CsvUploadSelectorProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
toast.error('Please select a CSV file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('File size must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Step 1: Request presigned upload URL
|
||||
logger.info('Requesting presigned upload URL for:', file.name);
|
||||
const presignedResponse = await fetch('/api/v1/s3/presigned-upload-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
content_type: 'text/csv',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!presignedResponse.ok) {
|
||||
const error = await presignedResponse.json();
|
||||
throw new Error(error.detail || 'Failed to get upload URL');
|
||||
}
|
||||
|
||||
const presignedData: PresignedUploadUrlResponse = await presignedResponse.json();
|
||||
logger.info('Received presigned URL, uploading file...');
|
||||
|
||||
// Step 2: Upload file directly to S3/MinIO
|
||||
const uploadResponse = await fetch(presignedData.upload_url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload file to storage');
|
||||
}
|
||||
|
||||
setUploadProgress(100);
|
||||
logger.info('File uploaded successfully, file_key:', presignedData.file_key);
|
||||
|
||||
// Step 3: Notify parent with file_key
|
||||
onFileUploaded(presignedData.file_key, file.name);
|
||||
toast.success(`File uploaded: ${file.name}`);
|
||||
} catch (error) {
|
||||
logger.error('Error uploading CSV:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload CSV file');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>CSV File</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleButtonClick}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? `Uploading... ${uploadProgress}%` : 'Upload CSV File'}
|
||||
</Button>
|
||||
{selectedFileName && !uploading && (
|
||||
<div className="flex-1 text-sm">
|
||||
<span className="text-gray-600">Selected: </span>
|
||||
<span className="text-blue-600">{selectedFileName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { toast } from 'sonner';
|
|||
import {
|
||||
getCampaignApiV1CampaignCampaignIdGet,
|
||||
getCampaignRunsApiV1CampaignCampaignIdRunsGet,
|
||||
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
|
||||
pauseCampaignApiV1CampaignCampaignIdPausePost,
|
||||
resumeCampaignApiV1CampaignCampaignIdResumePost,
|
||||
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
|
||||
|
|
@ -125,6 +126,33 @@ export default function CampaignDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle CSV download
|
||||
const handleDownloadCsv = async () => {
|
||||
if (!user || !campaign || campaign.source_type !== 'csv') return;
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.download_url) {
|
||||
// Open download URL in new tab
|
||||
window.open(response.data.download_url, '_blank');
|
||||
} else {
|
||||
toast.error('Failed to get download URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download CSV:', error);
|
||||
toast.error('Failed to download CSV file');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle start campaign
|
||||
const handleStart = async () => {
|
||||
if (!user) return;
|
||||
|
|
@ -354,16 +382,27 @@ export default function CampaignDetailPage() {
|
|||
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Source Sheet</dt>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
{campaign.source_type === 'csv' ? 'Source File' : 'Source Sheet'}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<a
|
||||
href={campaign.source_id}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
|
||||
>
|
||||
{campaign.source_id}
|
||||
</a>
|
||||
{campaign.source_type === 'csv' ? (
|
||||
<button
|
||||
onClick={handleDownloadCsv}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
|
||||
>
|
||||
{campaign.source_id.split('/').pop()}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={campaign.source_id}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
|
||||
>
|
||||
{campaign.source_id}
|
||||
</a>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
import CsvUploadSelector from '../CsvUploadSelector';
|
||||
import GoogleSheetSelector from '../GoogleSheetSelector';
|
||||
|
||||
export default function NewCampaignPage() {
|
||||
|
|
@ -29,7 +30,9 @@ export default function NewCampaignPage() {
|
|||
// Form state
|
||||
const [campaignName, setCampaignName] = useState('');
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>('');
|
||||
const [selectedSheetUrl, setSelectedSheetUrl] = useState('');
|
||||
const [sourceType, setSourceType] = useState<'google-sheet' | 'csv'>('csv');
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [selectedFileName, setSelectedFileName] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [userAccessToken, setUserAccessToken] = useState<string>('');
|
||||
|
||||
|
|
@ -78,7 +81,7 @@ export default function NewCampaignPage() {
|
|||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!campaignName || !selectedWorkflowId || !selectedSheetUrl) {
|
||||
if (!campaignName || !selectedWorkflowId || !sourceId) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
|
@ -91,7 +94,8 @@ export default function NewCampaignPage() {
|
|||
body: {
|
||||
name: campaignName,
|
||||
workflow_id: parseInt(selectedWorkflowId),
|
||||
source_id: selectedSheetUrl,
|
||||
source_type: sourceType,
|
||||
source_id: sourceId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
|
|
@ -117,7 +121,13 @@ export default function NewCampaignPage() {
|
|||
|
||||
// Handle sheet selection
|
||||
const handleSheetSelected = (sheetUrl: string) => {
|
||||
setSelectedSheetUrl(sheetUrl);
|
||||
setSourceId(sheetUrl);
|
||||
};
|
||||
|
||||
// Handle CSV file upload
|
||||
const handleFileUploaded = (fileKey: string, fileName: string) => {
|
||||
setSourceId(fileKey);
|
||||
setSelectedFileName(fileName);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -191,20 +201,52 @@ export default function NewCampaignPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500">
|
||||
Select the workflow to execute for each row in the spreadsheet
|
||||
Select the workflow to execute for each row in the data source
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GoogleSheetSelector
|
||||
accessToken={userAccessToken}
|
||||
onSheetSelected={handleSheetSelected}
|
||||
selectedSheetUrl={selectedSheetUrl}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="source-type">Data Source Type</Label>
|
||||
<Select
|
||||
value={sourceType}
|
||||
onValueChange={(value) => {
|
||||
setSourceType(value as 'google-sheet' | 'csv');
|
||||
setSourceId('');
|
||||
setSelectedFileName('');
|
||||
}}
|
||||
required
|
||||
>
|
||||
<SelectTrigger id="source-type">
|
||||
<SelectValue placeholder="Select source type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google-sheet">Google Sheet</SelectItem>
|
||||
<SelectItem value="csv">CSV File</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose where your contact data is stored
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sourceType === 'google-sheet' ? (
|
||||
<GoogleSheetSelector
|
||||
accessToken={userAccessToken}
|
||||
onSheetSelected={handleSheetSelected}
|
||||
selectedSheetUrl={sourceId}
|
||||
/>
|
||||
) : (
|
||||
<CsvUploadSelector
|
||||
accessToken={userAccessToken}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
selectedFileName={selectedFileName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !selectedSheetUrl}
|
||||
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Campaign'}
|
||||
</Button>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -72,6 +72,11 @@ export type CampaignResponse = {
|
|||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type CampaignSourceDownloadResponse = {
|
||||
download_url: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
export type CampaignsResponse = {
|
||||
campaigns: Array<CampaignResponse>;
|
||||
};
|
||||
|
|
@ -91,6 +96,7 @@ export type CreateApiKeyResponse = {
|
|||
export type CreateCampaignRequest = {
|
||||
name: string;
|
||||
workflow_id: number;
|
||||
source_type: string;
|
||||
source_id: string;
|
||||
};
|
||||
|
||||
|
|
@ -287,6 +293,27 @@ export type LoadTestStatsResponse = {
|
|||
}>;
|
||||
};
|
||||
|
||||
export type PresignedUploadUrlRequest = {
|
||||
/**
|
||||
* CSV filename
|
||||
*/
|
||||
file_name: string;
|
||||
/**
|
||||
* File size in bytes (max 10MB)
|
||||
*/
|
||||
file_size: number;
|
||||
/**
|
||||
* File content type
|
||||
*/
|
||||
content_type?: string;
|
||||
};
|
||||
|
||||
export type PresignedUploadUrlResponse = {
|
||||
upload_url: string;
|
||||
file_key: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
export type RtcOfferRequest = {
|
||||
pc_id: string | null;
|
||||
sdp: string;
|
||||
|
|
@ -1758,6 +1785,40 @@ export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses = {
|
|||
|
||||
export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse = GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses[keyof GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses];
|
||||
|
||||
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/campaign/{campaign_id}/source-download-url';
|
||||
};
|
||||
|
||||
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors];
|
||||
|
||||
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: CampaignSourceDownloadResponse;
|
||||
};
|
||||
|
||||
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses];
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
@ -2028,6 +2089,38 @@ export type GetFileMetadataApiV1S3FileMetadataGetResponses = {
|
|||
|
||||
export type GetFileMetadataApiV1S3FileMetadataGetResponse = GetFileMetadataApiV1S3FileMetadataGetResponses[keyof GetFileMetadataApiV1S3FileMetadataGetResponses];
|
||||
|
||||
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData = {
|
||||
body: PresignedUploadUrlRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/s3/presigned-upload-url';
|
||||
};
|
||||
|
||||
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError = GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors[keyof GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors];
|
||||
|
||||
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: PresignedUploadUrlResponse;
|
||||
};
|
||||
|
||||
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse = GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses[keyof GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses];
|
||||
|
||||
export type GetServiceKeysApiV1UserServiceKeysGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue