feat: add google stt and tts. add folders to organize agents

This commit is contained in:
Abhishek Kumar 2026-05-22 14:36:50 +05:30
parent 21951eca18
commit ad2fa07058
52 changed files with 3412 additions and 621 deletions

View file

@ -2,6 +2,7 @@ from api.db.agent_trigger_client import AgentTriggerClient
from api.db.api_key_client import APIKeyClient
from api.db.campaign_client import CampaignClient
from api.db.embed_token_client import EmbedTokenClient
from api.db.folder_client import FolderClient
from api.db.integration_client import IntegrationClient
from api.db.knowledge_base_client import KnowledgeBaseClient
from api.db.organization_client import OrganizationClient
@ -41,6 +42,7 @@ class DBClient(
WorkflowRecordingClient,
TelephonyConfigurationClient,
TelephonyPhoneNumberClient,
FolderClient,
):
"""
Unified database client that combines all specialized database operations.
@ -62,6 +64,7 @@ class DBClient(
- WebhookCredentialClient: handles webhook credential operations
- ToolClient: handles tool operations for reusable HTTP API tools
- KnowledgeBaseClient: handles knowledge base document and vector search operations
- FolderClient: handles folder operations for grouping workflows (agents)
"""
pass

115
api/db/folder_client.py Normal file
View file

@ -0,0 +1,115 @@
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import FolderModel, WorkflowModel
from api.enums import WorkflowStatus
class FolderNameConflictError(Exception):
"""Raised when a folder name already exists within the organization."""
class FolderClient(BaseDBClient):
async def create_folder(self, name: str, organization_id: int) -> FolderModel:
async with self.async_session() as session:
folder = FolderModel(name=name, organization_id=organization_id)
session.add(folder)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise FolderNameConflictError(
f"A folder named '{name}' already exists."
)
await session.refresh(folder)
return folder
async def get_folder(
self, folder_id: int, organization_id: int
) -> FolderModel | None:
"""Fetch a single folder scoped to the organization (tenant isolation)."""
async with self.async_session() as session:
result = await session.execute(
select(FolderModel).where(
FolderModel.id == folder_id,
FolderModel.organization_id == organization_id,
)
)
return result.scalar_one_or_none()
async def list_folders(self, organization_id: int) -> list[FolderModel]:
async with self.async_session() as session:
result = await session.execute(
select(FolderModel)
.where(FolderModel.organization_id == organization_id)
.order_by(FolderModel.name.asc())
)
return result.scalars().all()
async def rename_folder(
self, folder_id: int, name: str, organization_id: int
) -> FolderModel:
async with self.async_session() as session:
result = await session.execute(
select(FolderModel).where(
FolderModel.id == folder_id,
FolderModel.organization_id == organization_id,
)
)
folder = result.scalar_one_or_none()
if folder is None:
raise ValueError(f"Folder with id {folder_id} not found")
folder.name = name
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise FolderNameConflictError(
f"A folder named '{name}' already exists."
)
await session.refresh(folder)
return folder
async def delete_folder(self, folder_id: int, organization_id: int) -> bool:
"""Delete a folder. Member workflows are un-filed (folder_id -> NULL)
via the ON DELETE SET NULL foreign key, never deleted.
"""
async with self.async_session() as session:
result = await session.execute(
select(FolderModel).where(
FolderModel.id == folder_id,
FolderModel.organization_id == organization_id,
)
)
folder = result.scalar_one_or_none()
if folder is None:
return False
await session.delete(folder)
await session.commit()
return True
async def get_active_workflow_counts_by_folder(
self, organization_id: int
) -> dict[int, int]:
"""Return {folder_id: active_workflow_count} for the organization.
Only counts active (non-archived) workflows with a non-NULL folder_id.
"""
async with self.async_session() as session:
result = await session.execute(
select(
WorkflowModel.folder_id,
func.count(WorkflowModel.id).label("count"),
)
.where(
WorkflowModel.organization_id == organization_id,
WorkflowModel.folder_id.is_not(None),
WorkflowModel.status == WorkflowStatus.ACTIVE.value,
)
.group_by(WorkflowModel.folder_id)
)
return {folder_id: count for folder_id, count in result.all()}

View file

@ -352,6 +352,32 @@ class WorkflowDefinitionModel(Base):
workflow_runs = relationship("WorkflowRunModel", back_populates="definition")
class FolderModel(Base):
"""A folder for grouping workflows (agents) within an organization.
Folders are flat (no nesting) and org-scoped. A workflow belongs to at
most one folder via ``WorkflowModel.folder_id``; a NULL folder_id means
the workflow is "Uncategorized".
"""
__tablename__ = "folders"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
Integer, ForeignKey("organizations.id"), nullable=False, index=True
)
organization = relationship("OrganizationModel")
name = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
workflows = relationship("WorkflowModel", back_populates="folder")
# Folder names must be unique within an organization.
__table_args__ = (
UniqueConstraint("organization_id", "name", name="uq_folder_org_name"),
)
class WorkflowModel(Base):
__tablename__ = "workflows"
id = Column(Integer, primary_key=True, index=True)
@ -366,6 +392,15 @@ class WorkflowModel(Base):
user = relationship("UserModel", back_populates="workflows")
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
organization = relationship("OrganizationModel")
# Optional folder for grouping in the agents list. NULL = "Uncategorized".
# ON DELETE SET NULL: deleting a folder un-files its agents, never deletes them.
folder_id = Column(
Integer,
ForeignKey("folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
folder = relationship("FolderModel", back_populates="workflows")
name = Column(String, index=True, nullable=False)
status = Column(
Enum(*[status.value for status in WorkflowStatus], name="workflow_status"),

View file

@ -372,6 +372,8 @@ class WorkflowClient(BaseDBClient):
WorkflowModel.name,
WorkflowModel.status,
WorkflowModel.created_at,
WorkflowModel.folder_id,
WorkflowModel.workflow_uuid,
)
)
@ -425,8 +427,26 @@ class WorkflowClient(BaseDBClient):
return result.scalar_one_or_none()
async def get_workflow(
self, workflow_id: int, user_id: int = None, organization_id: int = None
self,
workflow_id: int,
user_id: int | None = None,
organization_id: int | None = None,
) -> WorkflowModel | None:
"""Fetch a workflow by id, scoped to a tenant.
Scoping is mandatory: pass ``organization_id`` (preferred) or
``user_id``. A fully unscoped lookup would let a request-supplied id
reach another tenant's workflow. System/runtime paths that only have a
``workflow_id`` and derive the org from the workflow itself (e.g.
inbound telephony routing) must call ``get_workflow_by_id`` instead
the explicit unscoped variant.
"""
if user_id is None and organization_id is None:
raise ValueError(
"get_workflow requires organization_id (preferred) or user_id "
"for tenant scoping; use get_workflow_by_id for unscoped "
"system lookups."
)
async with self.async_session() as session:
query = (
select(WorkflowModel)
@ -448,6 +468,13 @@ class WorkflowClient(BaseDBClient):
return result.scalars().first()
async def get_workflow_by_id(self, workflow_id: int) -> WorkflowModel | None:
"""Fetch a workflow by id WITHOUT tenant scoping.
Explicit unscoped variant of ``get_workflow``. Only for system/runtime
contexts that legitimately have just a workflow_id and derive the org
from the workflow itself (e.g. inbound telephony). Never call this with
a request-supplied id on a user-facing path.
"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
@ -609,7 +636,7 @@ class WorkflowClient(BaseDBClient):
self,
workflow_id: int,
status: str,
organization_id: int = None,
organization_id: int,
) -> WorkflowModel:
"""
Update the status of a workflow.
@ -617,7 +644,9 @@ class WorkflowClient(BaseDBClient):
Args:
workflow_id: The ID of the workflow to update
status: The new status (active/archived)
organization_id: The organization ID
organization_id: The organization ID. Required and always filtered
on: this is a mutation, so an unscoped query would let a caller
archive another org's workflow (tenant-isolation bypass).
Returns:
The updated WorkflowModel
@ -632,12 +661,12 @@ class WorkflowClient(BaseDBClient):
selectinload(WorkflowModel.current_definition),
selectinload(WorkflowModel.released_definition),
)
.where(WorkflowModel.id == workflow_id)
.where(
WorkflowModel.id == workflow_id,
WorkflowModel.organization_id == organization_id,
)
)
if organization_id:
query = query.where(WorkflowModel.organization_id == organization_id)
result = await session.execute(query)
workflow = result.scalars().first()
@ -654,6 +683,47 @@ class WorkflowClient(BaseDBClient):
await session.refresh(workflow)
return workflow
async def move_workflow_to_folder(
self,
workflow_id: int,
folder_id: int | None,
organization_id: int,
) -> WorkflowModel:
"""Set (or clear) a workflow's folder.
Pass ``folder_id=None`` to move the workflow to "Uncategorized". The
caller must validate that ``folder_id`` belongs to ``organization_id``
before calling (the FK only proves the folder exists, not ownership).
``organization_id`` is required and always filtered on: this is a
mutation, so an unscoped query would let a caller move another org's
workflow (tenant-isolation bypass).
Raises:
ValueError: If the workflow is not found within the organization.
"""
async with self.async_session() as session:
query = select(WorkflowModel).where(
WorkflowModel.id == workflow_id,
WorkflowModel.organization_id == organization_id,
)
result = await session.execute(query)
workflow = result.scalars().first()
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
workflow.folder_id = folder_id
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(workflow)
return workflow
async def get_workflow_run_count(self, workflow_id: int) -> int:
"""Get the count of runs for a workflow."""
async with self.async_session() as session: