Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/workflows.py

1227 lines
44 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
API endpoints برای مدیریت Workflow
"""
from collections import defaultdict
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request, Body, HTTPException, Query, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, or_, func, cast, Text, case, text
from adapters.db.session import get_db
from adapters.db.models.workflow import (
Workflow,
WorkflowStatus,
WorkflowExecution,
WorkflowExecutionStatus,
WorkflowLog,
WorkflowLogLevel,
)
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access, require_business_permission_dep
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.services.workflow.workflow_engine import WorkflowEngine
from app.services.workflow.trigger_registry import TriggerRegistry
from app.services.workflow.action_registry import ActionRegistry
from app.services.workflow.workflow_trigger_service import ensure_workflow_webhook_settings
from app.core.settings import get_settings as get_app_settings
from adapters.api.v1.schemas import QueryInfo
router = APIRouter(tags=["workflows"])
_logger = logging.getLogger(__name__)
def _workflow_background_run(
execution_id: int,
workflow_id: int,
business_id: int,
user_id: Optional[int],
trigger_data: Dict[str, Any],
) -> None:
"""اجرای workflow در پس‌زمینه پس از پاسخ HTTP (برای نمایش زنده در کلاینت)."""
from adapters.db.session import SessionLocal
db = SessionLocal()
try:
workflow = db.get(Workflow, workflow_id)
execution = db.get(WorkflowExecution, execution_id)
if not workflow or not execution:
_logger.error("workflow background: workflow or execution missing")
return
if execution.workflow_id != workflow_id:
_logger.error("workflow background: execution workflow mismatch")
return
if workflow.business_id != business_id:
_logger.error("workflow background: business mismatch")
return
if execution.status != WorkflowExecutionStatus.PENDING:
_logger.warning(
"workflow background: execution %s status is %s, skip",
execution_id,
execution.status,
)
return
engine = WorkflowEngine(db, business_id, user_id)
engine.run_pending_execution(workflow, execution, trigger_data)
except Exception as e:
_logger.error("workflow background run failed: %s", e, exc_info=True)
finally:
db.close()
def _webhook_trigger_timeout_seconds(workflow: Workflow) -> float:
"""timeout_seconds از نود تریگر webhook (۵…۶۰۰ ثانیه)."""
nodes = (workflow.workflow_data or {}).get("nodes") or []
for n in nodes:
if not isinstance(n, dict):
continue
if n.get("type") != "trigger":
continue
cfg = n.get("config") or {}
if cfg.get("trigger_type") != "webhook":
continue
raw = cfg.get("timeout_seconds", 30)
try:
t = float(raw)
except (TypeError, ValueError):
t = 30.0
return max(5.0, min(t, 600.0))
return 30.0
def _attach_workflow_webhook_url(data: Dict[str, Any], request: Request) -> None:
settings_row = data.get("settings")
secret = (settings_row or {}).get("webhook_secret") if isinstance(settings_row, dict) else None
if not secret:
return
try:
root = str(request.base_url).rstrip("/")
prefix = get_app_settings().api_v1_prefix.rstrip("/")
data["webhook_inbound_url"] = f"{root}{prefix}/workflow-hooks/{secret}"
except Exception:
pass
def validate_workflow_data(workflow_data: Dict[str, Any]) -> List[str]:
"""
اعتبارسنجی ساختار workflow_data
Args:
workflow_data: داده‌های workflow برای validation
Returns:
لیست خطاها (خالی اگر معتبر باشد)
"""
errors = []
if not isinstance(workflow_data, dict):
errors.append("workflow_data باید یک dictionary باشد")
return errors
# بررسی وجود nodes
nodes = workflow_data.get("nodes", [])
if not isinstance(nodes, list):
errors.append("nodes باید یک لیست باشد")
return errors
if len(nodes) == 0:
errors.append("workflow باید حداقل یک node داشته باشد")
return errors
# بررسی وجود connections
connections = workflow_data.get("connections", [])
if not isinstance(connections, list):
errors.append("connections باید یک لیست باشد")
return errors
# بررسی ساختار هر node
node_ids = set()
for i, node in enumerate(nodes):
if not isinstance(node, dict):
errors.append(f"node در index {i} باید یک dictionary باشد")
continue
node_id = node.get("id")
if not node_id:
errors.append(f"node در index {i} باید دارای id باشد")
continue
if node_id in node_ids:
errors.append(f"node با id تکراری '{node_id}' یافت شد")
node_ids.add(node_id)
node_type = node.get("type")
if node_type not in ["trigger", "action", "condition", "loop"]:
errors.append(f"node '{node_id}' دارای type نامعتبر '{node_type}' است")
if not node.get("label"):
errors.append(f"node '{node_id}' باید دارای label باشد")
# بررسی ساختار connections
for i, conn in enumerate(connections):
if not isinstance(conn, dict):
errors.append(f"connection در index {i} باید یک dictionary باشد")
continue
source = conn.get("source")
target = conn.get("target")
if not source or not target:
errors.append(f"connection در index {i} باید دارای source و target باشد")
continue
if source not in node_ids:
errors.append(f"connection در index {i} به node ناموجود '{source}' اشاره می‌کند")
if target not in node_ids:
errors.append(f"connection در index {i} به node ناموجود '{target}' اشاره می‌کند")
# بررسی حداقل یک trigger
trigger_nodes = [n for n in nodes if isinstance(n, dict) and n.get("type") == "trigger"]
if len(trigger_nodes) == 0:
errors.append("workflow باید حداقل یک trigger node داشته باشد")
# تریگر زمان‌بندی: باید کرون معتبر یا حالت سادهٔ قابل تبدیل داشته باشد
try:
from app.services.workflow.schedule_cron_resolution import schedule_config_is_valid
for n in trigger_nodes:
cfg = (n.get("config") or {}) if isinstance(n, dict) else {}
if cfg.get("trigger_type") != "scheduled":
continue
if not schedule_config_is_valid(cfg):
errors.append(
"تریگر زمان‌بندی‌شده: عبارت کرون را وارد کنید یا حالت ساده را کامل کنید"
)
break
except Exception:
pass
# بررسی محدودیت اندازه
import json
workflow_data_size = len(json.dumps(workflow_data))
max_size = 10 * 1024 * 1024 # 10 MB
if workflow_data_size > max_size:
errors.append(f"اندازه workflow_data ({workflow_data_size} bytes) بیش از حد مجاز ({max_size} bytes) است")
return errors
@router.post(
"/businesses/{business_id}/workflows/create",
summary="ایجاد workflow جدید",
description="ایجاد یک workflow جدید برای کسب‌وکار",
)
@require_business_access("business_id")
async def create_workflow(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("workflows", "add")),
):
"""ایجاد workflow جدید"""
name = body.get("name")
if not name:
raise ApiError("WORKFLOW_NAME_REQUIRED", "نام workflow الزامی است")
workflow_data = body.get("workflow_data", {})
if not workflow_data:
raise ApiError("WORKFLOW_DATA_REQUIRED", "داده‌های workflow الزامی است")
# اعتبارسنجی ساختار workflow_data
validation_errors = validate_workflow_data(workflow_data)
if validation_errors:
raise ApiError(
"WORKFLOW_DATA_INVALID",
f"ساختار workflow_data نامعتبر است: {'; '.join(validation_errors)}"
)
merged_settings = ensure_workflow_webhook_settings(
workflow_data, dict(body.get("settings") or {})
)
workflow = Workflow(
business_id=business_id,
name=name,
description=body.get("description"),
status=WorkflowStatus(body.get("status", WorkflowStatus.DRAFT.value)),
workflow_data=workflow_data,
settings=merged_settings if merged_settings else None,
created_by_user_id=ctx.get_user_id(),
)
db.add(workflow)
db.commit()
db.refresh(workflow)
created = format_datetime_fields(workflow.__dict__, request)
_attach_workflow_webhook_url(created, request)
return success_response(
data=created,
request=request,
message="WORKFLOW_CREATED",
)
@router.post(
"/businesses/{business_id}/workflows/list",
summary="لیست workflowها",
description="دریافت لیست workflowهای یک کسب‌وکار",
)
@require_business_access("business_id")
async def list_workflows(
request: Request,
business_id: int,
query_info: QueryInfo = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""لیست workflowها"""
stmt = select(Workflow).where(Workflow.business_id == business_id)
# اعمال فیلترها
if query_info.filters:
for filter_item in query_info.filters:
if filter_item.property == "status":
stmt = stmt.where(Workflow.status == filter_item.value)
elif filter_item.property == "name":
stmt = stmt.where(Workflow.name.ilike(f"%{filter_item.value}%"))
# مرتب‌سازی
if query_info.sort_by:
sort_desc = query_info.sort_desc
if query_info.sort_by == "name":
stmt = stmt.order_by(Workflow.name.desc() if sort_desc else Workflow.name.asc())
elif query_info.sort_by == "created_at":
stmt = stmt.order_by(Workflow.created_at.desc() if sort_desc else Workflow.created_at.asc())
else:
stmt = stmt.order_by(Workflow.created_at.desc())
else:
stmt = stmt.order_by(Workflow.created_at.desc())
total_count = db.execute(
stmt.with_only_columns(func.count()).order_by(None)
).scalar_one()
skip = query_info.skip or 0
take = query_info.take or 10
stmt = stmt.offset(skip).limit(take)
workflows = list(db.execute(stmt).scalars().all())
page = (skip // take) + 1
return success_response(
data={
"items": [format_datetime_fields(w.__dict__, request) for w in workflows],
"total": total_count,
"page": page,
"page_size": take,
},
request=request,
message="WORKFLOWS_LISTED",
)
@router.get(
"/businesses/{business_id}/workflows/{workflow_id}",
summary="دریافت workflow",
description="دریافت اطلاعات یک workflow",
)
@require_business_access("business_id")
async def get_workflow(
request: Request,
business_id: int,
workflow_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت workflow"""
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
payload = format_datetime_fields(workflow.__dict__, request)
_attach_workflow_webhook_url(payload, request)
return success_response(
data=payload,
request=request,
message="WORKFLOW_RETRIEVED",
)
@router.put(
"/businesses/{business_id}/workflows/{workflow_id}/edit",
summary="به‌روزرسانی workflow",
description="به‌روزرسانی یک workflow",
)
@require_business_access("business_id")
async def update_workflow(
request: Request,
business_id: int,
workflow_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("workflows", "edit")),
):
"""به‌روزرسانی workflow"""
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
if "name" in body:
workflow.name = body["name"]
if "description" in body:
workflow.description = body["description"]
if "status" in body:
workflow.status = WorkflowStatus(body["status"])
if "workflow_data" in body:
# اعتبارسنجی ساختار workflow_data
validation_errors = validate_workflow_data(body["workflow_data"])
if validation_errors:
raise ApiError(
"WORKFLOW_DATA_INVALID",
f"ساختار workflow_data نامعتبر است: {'; '.join(validation_errors)}"
)
workflow.workflow_data = body["workflow_data"]
workflow.settings = ensure_workflow_webhook_settings(
body["workflow_data"], dict(workflow.settings or {})
)
if "settings" in body:
merged = dict(workflow.settings or {})
merged.update(body["settings"] or {})
workflow.settings = ensure_workflow_webhook_settings(
workflow.workflow_data or {}, merged
)
db.commit()
db.refresh(workflow)
updated = format_datetime_fields(workflow.__dict__, request)
_attach_workflow_webhook_url(updated, request)
return success_response(
data=updated,
request=request,
message="WORKFLOW_UPDATED",
)
@router.delete(
"/businesses/{business_id}/workflows/{workflow_id}",
summary="حذف workflow",
description="حذف یک workflow",
)
@require_business_access("business_id")
async def delete_workflow(
request: Request,
business_id: int,
workflow_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("workflows", "delete")),
):
"""حذف workflow"""
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
db.delete(workflow)
db.commit()
return success_response(
data=None,
request=request,
message="WORKFLOW_DELETED",
)
@router.post(
"/businesses/{business_id}/workflows/{workflow_id}/execute",
summary="اجرای workflow",
description="اجرای دستی. بدنه می‌تواند `dry_run: true` باشد (آزمایشی، بدون ارسال/ثبت واقعی).",
)
@require_business_access("business_id")
async def execute_workflow(
request: Request,
business_id: int,
workflow_id: int,
background_tasks: BackgroundTasks,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""اجرای workflow"""
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
if workflow.status != WorkflowStatus.ACTIVE:
raise ApiError("WORKFLOW_NOT_ACTIVE", "Workflow فعال نیست")
raw_trigger = body.get("trigger_data")
trigger_data: Dict[str, Any] = raw_trigger if isinstance(raw_trigger, dict) else {}
async_execution = bool(body.get("async_execution", False))
dry_run = bool(body.get("dry_run", False))
if dry_run:
from app.services.workflow.dry_run import DRY_RUN_TRIGGER_KEY
if isinstance(trigger_data, dict):
trigger_data = {**trigger_data, DRY_RUN_TRIGGER_KEY: True}
else:
trigger_data = {DRY_RUN_TRIGGER_KEY: True}
engine = WorkflowEngine(db, business_id, ctx.get_user_id())
if async_execution:
execution = engine.create_pending_execution(workflow, trigger_data)
background_tasks.add_task(
_workflow_background_run,
execution.id,
workflow_id,
business_id,
ctx.get_user_id(),
trigger_data,
)
else:
execution = engine.execute_workflow(workflow, trigger_data)
return success_response(
data=format_datetime_fields(execution.__dict__, request),
request=request,
message="WORKFLOW_EXECUTED",
)
@router.get(
"/businesses/{business_id}/workflows/{workflow_id}/executions",
summary="لیست اجراهای workflow",
description="دریافت لیست اجراهای یک workflow",
)
@require_business_access("business_id")
async def list_workflow_executions(
request: Request,
business_id: int,
workflow_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""لیست اجراهای workflow"""
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
# شمارش کل با استفاده از func.count() برای کارایی بهتر
count_stmt = select(func.count(WorkflowExecution.id)).where(
WorkflowExecution.workflow_id == workflow_id
)
total_count = db.execute(count_stmt).scalar_one() or 0
# دریافت لیست با pagination
stmt = select(WorkflowExecution).where(WorkflowExecution.workflow_id == workflow_id)
stmt = stmt.order_by(WorkflowExecution.created_at.desc())
offset = (page - 1) * page_size
stmt = stmt.offset(offset).limit(page_size)
executions = list(db.execute(stmt).scalars().all())
return success_response(
data={
"items": [format_datetime_fields(e.__dict__, request) for e in executions],
"total": total_count,
"page": page,
"page_size": page_size,
},
request=request,
message="WORKFLOW_EXECUTIONS_LISTED",
)
@router.get(
"/businesses/{business_id}/workflows/{workflow_id}/executions/{execution_id}",
summary="جزئیات یک اجرای workflow",
description="دریافت وضعیت یک اجرا (برای polling هنگام اجرای ناهمزمان)",
)
@require_business_access("business_id")
async def get_workflow_execution(
request: Request,
business_id: int,
workflow_id: int,
execution_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
execution = db.get(WorkflowExecution, execution_id)
if not execution or execution.workflow_id != workflow_id:
raise ApiError("EXECUTION_NOT_FOUND", "اجرای workflow یافت نشد")
return success_response(
data=format_datetime_fields(execution.__dict__, request),
request=request,
message="WORKFLOW_EXECUTION_RETRIEVED",
)
@router.get(
"/businesses/{business_id}/workflows/{workflow_id}/executions/{execution_id}/logs",
summary="لاگ‌های اجرای workflow",
description="دریافت لاگ‌های یک اجرای workflow",
)
@require_business_access("business_id")
async def get_workflow_execution_logs(
request: Request,
business_id: int,
workflow_id: int,
execution_id: int,
after_log_id: Optional[int] = Query(
None,
ge=0,
description="فقط لاگ‌های با id بزرگ‌تر از این مقدار (polling افزایشی؛ ۰ یعنی از ابدا)",
),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""لاگ‌های اجرای workflow"""
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
execution = db.get(WorkflowExecution, execution_id)
if not execution or execution.workflow_id != workflow_id:
raise ApiError("EXECUTION_NOT_FOUND", "اجرای workflow یافت نشد")
stmt = select(WorkflowLog).where(WorkflowLog.execution_id == execution_id)
if after_log_id is not None and after_log_id > 0:
stmt = stmt.where(WorkflowLog.id > after_log_id)
stmt = stmt.order_by(WorkflowLog.id.asc())
logs = list(db.execute(stmt).scalars().all())
return success_response(
data=[format_datetime_fields(log.__dict__, request) for log in logs],
request=request,
message="WORKFLOW_LOGS_RETRIEVED",
)
@router.get(
"/workflows/triggers",
summary="لیست triggerهای موجود",
description="دریافت لیست تمام triggerهای موجود",
)
async def list_triggers(
request: Request,
):
"""لیست triggerهای موجود"""
registry = TriggerRegistry()
triggers = registry.list_triggers()
return success_response(
data=triggers,
request=request,
message="TRIGGERS_LISTED",
)
@router.get(
"/workflows/actions",
summary="لیست actionهای موجود",
description="دریافت لیست تمام actionهای موجود",
)
async def list_actions(
request: Request,
):
"""لیست actionهای موجود"""
registry = ActionRegistry()
actions = registry.list_actions()
return success_response(
data=actions,
request=request,
message="ACTIONS_LISTED",
)
@router.get(
"/businesses/{business_id}/workflows/analytics/errors",
summary="تحلیل خطاهای workflow",
description="دریافت آمار و تحلیل خطاهای workflow در بازه زمانی مشخص",
)
@require_business_access("business_id")
async def get_workflow_errors_analytics(
request: Request,
business_id: int,
days: int = Query(7, ge=1, le=90, description="تعداد روزهای گذشته"),
workflow_id: Optional[int] = Query(None, description="فیلتر بر اساس workflow خاص"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""تحلیل خطاهای workflow"""
# Query پایه - واکشی در Python برای سازگاری با دیتابیس و data=None
stmt = select(WorkflowLog).select_from(WorkflowLog).join(
WorkflowExecution, WorkflowExecution.id == WorkflowLog.execution_id
).join(Workflow, Workflow.id == WorkflowExecution.workflow_id).where(
and_(
Workflow.business_id == business_id,
WorkflowLog.level == WorkflowLogLevel.ERROR,
WorkflowLog.timestamp >= datetime.utcnow() - timedelta(days=days)
)
)
if workflow_id:
stmt = stmt.where(Workflow.id == workflow_id)
logs = list(db.execute(stmt).scalars().all())
# گروه‌بندی در Python
error_stats = defaultdict(lambda: {"count": 0, "last_occurrence": None, "first_occurrence": None})
for log in logs:
data = log.data or {}
error_type = data.get("error_type") or data.get("error_message") or "Unknown"
if isinstance(error_type, str) and len(error_type) > 100:
error_type = error_type[:100] + "..."
else:
error_type = str(error_type)
error_stats[error_type]["count"] += 1
ts = log.timestamp
if error_stats[error_type]["last_occurrence"] is None or ts > error_stats[error_type]["last_occurrence"]:
error_stats[error_type]["last_occurrence"] = ts
if error_stats[error_type]["first_occurrence"] is None or ts < error_stats[error_type]["first_occurrence"]:
error_stats[error_type]["first_occurrence"] = ts
total_errors = sum(s["count"] for s in error_stats.values())
results = sorted(
[{"error_type": k, "count": v["count"], "last_occurrence": v["last_occurrence"], "first_occurrence": v["first_occurrence"]} for k, v in error_stats.items()],
key=lambda x: x["count"],
reverse=True
)
return success_response(
data={
"total_errors": total_errors,
"unique_error_types": len(results),
"period_days": days,
"errors_by_type": [
{
"error_type": r["error_type"],
"count": r["count"],
"percentage": round((r["count"] / total_errors * 100), 2) if total_errors > 0 else 0,
"last_occurrence": format_datetime_fields({"timestamp": r["last_occurrence"]}, request)["timestamp"],
"first_occurrence": format_datetime_fields({"timestamp": r["first_occurrence"]}, request)["timestamp"]
}
for r in results
]
},
request=request,
message="WORKFLOW_ERRORS_ANALYTICS_RETRIEVED",
)
@router.get(
"/businesses/{business_id}/workflows/analytics/performance",
summary="تحلیل عملکرد workflows",
description="دریافت آمار عملکرد workflows شامل تعداد اجراها، نرخ موفقیت، و میانگین زمان اجرا",
)
@require_business_access("business_id")
async def get_workflow_performance_analytics(
request: Request,
business_id: int,
workflow_id: Optional[int] = Query(None, description="فیلتر بر اساس workflow خاص"),
days: int = Query(30, ge=1, le=365, description="تعداد روزهای گذشته"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""تحلیل عملکرد workflows"""
# Query پایه - برای database-agnostic بودن، duration را در Python محاسبه می‌کنیم
stmt = select(
Workflow.id,
Workflow.name,
Workflow.status,
func.count(WorkflowExecution.id).label('total_executions'),
func.count(
case(
(WorkflowExecution.status == WorkflowExecutionStatus.COMPLETED, 1),
)
).label('successful'),
func.count(
case(
(WorkflowExecution.status == WorkflowExecutionStatus.FAILED, 1),
)
).label('failed'),
).select_from(Workflow) \
.outerjoin(WorkflowExecution, WorkflowExecution.workflow_id == Workflow.id) \
.where(
and_(
Workflow.business_id == business_id,
or_(
WorkflowExecution.created_at >= datetime.utcnow() - timedelta(days=days),
WorkflowExecution.created_at.is_(None)
)
)
)
# فیلتر بر اساس workflow
if workflow_id:
stmt = stmt.where(Workflow.id == workflow_id)
stmt = stmt.group_by(Workflow.id, Workflow.name, Workflow.status) \
.order_by(func.count(WorkflowExecution.id).desc())
results = list(db.execute(stmt).all())
# محاسبه duration برای هر workflow در Python (database-agnostic)
workflow_stats = []
for r in results:
# دریافت executions برای محاسبه duration
exec_stmt = select(WorkflowExecution).where(
and_(
WorkflowExecution.workflow_id == r.id,
WorkflowExecution.created_at >= datetime.utcnow() - timedelta(days=days),
WorkflowExecution.started_at.isnot(None),
WorkflowExecution.completed_at.isnot(None),
)
)
executions = list(db.execute(exec_stmt).scalars().all())
durations = []
for exec in executions:
if exec.started_at and exec.completed_at:
duration = (exec.completed_at - exec.started_at).total_seconds()
durations.append(duration)
avg_duration = sum(durations) / len(durations) if durations else 0
min_duration = min(durations) if durations else 0
max_duration = max(durations) if durations else 0
workflow_stats.append({
"workflow_id": r.id,
"workflow_name": r.name,
"workflow_status": r.status.value if r.status else None,
"total_executions": r.total_executions or 0,
"successful": r.successful or 0,
"failed": r.failed or 0,
"success_rate": round(
(r.successful / r.total_executions * 100) if r.total_executions > 0 else 0,
2
),
"avg_duration_seconds": round(avg_duration, 2),
"min_duration_seconds": round(min_duration, 2),
"max_duration_seconds": round(max_duration, 2)
})
return success_response(
data={
"period_days": days,
"workflows": workflow_stats
},
request=request,
message="WORKFLOW_PERFORMANCE_ANALYTICS_RETRIEVED",
)
@router.get(
"/businesses/{business_id}/workflows/{workflow_id}/executions/{execution_id}/timeline",
summary="Timeline اجرای workflow",
description="دریافت timeline دقیق اجرای workflow برای debugging",
)
@require_business_access("business_id")
async def get_execution_timeline(
request: Request,
business_id: int,
workflow_id: int,
execution_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت timeline اجرای workflow"""
# بررسی وجود workflow
workflow = db.get(Workflow, workflow_id)
if not workflow or workflow.business_id != business_id:
raise ApiError("WORKFLOW_NOT_FOUND", "Workflow یافت نشد")
# بررسی وجود execution
execution = db.get(WorkflowExecution, execution_id)
if not execution or execution.workflow_id != workflow_id:
raise ApiError("EXECUTION_NOT_FOUND", "اجرای workflow یافت نشد")
# دریافت تمام لاگ‌ها
logs_stmt = select(WorkflowLog) \
.where(WorkflowLog.execution_id == execution_id) \
.order_by(WorkflowLog.timestamp.asc())
logs = list(db.execute(logs_stmt).scalars().all())
# ساخت timeline
timeline = []
node_stats = {}
for log in logs:
# اضافه به timeline
log_data = {
"timestamp": format_datetime_fields({"t": log.timestamp}, request)["t"],
"level": log.level.value if hasattr(log.level, 'value') else str(log.level),
"message": log.message,
"node_id": log.node_id,
"data": log.data
}
timeline.append(log_data)
# محاسبه آمار nodeها
if log.node_id:
if log.node_id not in node_stats:
node_stats[log.node_id] = {
"node_id": log.node_id,
"executions": 0,
"errors": 0,
"total_duration_ms": 0,
"node_type": log.data.get("node_type") if log.data else None,
"node_label": log.data.get("node_label") if log.data else None
}
node_stats[log.node_id]["executions"] += 1
if log.level.value == 'error' or (hasattr(log.level, 'value') and log.level.value == 'error'):
node_stats[log.node_id]["errors"] += 1
if log.data and "duration_ms" in log.data:
node_stats[log.node_id]["total_duration_ms"] += log.data["duration_ms"]
# محاسبه میانگین duration برای هر node
for node_id, stats in node_stats.items():
if stats["executions"] > 0:
stats["avg_duration_ms"] = round(
stats["total_duration_ms"] / stats["executions"],
2
)
# محاسبه مدت زمان کلی
duration_seconds = None
if execution.completed_at and execution.started_at:
duration_seconds = (execution.completed_at - execution.started_at).total_seconds()
return success_response(
data={
"execution": {
"id": execution.id,
"workflow_id": execution.workflow_id,
"workflow_name": workflow.name,
"status": execution.status.value if hasattr(execution.status, 'value') else str(execution.status),
"started_at": format_datetime_fields({"t": execution.started_at}, request)["t"] if execution.started_at else None,
"completed_at": format_datetime_fields({"t": execution.completed_at}, request)["t"] if execution.completed_at else None,
"duration_seconds": round(duration_seconds, 2) if duration_seconds else None,
"error_message": execution.error_message
},
"timeline": timeline,
"node_statistics": list(node_stats.values()),
"summary": {
"total_logs": len(logs),
"total_nodes": len(node_stats),
"error_count": sum(1 for log in logs if log.level.value == 'error' or str(log.level) == 'error')
}
},
request=request,
message="EXECUTION_TIMELINE_RETRIEVED",
)
@router.get(
"/workflows/metadata/triggers",
summary="دریافت metadata triggerها",
description="دریافت لیست triggerهای موجود با metadata",
)
async def get_triggers_metadata(
request: Request,
lang: str = Query("fa", description="زبان (fa/en)"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت metadata triggerها"""
from app.services.workflow.i18n import translate_trigger_metadata, TRIGGER_TRANSLATIONS_BY_KEY
trigger_registry = TriggerRegistry()
all_triggers = trigger_registry.get_all_metadata()
out = []
for t in all_triggers:
key = t.get("key") or ""
if key in TRIGGER_TRANSLATIONS_BY_KEY:
out.append(translate_trigger_metadata(t, lang, key))
else:
out.append(t)
return success_response(
data=out,
request=request,
message="TRIGGERS_METADATA_RETRIEVED",
)
@router.get(
"/workflows/metadata/actions",
summary="دریافت metadata actionها",
description="دریافت لیست actionهای موجود با metadata",
)
async def get_actions_metadata(
request: Request,
lang: str = Query("fa", description="زبان (fa/en)"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت metadata actionها با پشتیبانی از ترجمه"""
from app.services.workflow.i18n import translate_metadata
from app.services.workflow.i18n.hesabix_data_actions_i18n import get_workflow_action_keys
_wf_data_action_keys = get_workflow_action_keys()
action_registry = ActionRegistry()
all_actions = action_registry.get_all_metadata()
# ترجمه metadata برای هر action
translated_actions = []
for action in all_actions:
action_key = action.get("key", "")
# تعیین context برای ترجمه
translation_context = None
if action_key == "ai_agent":
translation_context = "ai_agent"
elif action_key == "business_backup":
translation_context = "business_backup"
elif action_key == "crm_web_chat_send_message":
translation_context = "crm_web_chat_send_message"
elif "invoice" in action_key and "create" in action_key:
translation_context = "create_invoice"
elif "bale" in action_key:
translation_context = "send_bale"
elif "telegram" in action_key:
translation_context = "send_telegram"
elif "email" in action_key:
translation_context = "send_email"
elif action_key == "send_business_sms":
translation_context = "send_business_sms"
elif action_key == "create_notification":
translation_context = "create_notification"
elif action_key in _wf_data_action_keys:
translation_context = action_key
# ترجمه metadata
if translation_context:
translated = translate_metadata(action, lang, translation_context)
else:
translated = action
translated_actions.append(translated)
return success_response(
data=translated_actions,
request=request,
message="ACTIONS_METADATA_RETRIEVED",
)
@router.get(
"/workflows/translations",
summary="دریافت تمام ترجمه‌های ورک‌فلو",
description="دریافت تمام رشته‌های ترجمه شده برای استفاده در UI",
)
async def get_workflow_translations(
request: Request,
lang: str = Query("fa", description="زبان (fa/en)"),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت تمام ترجمه‌های ورک‌فلو"""
from app.services.workflow.i18n import (
COMMON_TRANSLATIONS,
CREATE_INVOICE_TRANSLATIONS,
SEND_TELEGRAM_TRANSLATIONS,
SEND_EMAIL_TRANSLATIONS,
SEND_BUSINESS_SMS_TRANSLATIONS,
CREATE_NOTIFICATION_TRANSLATIONS,
OTHER_ACTIONS_TRANSLATIONS,
BUSINESS_BACKUP_TRANSLATIONS,
)
from app.services.workflow.i18n.hesabix_data_actions_i18n import WORKFLOW_ACTION_TRANSLATIONS
from app.services.workflow.i18n.workflow_translations import (
TRIGGER_TRANSLATIONS_BY_KEY,
CRM_WEB_CHAT_SEND_MESSAGE_TRANSLATIONS,
)
all_translations: dict = {}
all_translations.update(COMMON_TRANSLATIONS.get(lang, {}))
all_translations["create_invoice"] = CREATE_INVOICE_TRANSLATIONS.get(lang, {})
all_translations["send_telegram"] = SEND_TELEGRAM_TRANSLATIONS.get(lang, {})
all_translations["send_email"] = SEND_EMAIL_TRANSLATIONS.get(lang, {})
all_translations["send_business_sms"] = SEND_BUSINESS_SMS_TRANSLATIONS.get(lang, {})
all_translations["create_notification"] = CREATE_NOTIFICATION_TRANSLATIONS.get(lang, {})
all_translations["others"] = OTHER_ACTIONS_TRANSLATIONS.get(lang, {})
all_translations["business_backup"] = BUSINESS_BACKUP_TRANSLATIONS.get(lang, {})
all_translations["crm_web_chat_send_message"] = CRM_WEB_CHAT_SEND_MESSAGE_TRANSLATIONS.get(lang, {})
for _tk, _bundle in TRIGGER_TRANSLATIONS_BY_KEY.items():
all_translations[_tk] = _bundle.get(lang, {})
for _ak, _abundle in WORKFLOW_ACTION_TRANSLATIONS.items():
all_translations[_ak] = _abundle.get(lang, {})
return success_response(
data={
"language": lang,
"translations": all_translations
},
request=request,
message="TRANSLATIONS_RETRIEVED",
)
@router.get(
"/workflows/translations/export",
summary="صادرات ترجمه‌ها برای Flutter",
description="صادرات ترجمه‌های ورک‌فلو به فرمت مناسب برای فایل‌های arb",
)
async def export_workflow_translations(
request: Request,
lang: str = Query("fa", description="زبان (fa/en)"),
ctx: AuthContext = Depends(get_current_user),
):
"""صادرات ترجمه‌ها به فرمت arb"""
from app.services.workflow.i18n import (
COMMON_TRANSLATIONS,
CREATE_INVOICE_TRANSLATIONS,
SEND_TELEGRAM_TRANSLATIONS,
SEND_EMAIL_TRANSLATIONS,
OTHER_ACTIONS_TRANSLATIONS,
)
# تبدیل به فرمت arb (flat structure با prefix)
arb_translations = {}
# مشترک
for key, value in COMMON_TRANSLATIONS.get(lang, {}).items():
arb_translations[f"workflow_{key}"] = value
# Create Invoice
for key, value in CREATE_INVOICE_TRANSLATIONS.get(lang, {}).items():
arb_translations[f"workflowCreateInvoice_{key}"] = value
# Send Telegram
for key, value in SEND_TELEGRAM_TRANSLATIONS.get(lang, {}).items():
arb_translations[f"workflowSendTelegram_{key}"] = value
# Send Email
for key, value in SEND_EMAIL_TRANSLATIONS.get(lang, {}).items():
arb_translations[f"workflowSendEmail_{key}"] = value
# Others
for key, value in OTHER_ACTIONS_TRANSLATIONS.get(lang, {}).items():
arb_translations[f"workflowOthers_{key}"] = value
return success_response(
data={
"language": lang,
"format": "arb",
"translations": arb_translations,
"total_keys": len(arb_translations)
},
request=request,
message="TRANSLATIONS_EXPORTED",
)
@router.api_route(
"/workflow-hooks/{webhook_secret}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
summary="اجرای ورک‌فلو از طریق Webhook",
description="بدون احراز هویت کاربر؛ فقط با webhook_secret ذخیره‌شده در تنظیمات ورک‌فلو.",
)
async def workflow_webhook_inbound(
request: Request,
webhook_secret: str,
db: Session = Depends(get_db),
):
secret = (webhook_secret or "").strip()
if len(secret) < 16:
raise ApiError("WORKFLOW_WEBHOOK_NOT_FOUND", "نشانی webhook نامعتبر است", http_status=404)
stmt = select(Workflow).where(Workflow.status == WorkflowStatus.ACTIVE)
candidates = list(db.execute(stmt).scalars().all())
wf = next(
(w for w in candidates if (w.settings or {}).get("webhook_secret") == secret),
None,
)
if wf is None:
raise ApiError("WORKFLOW_WEBHOOK_NOT_FOUND", "ورک‌فلو فعال با این نشانی یافت نشد", http_status=404)
nodes = (wf.workflow_data or {}).get("nodes") or []
has_webhook_trigger = any(
isinstance(n, dict)
and n.get("type") == "trigger"
and (n.get("config") or {}).get("trigger_type") == "webhook"
for n in nodes
)
if not has_webhook_trigger:
raise ApiError(
"WORKFLOW_WEBHOOK_DISABLED",
"این ورک‌فلو تریگر webhook ندارد",
http_status=400,
)
raw_body = await request.body()
body_obj: Any = {}
if raw_body:
try:
body_obj = json.loads(raw_body.decode("utf-8"))
except Exception:
body_obj = {"_raw_text": raw_body.decode("utf-8", errors="replace")}
trigger_data: Dict[str, Any] = {
"method": request.method,
"headers": {k: v for k, v in request.headers.items()},
"query_params": dict(request.query_params),
"body": body_obj,
}
timeout_sec = _webhook_trigger_timeout_seconds(wf)
def _run_workflow_sync():
engine = WorkflowEngine(db, wf.business_id, user_id=None)
return engine.execute_workflow(wf, trigger_data)
try:
execution = await asyncio.wait_for(
asyncio.to_thread(_run_workflow_sync),
timeout=timeout_sec,
)
except asyncio.TimeoutError:
raise ApiError(
"WORKFLOW_WEBHOOK_TIMEOUT",
f"اجرای ورک‌فلو بیش از {int(timeout_sec)} ثانیه طول کشید",
http_status=504,
)
except ApiError:
raise
except Exception as e:
_logger.error("workflow webhook execute failed: %s", e, exc_info=True)
raise ApiError("WORKFLOW_WEBHOOK_EXEC_FAILED", str(e), http_status=500)
return success_response(
data=format_datetime_fields(execution.__dict__, request),
request=request,
message="WORKFLOW_WEBHOOK_EXECUTED",
)