mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
feat: add google stt and tts. add folders to organize agents
This commit is contained in:
parent
21951eca18
commit
ad2fa07058
52 changed files with 3412 additions and 621 deletions
|
|
@ -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
115
api/db/folder_client.py
Normal 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()}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue