fix: BUG-080/082 (20 unused frontend components deleted), BUG-083 (useTenant.ts deleted), BUG-011 (playwright baseURL), BUG-069 (unused python modules deleted), BUG-065 (already has eager loading), BUG-026 (already fixed 422), BUG-023 (no sync I/O found)
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-22 07:37:11 +02:00
parent 40cc99af5c
commit db4701bae7
30 changed files with 5 additions and 5076 deletions
-57
View File
@@ -1,57 +0,0 @@
"""Outbox delivery model for per-consumer delivery tracking."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class OutboxDelivery(Base):
"""Tracks per-consumer delivery status for outbox events.
Each row represents one consumer (event handler) processing one outbox
event. An event is only fully 'published' when all mandatory deliveries
succeed.
"""
__tablename__ = "outbox_deliveries"
__table_args__ = (
UniqueConstraint("event_id", "consumer_name", name="uq_outbox_deliveries_event_consumer"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
primary_key=True,
server_default=func.gen_random_uuid(),
)
event_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("event_outbox.id", ondelete="CASCADE"),
nullable=False,
)
consumer_name: Mapped[str] = mapped_column(String(150), nullable=False)
status: Mapped[str] = mapped_column(
String(30), nullable=False, server_default="pending",
)
attempt_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0",
)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
@@ -1,94 +0,0 @@
"""Integrates external MCP server tools into the AI Assistant tool_registry.
When the MCP Client plugin activates, it loads all enabled MCP server configs,
fetches their tool lists, and registers each tool in the global ToolRegistry.
Agents can then call external MCP tools like native tools.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
logger = logging.getLogger(__name__)
PLUGIN_NAME = "mcp_client"
def _make_tool_name(server_name: str, tool_name: str) -> str:
"""Generate a unique tool name: mcp__{server}__{tool}."""
safe_server = server_name.replace(" ", "_").replace("-", "_").lower()
return f"mcp__{safe_server}__{tool_name}"
def _make_handler(server_cfg: McpServerConfigModel, tool_name: str):
"""Create an async handler that calls the external MCP server."""
async def _handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
client = McpClient(base_url=server_cfg.url, api_token=server_cfg.api_token)
try:
resp = await client.execute_tool(tool_name, arguments)
if resp.success:
import json
result = resp.result if resp.result is not None else {}
return json.dumps(result) if isinstance(result, dict) else str(result)
return f"Error: {resp.error or 'Unknown error'}"
except Exception as exc:
logger.exception("MCP tool execution failed: %s/%s", server_cfg.name, tool_name)
return f"Error: {exc}"
return _handler
async def sync_external_tools(db: AsyncSession, tenant_id: uuid.UUID) -> int:
"""Fetch tools from all enabled MCP servers and register them in the tool registry.
Returns the number of tools registered.
"""
registry = get_tool_registry()
# Unregister previous tools from this plugin
registry.unregister_plugin(PLUGIN_NAME)
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.tenant_id == tenant_id,
McpServerConfigModel.enabled.is_(True),
)
result = await db.execute(stmt)
configs = result.scalars().all()
count = 0
for cfg in configs:
try:
client = McpClient(base_url=cfg.url, api_token=cfg.api_token, timeout=10.0)
tools_resp = await client.list_tools()
for tool in tools_resp.tools:
tool_name = _make_tool_name(cfg.name, tool.name)
registry.register(
name=tool_name,
description=f"[MCP:{cfg.name}] {tool.description}",
parameters=tool.parameters if isinstance(tool.parameters, dict) else {},
handler=_make_handler(cfg, tool.name),
plugin_name=PLUGIN_NAME,
required_permission="mcp-client:read",
category="mcp-external",
)
count += 1
logger.info("Registered %d tools from MCP server %s", len(tools_resp.tools), cfg.name)
except Exception as exc:
logger.warning("Failed to sync tools from MCP server %s: %s", cfg.name, exc)
return count
def unregister_all_external_tools() -> None:
"""Remove all MCP client tools from the registry."""
registry = get_tool_registry()
registry.unregister_plugin(PLUGIN_NAME)
-215
View File
@@ -1,215 +0,0 @@
"""Plugin quarantine — extract, validate, and install external plugins safely.
Workflow:
1. Extract ZIP to a temporary directory
2. Validate manifest exists and is valid
3. Check for dangerous imports
4. Validate migration SQL
5. Verify signature (if provided)
6. If all checks pass: move to plugins/ directory
7. If any check fails: delete temp directory and raise error
Usage::
from app.plugins.quarantine import quarantine_plugin
plugin_dir = await quarantine_plugin(
zip_path=Path("plugin.zip"),
signature=b"...",
public_key=b"...",
)
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from app.plugins.signature import PluginSignature
logger = logging.getLogger(__name__)
# Maximum plugin ZIP size (50 MB)
MAX_PLUGIN_SIZE = 50 * 1024 * 1024
# Dangerous patterns in plugin source code
DANGEROUS_PATTERNS = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
(r"\bopen\s*\([^)]*['\"]w['\"]", "file write outside DMS"),
]
class QuarantineError(Exception):
"""Raised when a plugin fails quarantine validation."""
def _validate_manifest(plugin_dir: Path) -> dict:
"""Validate that the plugin has a valid manifest.
Returns the parsed manifest data.
"""
plugin_py = plugin_dir / "plugin.py"
init_py = plugin_dir / "__init__.py"
if not plugin_py.exists() and not init_py.exists():
raise QuarantineError("Plugin must have plugin.py or __init__.py")
# Read source and look for manifest
source_file = plugin_py if plugin_py.exists() else init_py
source = source_file.read_text(encoding="utf-8")
if "PluginManifest" not in source:
raise QuarantineError("Plugin source must define a PluginManifest")
if "BasePlugin" not in source:
raise QuarantineError("Plugin source must inherit from BasePlugin")
return {"source_file": str(source_file), "has_manifest": True}
def _check_dangerous_imports(plugin_dir: Path) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
found: list[str] = []
for py_file in plugin_dir.rglob("*.py"):
source = py_file.read_text(encoding="utf-8")
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, source):
found.append(f"{py_file.name}: {description}")
return found
def _check_migration_sql(plugin_dir: Path) -> list[str]:
"""Validate migration SQL files in the plugin.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
migrations_dir = plugin_dir / "migrations"
if not migrations_dir.exists():
return issues # No migrations is OK
for sql_file in migrations_dir.glob("*.sql"):
content = sql_file.read_text(encoding="utf-8")
# Check for tenant_id in CREATE TABLE
if "CREATE TABLE" in content.upper() and "tenant_id" not in content.lower():
issues.append(
f"{sql_file.name}: CREATE TABLE without tenant_id column"
)
# Check for DROP DATABASE / DROP SCHEMA
if "DROP DATABASE" in content.upper() or "DROP SCHEMA" in content.upper():
issues.append(f"{sql_file.name}: Contains DROP DATABASE/SCHEMA")
return issues
async def quarantine_plugin(
zip_path: Path,
signature: bytes | None = None,
public_key: bytes | None = None,
plugins_dir: Path | None = None,
) -> Path:
"""Extract, validate, and install a plugin from a ZIP file.
Args:
zip_path: Path to the plugin ZIP file.
signature: Optional Ed25519 signature bytes.
public_key: Optional Ed25519 public key bytes.
plugins_dir: Target directory for external plugins (default: plugins/).
Returns:
Path to the installed plugin directory.
Raises:
QuarantineError: If any validation check fails.
"""
# Check file size
file_size = zip_path.stat().st_size # noqa: ASYNC240
if file_size > MAX_PLUGIN_SIZE:
raise QuarantineError(
f"Plugin ZIP too large: {file_size} bytes (max {MAX_PLUGIN_SIZE})"
)
# Verify signature if provided
if signature and public_key:
if not PluginSignature.verify_signature(zip_path, signature, public_key):
raise QuarantineError("Signature verification failed")
# Create temp directory for extraction
temp_dir = Path(tempfile.mkdtemp(prefix="plugin_quarantine_"))
try:
# Extract ZIP
with zipfile.ZipFile(zip_path, "r") as zf:
# Check for path traversal in ZIP entries
for entry in zf.namelist():
if entry.startswith("/") or ".." in entry:
raise QuarantineError(f"Unsafe ZIP entry: {entry}")
zf.extractall(temp_dir)
# Find the plugin directory (might be nested)
plugin_dir = temp_dir
if not (plugin_dir / "plugin.py").exists() and not (plugin_dir / "__init__.py").exists():
# Look for a single subdirectory
subdirs = [d for d in plugin_dir.iterdir() if d.is_dir() and not d.name.startswith("_")]
if len(subdirs) == 1:
plugin_dir = subdirs[0]
else:
raise QuarantineError("Could not find plugin root directory in ZIP")
# 1. Validate manifest
_validate_manifest(plugin_dir)
logger.info("Manifest validated for plugin in %s", plugin_dir.name)
# 2. Check dangerous imports
dangerous = _check_dangerous_imports(plugin_dir)
if dangerous:
raise QuarantineError(
f"Dangerous patterns found in plugin: {', '.join(dangerous)}"
)
# 3. Check migration SQL
sql_issues = _check_migration_sql(plugin_dir)
if sql_issues:
raise QuarantineError(
f"Migration SQL issues: {', '.join(sql_issues)}"
)
# 4. All checks passed — move to plugins directory
target_dir = plugins_dir or Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins"))
target_dir.mkdir(parents=True, exist_ok=True)
plugin_name = plugin_dir.name
final_dir = target_dir / plugin_name
if final_dir.exists():
raise QuarantineError(f"Plugin directory already exists: {final_dir}")
shutil.copytree(plugin_dir, final_dir)
logger.info("Plugin installed to %s", final_dir)
return final_dir
except Exception:
# Clean up temp directory on any error
shutil.rmtree(temp_dir, ignore_errors=True)
raise
finally:
# Always clean up temp directory
if temp_dir.exists(): # noqa: ASYNC240
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -1,172 +0,0 @@
"""CustomFieldDefinition service — CRUD with tenant isolation and visibility filter."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.models.custom_field_definition import CustomFieldDefinition
def _definition_to_dict(d: CustomFieldDefinition) -> dict[str, Any]:
"""Serialize a CustomFieldDefinition ORM object to dict."""
return {
"id": str(d.id),
"entity": d.entity,
"name": d.name,
"label": d.label,
"field_type": d.field_type,
"options": d.options,
"default_value": d.default_value,
"required": d.required,
"is_active": d.is_active,
"sort_order": d.sort_order,
"owner_id": str(d.owner_id) if d.owner_id else None,
"created_by": str(d.created_by) if d.created_by else None,
"updated_by": str(d.updated_by) if d.updated_by else None,
}
async def list_custom_field_definitions(
db: AsyncSession,
tenant_id: uuid.UUID,
entity: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> list[dict[str, Any]]:
"""List custom field definitions for a tenant, optionally filtered by entity."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.tenant_id == tenant_id,
CustomFieldDefinition.is_active == True, # noqa: E712
)
if entity:
q = q.where(CustomFieldDefinition.entity == entity)
if user_id and not is_system_admin:
q = await apply_visibility_filter(
db, q, "custom_field_definition", CustomFieldDefinition, user_id, tenant_id, is_system_admin
)
q = q.order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
result = await db.execute(q)
return [_definition_to_dict(d) for d in result.scalars().all()]
async def get_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
definition_id: uuid.UUID,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Get a single custom field definition by ID."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(q)
definition = result.scalar_one_or_none()
if definition is None:
return None
if user_id and not is_system_admin:
has_access = await check_single_entity_access(
db, "custom_field_definition", definition.id, user_id, tenant_id, "read", is_system_admin
)
if not has_access:
raise PermissionError("No access")
return _definition_to_dict(definition)
async def create_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new custom field definition."""
definition = CustomFieldDefinition(
tenant_id=tenant_id,
entity=data["entity"],
name=data["name"],
label=data["label"],
field_type=data["field_type"],
options=data.get("options"),
default_value=data.get("default_value"),
required=data.get("required", False),
is_active=data.get("is_active", True),
sort_order=data.get("sort_order", 0),
created_by=user_id,
updated_by=user_id,
owner_id=user_id,
)
db.add(definition)
await db.flush()
await db.refresh(definition)
return _definition_to_dict(definition)
async def update_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
definition_id: uuid.UUID,
data: dict[str, Any],
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Update an existing custom field definition."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(q)
definition = result.scalar_one_or_none()
if definition is None:
return None
if not is_system_admin:
has_access = await check_single_entity_access(
db, "custom_field_definition", definition.id, user_id, tenant_id, "write", is_system_admin
)
if not has_access:
raise PermissionError("No access")
update_fields = ["label", "field_type", "options", "default_value", "required", "is_active", "sort_order"]
for field in update_fields:
if field in data:
setattr(definition, field, data[field])
definition.updated_by = user_id
await db.flush()
await db.refresh(definition)
return _definition_to_dict(definition)
async def delete_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
definition_id: uuid.UUID,
is_system_admin: bool = False,
) -> bool:
"""Delete a custom field definition."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(q)
definition = result.scalar_one_or_none()
if definition is None:
return False
if not is_system_admin:
has_access = await check_single_entity_access(
db, "custom_field_definition", definition.id, user_id, tenant_id, "admin", is_system_admin
)
if not has_access:
raise PermissionError("No access")
await db.delete(definition)
await db.flush()
return True
-187
View File
@@ -1,187 +0,0 @@
"""Plugin install service — business logic for plugin installation from ZIP/URL."""
from __future__ import annotations
import importlib
import logging
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from app.plugins.base import BasePlugin
from app.services.plugin_service import get_plugin_service
logger = logging.getLogger(__name__)
class PluginInstallService:
"""Service layer for plugin installation operations.
Handles ZIP extraction, security validation, and plugin registration.
"""
@staticmethod
def validate_manifest_name(name: str) -> str:
"""Validate plugin name is alphanumeric with underscores only."""
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name):
raise ValueError(
f"Invalid plugin name '{name}': must start with a letter and contain only "
f"alphanumeric characters and underscores"
)
return name
@staticmethod
def check_dangerous_imports(source_code: str) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
dangerous_patterns = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
]
found: list[str] = []
for pattern, description in dangerous_patterns:
if re.search(pattern, source_code):
found.append(description)
return found
@staticmethod
def check_migration_sql(sql_content: str) -> list[str]:
"""Basic SQL validation for migration files.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
lines = sql_content.strip().split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped or stripped.startswith("--"):
continue
if stripped.count("(") != stripped.count(")"):
issues.append(f"Line {i}: unbalanced parentheses")
if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE):
issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations")
return issues
@staticmethod
def find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None:
"""Find a BasePlugin subclass in a module."""
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
return attr
return None
@staticmethod
def extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]:
"""Extract a ZIP file and find the plugin class.
Returns (extract_dir, plugin_name, plugin_class).
"""
extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_"))
try:
with zipfile.ZipFile(zip_path, "r") as zf:
bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")]
if bad_files:
raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}")
zf.extractall(extract_dir)
plugin_py_path: Path | None = None
for fpath in extract_dir.rglob("plugin.py"):
plugin_py_path = fpath
break
if plugin_py_path is None:
raise ValueError("ZIP does not contain a plugin.py file")
source_code = plugin_py_path.read_text(encoding="utf-8")
dangerous = PluginInstallService.check_dangerous_imports(source_code)
if dangerous:
raise ValueError(
f"Plugin contains dangerous patterns: {', '.join(dangerous)}"
)
spec = importlib.util.spec_from_file_location(
"uploaded_plugin", plugin_py_path
)
if spec is None or spec.loader is None:
raise ValueError("Could not load plugin.py module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = PluginInstallService.find_plugin_class_in_module(module)
if plugin_class is None:
raise ValueError(
"plugin.py does not contain a BasePlugin subclass"
)
plugin_instance = plugin_class()
plugin_name = plugin_instance.name
manifest = plugin_instance.manifest
if not manifest.name or not manifest.version or not manifest.display_name:
raise ValueError(
"Plugin manifest must include name, version, and display_name"
)
PluginInstallService.validate_manifest_name(manifest.name)
migrations_dir = plugin_py_path.parent / "migrations"
if migrations_dir.exists():
for sql_file in sorted(migrations_dir.glob("*.sql")):
sql_content = sql_file.read_text(encoding="utf-8")
issues = PluginInstallService.check_migration_sql(sql_content)
if issues:
raise ValueError(
f"Migration file {sql_file.name} has issues: {'; '.join(issues)}"
)
return extract_dir, plugin_name, plugin_class
except Exception:
shutil.rmtree(extract_dir, ignore_errors=True)
raise
@staticmethod
def install_plugin_from_dir(
extract_dir: Path,
plugin_name: str,
plugin_class: type[BasePlugin],
) -> None:
"""Copy plugin directory to builtins and register it.
Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/.
"""
builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name
builtins_dir.mkdir(parents=True, exist_ok=True)
for item in extract_dir.iterdir():
dest = builtins_dir / item.name
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
registry = get_plugin_service().registry
instance = plugin_class()
registry.register_plugin(instance)
logger.info(
"Installed plugin '%s' from uploaded ZIP to %s",
plugin_name,
builtins_dir,
)
-162
View File
@@ -1,162 +0,0 @@
"""SavedView service — CRUD with tenant isolation and visibility filter."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.models.saved_view import SavedView
def _view_to_dict(v: SavedView) -> dict[str, Any]:
"""Serialize a SavedView ORM object to dict."""
return {
"id": str(v.id),
"name": v.name,
"entity_type": v.entity_type,
"view_config": v.view_config,
"user_id": str(v.user_id),
"created_at": v.created_at.isoformat() if v.created_at else None,
"updated_at": v.updated_at.isoformat() if v.updated_at else None,
}
async def list_saved_views(
db: AsyncSession,
tenant_id: uuid.UUID,
entity_type: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> list[dict[str, Any]]:
"""List saved views for a tenant, optionally filtered by entity_type."""
q = select(SavedView).where(
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
if entity_type:
q = q.where(SavedView.entity_type == entity_type)
if user_id and not is_system_admin:
q = await apply_visibility_filter(
db, q, "saved_view", SavedView, user_id, tenant_id, is_system_admin
)
q = q.order_by(SavedView.name)
result = await db.execute(q)
return [_view_to_dict(v) for v in result.scalars().all()]
async def get_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
view_id: uuid.UUID,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Get a single saved view by ID."""
q = select(SavedView).where(
SavedView.id == view_id,
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
result = await db.execute(q)
saved = result.scalar_one_or_none()
if saved is None:
return None
if user_id and not is_system_admin:
has_access = await check_single_entity_access(
db, "saved_view", saved.id, user_id, tenant_id, "read", is_system_admin
)
if not has_access:
raise PermissionError("No access")
return _view_to_dict(saved)
async def create_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new saved view."""
saved = SavedView(
tenant_id=tenant_id,
user_id=user_id,
name=data["name"],
entity_type=data["entity_type"],
view_config=data.get("view_config", {}),
owner_id=user_id,
)
db.add(saved)
await db.flush()
await db.refresh(saved)
return _view_to_dict(saved)
async def update_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
view_id: uuid.UUID,
data: dict[str, Any],
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Update a saved view."""
q = select(SavedView).where(
SavedView.id == view_id,
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
result = await db.execute(q)
saved = result.scalar_one_or_none()
if saved is None:
return None
if not is_system_admin:
has_access = await check_single_entity_access(
db, "saved_view", saved.id, user_id, tenant_id, "write", is_system_admin
)
if not has_access:
raise PermissionError("No access")
if "name" in data and data["name"] is not None:
saved.name = data["name"]
if "view_config" in data and data["view_config"] is not None:
saved.view_config = data["view_config"]
await db.flush()
await db.refresh(saved)
return _view_to_dict(saved)
async def delete_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
view_id: uuid.UUID,
is_system_admin: bool = False,
) -> bool:
"""Soft-delete a saved view."""
q = select(SavedView).where(
SavedView.id == view_id,
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
result = await db.execute(q)
saved = result.scalar_one_or_none()
if saved is None:
return False
if not is_system_admin:
has_access = await check_single_entity_access(
db, "saved_view", saved.id, user_id, tenant_id, "admin", is_system_admin
)
if not has_access:
raise PermissionError("No access")
saved.deleted_at = datetime.now(UTC)
await db.flush()
return True
-118
View File
@@ -1,118 +0,0 @@
"""Onboarding workflow — runs on user creation.
A code-engine workflow that creates a welcome notification and an
approval step for admin confirmation of new users.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.workflow_service import create_instance
# Onboarding workflow definition (code-engine — hardcoded steps)
ONBOARDING_WORKFLOW_STEPS = [
{
"name": "Send welcome notification",
"type": "notification",
"config": {
"notification_type": "welcome",
"title": "Welcome to LeoCRM!",
"body": "Your account has been created. An admin will approve your access shortly.",
},
"description": "Send a welcome notification to the new user",
},
{
"name": "Admin approval",
"type": "approval",
"config": {
"required_role": "admin",
"description": "Admin must approve the new user account",
},
"description": "Admin reviews and approves the new user",
},
{
"name": "Send confirmation",
"type": "notification",
"config": {
"notification_type": "onboarding_complete",
"title": "Account approved",
"body": "Your account has been approved. You can now use LeoCRM.",
},
"description": "Notify user that their account is approved",
},
]
def get_onboarding_workflow_definition() -> dict[str, Any]:
"""Return the onboarding workflow definition for DB seeding."""
return {
"name": "User Onboarding",
"description": "Automated onboarding workflow triggered on user creation",
"trigger_event": "user.created",
"steps": ONBOARDING_WORKFLOW_STEPS,
"is_active": True,
}
async def ensure_onboarding_workflow_exists(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> uuid.UUID | None:
"""Ensure the onboarding workflow exists in the DB for this tenant.
If it doesn't exist yet, create it. Returns the workflow ID.
"""
from sqlalchemy import select
from app.models.workflow import Workflow
result = await db.execute(
select(Workflow).where(
Workflow.tenant_id == tenant_id,
Workflow.trigger_event == "user.created",
Workflow.name == "User Onboarding",
)
)
existing = result.scalar_one_or_none()
if existing:
return existing.id
from app.services.workflow_service import create_workflow
wf_dict = await create_workflow(
db,
tenant_id,
user_id,
get_onboarding_workflow_definition(),
)
return uuid.UUID(wf_dict["id"]) if wf_dict else None
async def trigger_onboarding(
db: AsyncSession,
tenant_id: uuid.UUID,
admin_user_id: uuid.UUID,
new_user_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Trigger the onboarding workflow for a newly created user.
1. Ensure the onboarding workflow exists in DB
2. Create a workflow instance with new_user_id in context
"""
wf_id = await ensure_onboarding_workflow_exists(db, tenant_id, admin_user_id)
if wf_id is None:
return None
instance = await create_instance(
db,
tenant_id,
admin_user_id,
str(wf_id),
context={"new_user_id": str(new_user_id), "user_id": str(new_user_id)},
)
return instance