chore: fix all ruff lint errors + format — 0 errors, 306 tests pass

This commit is contained in:
leocrm-bot
2026-06-29 17:43:56 +02:00
parent 316f323ff4
commit a2452cc04b
81 changed files with 2317 additions and 1128 deletions
+12 -3
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import get_settings
from app.core.db import Base
from app.models import * # noqa: F401,F403
@@ -25,7 +25,12 @@ config.set_main_option("sqlalchemy.url", settings.database_url)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
@@ -37,7 +42,11 @@ def do_run_migrations(connection: Connection) -> None:
async def run_async_migrations() -> None:
connectable = async_engine_from_config(config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool)
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
+136 -105
View File
@@ -9,19 +9,26 @@ from __future__ import annotations
import re
from typing import Any
# Precompiled patterns for intent detection
_PATTERNS = {
'create_company': re.compile(r'\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b', re.IGNORECASE),
'delete_company': re.compile(r'\b(delete|remove)\b.*\b(company|firm)\b', re.IGNORECASE),
'update_company': re.compile(r'\b(update|edit|modify|change)\b.*\b(company|firm)\b', re.IGNORECASE),
'list_company': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b', re.IGNORECASE),
'list_company2': re.compile(r'\bcompan.*\b(list|all)\b', re.IGNORECASE),
'create_contact': re.compile(r'\b(create|add|new)\b.*\b(contact|person)\b', re.IGNORECASE),
'list_contact': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(contact|person)\b', re.IGNORECASE),
'list_workflow': re.compile(r'\b(list|show|get|display)\b.*\b(workflow)\b', re.IGNORECASE),
'create_workflow': re.compile(r'\b(create|new|add)\b.*\b(workflow)\b', re.IGNORECASE),
'help': re.compile(r'\b(help|what can you do|assist)\b', re.IGNORECASE),
"create_company": re.compile(
r"\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b", re.IGNORECASE
),
"delete_company": re.compile(r"\b(delete|remove)\b.*\b(company|firm)\b", re.IGNORECASE),
"update_company": re.compile(
r"\b(update|edit|modify|change)\b.*\b(company|firm)\b", re.IGNORECASE
),
"list_company": re.compile(
r"\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b", re.IGNORECASE
),
"list_company2": re.compile(r"\bcompan.*\b(list|all)\b", re.IGNORECASE),
"create_contact": re.compile(r"\b(create|add|new)\b.*\b(contact|person)\b", re.IGNORECASE),
"list_contact": re.compile(
r"\b(list|show|find|search|get|display)\b.*\b(contact|person)\b", re.IGNORECASE
),
"list_workflow": re.compile(r"\b(list|show|get|display)\b.*\b(workflow)\b", re.IGNORECASE),
"create_workflow": re.compile(r"\b(create|new|add)\b.*\b(workflow)\b", re.IGNORECASE),
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
}
# Name extraction patterns - using single-quoted strings to avoid escaping issues
@@ -36,10 +43,14 @@ _NAME_PATTERNS = [
# Field extraction patterns
_INDUSTRY_PAT = re.compile(r"industry\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_NAME_UPDATE_PAT = re.compile(r"(?:name|rename)\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_NAME_UPDATE_PAT = re.compile(
r"(?:name|rename)\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE
)
_PHONE_PAT = re.compile(r"phone\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_EMAIL_PAT = re.compile(r"email\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_SEARCH_PAT = re.compile(r"\b(?:named|called|matching|with name)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_SEARCH_PAT = re.compile(
r"\b(?:named|called|matching|with name)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE
)
def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> list[dict[str, Any]]:
@@ -53,109 +64,129 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
actions: list[dict[str, Any]] = []
# --- Company intents ---
if _PATTERNS['create_company'].search(q):
if _PATTERNS["create_company"].search(q):
name = _extract_name(query)
actions.append({
'method': 'POST',
'path': '/api/v1/companies',
'body': {'name': name or 'New Company'},
'description': f"Create a new company named '{name or 'New Company'}'",
'confidence': 0.9,
})
actions.append(
{
"method": "POST",
"path": "/api/v1/companies",
"body": {"name": name or "New Company"},
"description": f"Create a new company named '{name or 'New Company'}'",
"confidence": 0.9,
}
)
elif _PATTERNS['delete_company'].search(q):
entity_id = context.get('company_id') or context.get('entity_id')
elif _PATTERNS["delete_company"].search(q):
entity_id = context.get("company_id") or context.get("entity_id")
if entity_id:
actions.append({
'method': 'DELETE',
'path': f'/api/v1/companies/{entity_id}',
'body': None,
'description': f'Delete company {entity_id}',
'confidence': 0.9,
})
actions.append(
{
"method": "DELETE",
"path": f"/api/v1/companies/{entity_id}",
"body": None,
"description": f"Delete company {entity_id}",
"confidence": 0.9,
}
)
else:
actions.append({
'method': 'DELETE',
'path': '/api/v1/companies/{id}',
'body': None,
'description': 'Delete a company (requires company ID in context or selection)',
'confidence': 0.5,
})
actions.append(
{
"method": "DELETE",
"path": "/api/v1/companies/{id}",
"body": None,
"description": "Delete a company (requires company ID in context or selection)",
"confidence": 0.5,
}
)
elif _PATTERNS['update_company'].search(q):
entity_id = context.get('company_id') or context.get('entity_id')
path = f'/api/v1/companies/{entity_id}' if entity_id else '/api/v1/companies/{id}'
actions.append({
'method': 'PATCH',
'path': path,
'body': _extract_update_fields(query),
'description': 'Update company information',
'confidence': 0.8,
})
elif _PATTERNS["update_company"].search(q):
entity_id = context.get("company_id") or context.get("entity_id")
path = f"/api/v1/companies/{entity_id}" if entity_id else "/api/v1/companies/{id}"
actions.append(
{
"method": "PATCH",
"path": path,
"body": _extract_update_fields(query),
"description": "Update company information",
"confidence": 0.8,
}
)
elif _PATTERNS['list_company'].search(q) or _PATTERNS['list_company2'].search(q):
elif _PATTERNS["list_company"].search(q) or _PATTERNS["list_company2"].search(q):
search_term = _extract_search_term(query)
desc = 'List companies'
desc = "List companies"
if search_term:
desc += f" matching '{search_term}'"
actions.append({
'method': 'GET',
'path': '/api/v1/companies',
'body': None,
'description': desc,
'confidence': 0.85,
})
actions.append(
{
"method": "GET",
"path": "/api/v1/companies",
"body": None,
"description": desc,
"confidence": 0.85,
}
)
# --- Contact intents ---
elif _PATTERNS['create_contact'].search(q):
elif _PATTERNS["create_contact"].search(q):
name = _extract_name(query)
actions.append({
'method': 'POST',
'path': '/api/v1/contacts',
'body': {'name': name or 'New Contact'},
'description': f"Create a new contact named '{name or 'New Contact'}'",
'confidence': 0.9,
})
actions.append(
{
"method": "POST",
"path": "/api/v1/contacts",
"body": {"name": name or "New Contact"},
"description": f"Create a new contact named '{name or 'New Contact'}'",
"confidence": 0.9,
}
)
elif _PATTERNS['list_contact'].search(q):
actions.append({
'method': 'GET',
'path': '/api/v1/contacts',
'body': None,
'description': 'List contacts',
'confidence': 0.85,
})
elif _PATTERNS["list_contact"].search(q):
actions.append(
{
"method": "GET",
"path": "/api/v1/contacts",
"body": None,
"description": "List contacts",
"confidence": 0.85,
}
)
# --- Workflow intents ---
elif _PATTERNS['list_workflow'].search(q):
actions.append({
'method': 'GET',
'path': '/api/v1/workflows',
'body': None,
'description': 'List workflows',
'confidence': 0.85,
})
elif _PATTERNS["list_workflow"].search(q):
actions.append(
{
"method": "GET",
"path": "/api/v1/workflows",
"body": None,
"description": "List workflows",
"confidence": 0.85,
}
)
elif _PATTERNS['create_workflow'].search(q):
elif _PATTERNS["create_workflow"].search(q):
name = _extract_name(query)
actions.append({
'method': 'POST',
'path': '/api/v1/workflows',
'body': {'name': name or 'New Workflow', 'steps': []},
'description': 'Create a new workflow',
'confidence': 0.8,
})
actions.append(
{
"method": "POST",
"path": "/api/v1/workflows",
"body": {"name": name or "New Workflow", "steps": []},
"description": "Create a new workflow",
"confidence": 0.8,
}
)
# --- Generic fallback ---
if not actions:
if _PATTERNS['help'].search(q):
actions.append({
'method': 'GET',
'path': '/api/v1/companies',
'body': None,
'description': 'Show available companies (demonstration action)',
'confidence': 0.3,
})
if _PATTERNS["help"].search(q):
actions.append(
{
"method": "GET",
"path": "/api/v1/companies",
"body": None,
"description": "Show available companies (demonstration action)",
"confidence": 0.3,
}
)
return actions
@@ -180,20 +211,20 @@ def _extract_search_term(query: str) -> str | None:
def _extract_update_fields(query: str) -> dict[str, Any]:
"""Extract fields to update from the query."""
fields: dict[str, Any] = {}
if re.search(r'\bindustry\b', query, re.IGNORECASE):
if re.search(r"\bindustry\b", query, re.IGNORECASE):
match = _INDUSTRY_PAT.search(query)
if match:
fields['industry'] = match.group(1).strip()
if re.search(r'\b(name|rename)\b', query, re.IGNORECASE):
fields["industry"] = match.group(1).strip()
if re.search(r"\b(name|rename)\b", query, re.IGNORECASE):
match = _NAME_UPDATE_PAT.search(query)
if match:
fields['name'] = match.group(1).strip()
if re.search(r'\bphone\b', query, re.IGNORECASE):
fields["name"] = match.group(1).strip()
if re.search(r"\bphone\b", query, re.IGNORECASE):
match = _PHONE_PAT.search(query)
if match:
fields['phone'] = match.group(1).strip()
if re.search(r'\bemail\b', query, re.IGNORECASE):
fields["phone"] = match.group(1).strip()
if re.search(r"\bemail\b", query, re.IGNORECASE):
match = _EMAIL_PAT.search(query)
if match:
fields['email'] = match.group(1).strip()
return fields or {'name': 'Updated Name'}
fields["email"] = match.group(1).strip()
return fields or {"name": "Updated Name"}
+22 -6
View File
@@ -7,9 +7,9 @@ tests to run without external API dependencies.
from __future__ import annotations
import os
import json
import logging
import os
from typing import Any
import httpx
@@ -20,7 +20,9 @@ logger = logging.getLogger(__name__)
class LLMResponse:
"""Structured LLM response containing proposed actions."""
def __init__(self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8):
def __init__(
self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8
):
self.message = message
self.proposed_actions = proposed_actions
self.confidence = confidence
@@ -41,7 +43,9 @@ class LLMClient:
- Otherwise: mock/stub mode with keyword-based action mapping
"""
def __init__(self, model: str | None = None, api_key: str | None = None, api_base: str | None = None):
def __init__(
self, model: str | None = None, api_key: str | None = None, api_base: str | None = None
):
self.model = model or os.environ.get("AI_MODEL", "")
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
self.api_base = api_base or os.environ.get("AI_API_BASE", "https://api.openai.com/v1")
@@ -118,9 +122,21 @@ class LLMClient:
available_apis = [
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"},
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"},
{"method": "GET", "path": "/api/v1/companies/{id}", "description": "Get company details"},
{"method": "PATCH", "path": "/api/v1/companies/{id}", "description": "Update a company"},
{"method": "DELETE", "path": "/api/v1/companies/{id}", "description": "Delete a company"},
{
"method": "GET",
"path": "/api/v1/companies/{id}",
"description": "Get company details",
},
{
"method": "PATCH",
"path": "/api/v1/companies/{id}",
"description": "Update a company",
},
{
"method": "DELETE",
"path": "/api/v1/companies/{id}",
"description": "Delete a company",
},
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts"},
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact"},
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
+12 -7
View File
@@ -5,20 +5,20 @@ from __future__ import annotations
import hashlib
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
import redis.asyncio as aioredis
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.user import User, UserTenant
from app.models.role import Role
from app.models.session import Session as SessionModel
from app.models.user import User
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds)
_pwd_context = CryptContext(
schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds
)
def hash_password(password: str) -> str:
@@ -63,7 +63,7 @@ async def create_session(
settings = get_settings()
session_id = str(uuid.uuid4())
csrf_token = generate_csrf_token()
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.session_ttl_seconds)
expires_at = datetime.now(UTC) + timedelta(seconds=settings.session_ttl_seconds)
# Redis runtime session
session_data: dict[str, Any] = {
@@ -76,6 +76,7 @@ async def create_session(
"is_active": user.is_active,
}
import json
await redis.setex(
f"session:{session_id}",
settings.session_ttl_seconds,
@@ -99,6 +100,7 @@ async def create_session(
async def get_session_data(redis: aioredis.Redis, session_id: str) -> dict[str, Any] | None:
"""Retrieve session data from Redis."""
import json
raw = await redis.get(f"session:{session_id}")
if raw is None:
return None
@@ -117,6 +119,7 @@ async def update_session_tenant(
) -> dict[str, Any] | None:
"""Update the active tenant in a Redis session."""
import json
settings = get_settings()
raw = await redis.get(f"session:{session_id}")
if raw is None:
@@ -130,7 +133,9 @@ async def update_session_tenant(
return data
def check_permission(role_name: str, module: str, action: str, permissions: dict | None = None) -> bool:
def check_permission(
role_name: str, module: str, action: str, permissions: dict | None = None
) -> bool:
"""Check if a role has permission for a module+action.
Built-in roles: admin (all), editor (read+write), viewer (read only).
Custom roles use the permissions dict.
-1
View File
@@ -9,7 +9,6 @@ import redis.asyncio as aioredis
from app.config import get_settings
_cache_redis: aioredis.Redis | None = None
+13 -15
View File
@@ -3,10 +3,14 @@
from __future__ import annotations
import contextlib
import uuid
from collections.abc import AsyncGenerator
from typing import Any
from datetime import datetime
from typing import Any # noqa: F401
from sqlalchemy import event as sa_event
from sqlalchemy import DateTime, String, func, text # noqa: F401
from sqlalchemy import event as sa_event # noqa: F401
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
@@ -14,10 +18,6 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy import text, String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
import uuid
from datetime import datetime
from app.config import get_settings
@@ -26,8 +26,8 @@ class Base(DeclarativeBase):
"""Declarative base with shared columns and tenant-scoping support."""
@declared_attr.directive
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"
def __tablename__(self) -> str:
return self.__name__.lower() + "s"
class TimestampMixin:
@@ -44,9 +44,7 @@ class TimestampMixin:
class TenantMixin(TimestampMixin):
"""Adds tenant_id column and enables ORM-level auto-filtering."""
tenant_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), nullable=False, index=True
)
tenant_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False, index=True)
# Global engine and session factory
@@ -101,7 +99,9 @@ async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str)
@contextlib.asynccontextmanager
async def create_db_session(tenant_id: uuid.UUID | str | None = None) -> AsyncGenerator[AsyncSession, None]:
async def create_db_session(
tenant_id: uuid.UUID | str | None = None,
) -> AsyncGenerator[AsyncSession, None]:
"""Create a standalone session outside FastAPI (e.g. for tests/workers)."""
factory = get_session_factory()
async with factory() as session:
@@ -128,7 +128,5 @@ def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSes
"""Replace the global engine with a test engine. Returns a session factory."""
global _engine, _session_factory
_engine = engine
_session_factory = async_sessionmaker(
bind=engine, expire_on_commit=False, class_=AsyncSession
)
_session_factory = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
return _session_factory
+3 -1
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any, Callable, Coroutine
from collections.abc import Callable, Coroutine
from typing import Any
EventHandler = Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
@@ -48,4 +49,5 @@ def register_workflow_event_handlers() -> None:
Should be called during application startup.
"""
from app.workflows.engine import register_workflow_event_handlers as _register
_register()
+15 -5
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import select, func, update
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
@@ -42,10 +43,14 @@ async def list_notifications(
"""List notifications for a user, unread first, then by created_at desc."""
offset = (page - 1) * page_size
# Count total
count_q = select(func.count()).select_from(Notification).where(
count_q = (
select(func.count())
.select_from(Notification)
.where(
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
)
)
total = (await db.execute(count_q)).scalar() or 0
# Query — unread first (read_at IS NULL), then newest
@@ -80,7 +85,8 @@ async def mark_notification_read(
notification_id: uuid.UUID,
) -> Notification | None:
"""Mark a notification as read."""
from datetime import datetime, timezone
from datetime import datetime
q = (
update(Notification)
.where(
@@ -88,7 +94,7 @@ async def mark_notification_read(
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
)
.values(read_at=datetime.now(timezone.utc))
.values(read_at=datetime.now(UTC))
.returning(Notification)
)
result = await db.execute(q)
@@ -102,11 +108,15 @@ async def get_unread_count(
user_id: uuid.UUID,
) -> int:
"""Get unread notification count for a user."""
q = select(func.count()).select_from(Notification).where(
q = (
select(func.count())
.select_from(Notification)
.where(
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
Notification.read_at.is_(None),
)
)
result = await db.execute(q)
return result.scalar() or 0
-1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from fastapi import HTTPException, Request, status
from app.core.auth import get_redis
from app.config import get_settings
async def check_rate_limit(
+1 -3
View File
@@ -3,10 +3,7 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import event as sa_event
from sqlalchemy.orm import Session, with_loader_criteria
from sqlalchemy.sql import Select
from app.core.db import TenantMixin
@@ -21,4 +18,5 @@ def apply_tenant_filter(query: Select, tenant_id: uuid.UUID) -> Select:
async def set_rls_context(session, tenant_id: uuid.UUID | str) -> None:
"""Set PostgreSQL RLS session variable."""
from app.core.db import set_tenant_context
await set_tenant_context(session, tenant_id)
-2
View File
@@ -7,13 +7,11 @@ from typing import Any
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis, get_session_data
from app.core.db import get_db, set_tenant_context
from app.models.user import User
async def get_redis_dep() -> aioredis.Redis:
+15 -2
View File
@@ -8,11 +8,24 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.core.middleware import CSRFMiddleware
from app.core.db import close_engine, get_engine
from app.core.middleware import CSRFMiddleware
from app.core.service_container import get_container
from app.plugins.registry import get_registry
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
from app.routes import (
ai_copilot,
auth,
companies,
contacts,
health,
import_export,
notifications,
plugins,
roles,
tenants,
users,
workflows,
)
@asynccontextmanager
+43 -20
View File
@@ -1,32 +1,55 @@
"""SQLAlchemy models for LeoCRM."""
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.audit import AuditLog, DeletionLog
from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.models.notification import Notification
from app.models.plugin import Plugin, PluginMigration
from app.models.role import Role
from app.models.session import Session
from app.models.audit import AuditLog, DeletionLog
from app.models.notification import Notification
from app.models.auth import PasswordResetToken, ApiToken
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.plugin import Plugin, PluginMigration
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
__all__ = [
"Tenant", "User", "UserTenant", "Role", "Session",
"AuditLog", "DeletionLog", "Notification",
"PasswordResetToken", "ApiToken", "Company",
"Contact", "CompanyContact",
"Plugin", "PluginMigration",
"Tenant",
"User",
"UserTenant",
"Role",
"Session",
"AuditLog",
"DeletionLog",
"Notification",
"PasswordResetToken",
"ApiToken",
"Company",
"Contact",
"CompanyContact",
"Plugin",
"PluginMigration",
]
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
__all__ = [
"Tenant", "User", "UserTenant", "Role", "Session",
"AuditLog", "DeletionLog", "Notification",
"PasswordResetToken", "ApiToken", "Company",
"Contact", "CompanyContact",
"Plugin", "PluginMigration",
"AIConversation", "AIMessage",
"Workflow", "WorkflowInstance", "WorkflowStepHistory",
"Tenant",
"User",
"UserTenant",
"Role",
"Session",
"AuditLog",
"DeletionLog",
"Notification",
"PasswordResetToken",
"ApiToken",
"Company",
"Contact",
"CompanyContact",
"Plugin",
"PluginMigration",
"AIConversation",
"AIMessage",
"Workflow",
"WorkflowInstance",
"WorkflowStepHistory",
]
+9 -10
View File
@@ -3,11 +3,11 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -17,9 +17,7 @@ class AIConversation(Base, TenantMixin):
"""AI Copilot conversation thread — tenant-scoped."""
__tablename__ = "ai_conversations"
__table_args__ = (
Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),
)
__table_args__ = (Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
@@ -35,15 +33,16 @@ class AIMessage(Base, TenantMixin):
"""Individual messages within an AI conversation — user input, AI response, actions."""
__tablename__ = "ai_messages"
__table_args__ = (
Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),
)
__table_args__ = (Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False, index=True
PGUUID(as_uuid=True),
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
content: Mapped[str] = mapped_column(Text, nullable=False)
+3 -2
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, DateTime, ForeignKey, func, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
+4 -5
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Index
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -32,9 +33,7 @@ class ApiToken(Base, TenantMixin):
"""API token for programmatic access."""
__tablename__ = "api_tokens"
__table_args__ = (
Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),
)
__table_args__ = (Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+4 -5
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, Computed
from sqlalchemy.dialects.postgresql import UUID as PGUUID, TSVECTOR
from sqlalchemy import Computed, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -34,9 +35,7 @@ class Company(Base, TenantMixin):
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# FTS vector — PostgreSQL generated column, auto-updated on insert/update
search_tsv: Mapped[Any] = mapped_column(
TSVECTOR,
+5 -9
View File
@@ -6,12 +6,12 @@ import uuid
from datetime import datetime
from sqlalchemy import (
String,
Text,
DateTime,
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
@@ -42,9 +42,7 @@ class Contact(Base, TenantMixin):
department: Mapped[str | None] = mapped_column(String(100), nullable=True)
linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -62,9 +60,7 @@ class CompanyContact(Base, TenantMixin):
__tablename__ = "company_contacts"
__table_args__ = (
UniqueConstraint(
"company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"
),
UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
Index("ix_cc_company", "company_id"),
Index("ix_cc_contact", "contact_id"),
)
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text, func, Index
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
+8 -18
View File
@@ -3,10 +3,9 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Boolean, Text, DateTime, func, Index
from sqlalchemy import Boolean, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -21,9 +20,7 @@ class Plugin(Base, TimestampMixin):
"""
__tablename__ = "plugins"
__table_args__ = (
Index("ix_plugins_name", "name", unique=True),
)
__table_args__ = (Index("ix_plugins_name", "name", unique=True),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
@@ -31,17 +28,12 @@ class Plugin(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
version: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="installed"
)
installed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed")
installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
config: Mapped[dict[str, Any] | None] = mapped_column(
Text, nullable=True # JSON string for plugin configuration
Text,
nullable=True, # JSON string for plugin configuration
)
# Transient attribute for response (not persisted)
@@ -63,6 +55,4 @@ class PluginMigration(Base, TimestampMixin):
)
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="applied"
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
+3 -2
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
+5 -6
View File
@@ -6,9 +6,10 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -17,9 +18,7 @@ class User(Base, TenantMixin):
"""User entity — belongs to a tenant, can be member of multiple tenants."""
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+17 -14
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer, Boolean
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -48,7 +49,10 @@ class WorkflowInstance(Base, TenantMixin):
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
workflow_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False, index=True
PGUUID(as_uuid=True),
ForeignKey("workflows.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending")
# pending → in_progress → completed / rejected / cancelled
@@ -57,32 +61,31 @@ class WorkflowInstance(Base, TenantMixin):
initiated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timeout_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
timeout_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
timeout_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class WorkflowStepHistory(Base, TenantMixin):
"""Immutable record of every step transition in a workflow instance."""
__tablename__ = "workflow_step_history"
__table_args__ = (
Index("ix_wf_step_history_tenant_instance", "tenant_id", "instance_id"),
)
__table_args__ = (Index("ix_wf_step_history_tenant_instance", "tenant_id", "instance_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
instance_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("workflow_instances.id", ondelete="CASCADE"), nullable=False, index=True
PGUUID(as_uuid=True),
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False) # entered, approved, rejected, skipped, cancelled
action: Mapped[str] = mapped_column(
String(50), nullable=False
) # entered, approved, rejected, skipped, cancelled
actor_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+2 -2
View File
@@ -1,8 +1,8 @@
"""Plugin system framework for LeoCRM."""
from app.plugins.manifest import PluginManifest
from app.plugins.base import BasePlugin
from app.plugins.registry import get_registry
from app.plugins.manifest import PluginManifest
from app.plugins.migration_runner import MigrationRunner
from app.plugins.registry import get_registry
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
+7 -1
View File
@@ -11,6 +11,7 @@ from app.plugins.manifest import PluginManifest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import EventBus
from app.core.service_container import ServiceContainer
@@ -85,6 +86,7 @@ class BasePlugin(ABC):
routers: list[APIRouter] = []
for route_def in self.manifest.routes:
import importlib
module = importlib.import_module(route_def.module)
router: APIRouter = getattr(module, route_def.router_attr)
routers.append(router)
@@ -106,9 +108,11 @@ class BasePlugin(ABC):
fallback = getattr(self, "on_event", None)
if fallback is not None:
return fallback
# Default no-op handler
async def _noop_handler(payload: dict[str, Any]) -> None:
pass
return _noop_handler
# ─── Utility ───
@@ -122,4 +126,6 @@ class BasePlugin(ABC):
return self.manifest.version
def __repr__(self) -> str:
return f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
return (
f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
)
+2 -2
View File
@@ -7,8 +7,8 @@ Subdirectory plugins (tags, permissions, entity_links) export their plugin
class via __init__.py so the registry can discover them as packages.
"""
from app.plugins.builtins.tags import TagsPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.entity_links import EntityLinksPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.tags import TagsPlugin
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin"]
+6 -5
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy import Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -17,7 +17,10 @@ class EntityLink(Base, TenantMixin):
__tablename__ = "entity_links"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "entity_type", "entity_id",
"tenant_id",
"file_id",
"entity_type",
"entity_id",
name="uq_entity_links_file_entity",
),
Index("ix_entity_links_file", "tenant_id", "file_id"),
@@ -30,6 +33,4 @@ class EntityLink(Base, TenantMixin):
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
entity_type: Mapped[str] = mapped_column(String(20), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
@@ -42,6 +42,7 @@ class EntityLinksPlugin(BasePlugin):
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
"""Handle company.deleted event — remove all EntityLink rows for that company."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
@@ -51,6 +52,7 @@ class EntityLinksPlugin(BasePlugin):
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
@@ -65,6 +67,7 @@ class EntityLinksPlugin(BasePlugin):
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
@@ -74,6 +77,7 @@ class EntityLinksPlugin(BasePlugin):
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
+7 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -24,7 +24,9 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.post("/files/{file_id}/link")
@@ -41,7 +43,9 @@ async def link_file_to_entity(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Check if link already exists
existing = await db.execute(
+8 -11
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy import DateTime, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -18,7 +18,10 @@ class Permission(Base, TenantMixin):
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "user_id", "access_level",
"tenant_id",
"file_id",
"user_id",
"access_level",
name="uq_permissions_file_user_level",
),
Index("ix_permissions_file", "tenant_id", "file_id"),
@@ -30,9 +33,7 @@ class Permission(Base, TenantMixin):
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
group_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read")
@@ -51,10 +52,6 @@ class ShareLink(Base, TenantMixin):
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="download")
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
@@ -2,10 +2,6 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
+27 -16
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password, verify_password
@@ -28,11 +28,14 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
# ─── Permissions CRUD ───
@router.get("/files/{file_id}/permissions")
async def list_permissions(
file_id: str,
@@ -85,7 +88,9 @@ async def grant_permission(
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Permission already exists", "code": "duplicate"})
raise HTTPException(
409, detail={"detail": "Permission already exists", "code": "duplicate"}
)
perm = Permission(
tenant_id=tenant_id,
@@ -135,8 +140,13 @@ async def revoke_permission(
# ─── Permission Check Helper ───
async def check_user_file_permission(
db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID, user_id: uuid.UUID, required: str = "read"
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
required: str = "read",
) -> bool:
"""Check if user has required permission on a file."""
result = await db.execute(
@@ -158,6 +168,7 @@ async def check_user_file_permission(
# ─── Share Links ───
@router.post("/files/{file_id}/share-link")
async def create_share_link(
file_id: str,
@@ -219,6 +230,7 @@ async def revoke_share_link(
# ─── Public Share Access (NO AUTH) ───
@public_router.get("/share/{token}")
async def public_access(
token: str,
@@ -229,23 +241,20 @@ async def public_access(
Returns 410 Gone if link is expired.
Returns 403 if password is required but not provided/invalid.
"""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
result = await db.execute(select(ShareLink).where(ShareLink.token == token))
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Check password
if link.password_hash is not None:
# Password must be provided via query param or header — check query param
from fastapi import Request
# We need the request to get the password param
# For simplicity, return that password is required
raise HTTPException(
@@ -267,25 +276,27 @@ async def public_access_with_password(
db: AsyncSession = Depends(get_db),
):
"""Public share access with password verification — no auth required."""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
result = await db.execute(select(ShareLink).where(ShareLink.token == token))
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Verify password if required
if link.password_hash is not None:
if body.password is None:
raise HTTPException(401, detail={"detail": "Password required", "code": "password_required"})
raise HTTPException(
401, detail={"detail": "Password required", "code": "password_required"}
)
if not verify_password(body.password, link.password_hash):
raise HTTPException(403, detail={"detail": "Invalid password", "code": "invalid_password"})
raise HTTPException(
403, detail={"detail": "Invalid password", "code": "invalid_password"}
)
return {
"file_id": str(link.file_id),
+5 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -33,7 +33,10 @@ class TagAssignment(Base, TenantMixin):
__tablename__ = "tag_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id", "tag_id", "entity_type", "entity_id",
"tenant_id",
"tag_id",
"entity_type",
"entity_id",
name="uq_tag_assignments_entity",
),
Index("ix_tag_assignments_tag", "tenant_id", "tag_id"),
+41 -28
View File
@@ -5,18 +5,18 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, func, delete
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import (
TagCreate,
TagUpdate,
TagAssignRequest,
TagUnassignRequest,
TagBulkAssignRequest,
TagCreate,
TagUnassignRequest,
TagUpdate,
)
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
@@ -28,7 +28,9 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.get("")
@@ -107,9 +109,7 @@ async def update_tag(
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
@@ -122,7 +122,9 @@ async def update_tag(
select(Tag).where(Tag.tenant_id == tenant_id, Tag.name == data["name"])
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
raise HTTPException(
409, detail={"detail": "Tag name already exists", "code": "duplicate"}
)
tag.name = data["name"]
if "color" in data:
tag.color = data["color"]
@@ -148,12 +150,12 @@ async def assign_tag(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id)
)
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id))
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "tag_not_found"})
@@ -167,7 +169,13 @@ async def assign_tag(
)
)
if existing.scalar_one_or_none() is not None:
return {"id": str(tag_id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": True}
return {
"id": str(tag_id),
"tag_id": str(tag_id),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_assigned": True,
}
assignment = TagAssignment(
tenant_id=tenant_id,
@@ -177,7 +185,13 @@ async def assign_tag(
)
db.add(assignment)
await db.flush()
return {"id": str(assignment.id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": False}
return {
"id": str(assignment.id),
"tag_id": str(tag_id),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_assigned": False,
}
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT)
@@ -217,22 +231,21 @@ async def delete_tag(
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
# Cascade delete assignments
await db.execute(
delete(TagAssignment).where(TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id)
delete(TagAssignment).where(
TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id
)
)
await db.delete(tag)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/bulk-assign")
async def bulk_assign_tags(
body: TagBulkAssignRequest,
@@ -244,17 +257,19 @@ async def bulk_assign_tags(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
tag_ids = [_parse_uuid(tid, "tag_id") for tid in body.tag_ids]
# Verify all tags exist
for tid in tag_ids:
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
if result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"})
raise HTTPException(
404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"}
)
assigned = []
already = []
@@ -299,9 +314,7 @@ async def list_tag_entities(
tid = _parse_uuid(tag_id, "tag_id")
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
tag_result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
-2
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
+61 -15
View File
@@ -10,21 +10,35 @@ class PluginRouteDef(BaseModel):
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
router_attr: str = Field(
default="router", description="Attribute name of the APIRouter in the module"
)
class PluginManifest(BaseModel):
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
name: str = Field(
..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)"
)
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
display_name: str = Field(..., min_length=1, max_length=120)
description: str = Field(default="", max_length=500)
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
dependencies: list[str] = Field(
default_factory=list, description="Other plugin names this plugin depends on"
)
routes: list[PluginRouteDef] = Field(
default_factory=list, description="Route definitions to register on activation"
)
events: list[str] = Field(
default_factory=list, description="Event names this plugin listens to"
)
migrations: list[str] = Field(
default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)"
)
permissions: list[str] = Field(
default_factory=list, description="Required permissions for this plugin"
)
@field_validator("name")
@classmethod
@@ -46,15 +60,47 @@ class ManifestSchemaResponse(BaseModel):
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
fields={
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
"name": {
"type": "str",
"required": "true",
"description": "Unique plugin identifier (snake_case, max 80 chars)",
},
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
"display_name": {
"type": "str",
"required": "true",
"description": "Human-readable plugin name",
},
"description": {
"type": "str",
"required": "false",
"description": "Plugin description (max 500 chars)",
},
"dependencies": {
"type": "list[str]",
"required": "false",
"description": "Other plugin names required",
},
"routes": {
"type": "list[PluginRouteDef]",
"required": "false",
"description": "Route definitions to register",
},
"events": {
"type": "list[str]",
"required": "false",
"description": "Event names to listen to",
},
"migrations": {
"type": "list[str]",
"required": "false",
"description": "Migration file names (ordered)",
},
"permissions": {
"type": "list[str]",
"required": "false",
"description": "Required permissions",
},
},
example=PluginManifest(
name="example_plugin",
+7 -2
View File
@@ -6,7 +6,7 @@ import os
from pathlib import Path
from typing import Any
from sqlalchemy import text, inspect
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.models.plugin import PluginMigration
@@ -21,7 +21,9 @@ class MigrationRunner:
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
self._engine = engine
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
self._migrations_dir = Path(
migrations_dir or os.path.join(os.path.dirname(__file__), "migrations")
)
def _resolve_migration_path(self, filename: str, plugin_name: str | None = None) -> Path:
"""Resolve a migration filename to an absolute path.
@@ -213,6 +215,7 @@ class MigrationRunner:
async def _get_table_names(self) -> set[str]:
"""Get current table names from the database (separate connection)."""
def _get_names(sync_conn):
insp = inspect(sync_conn)
return set(insp.get_table_names())
@@ -222,6 +225,7 @@ class MigrationRunner:
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
"""Check if a table has a specific column (separate connection)."""
def _has_col(sync_conn):
insp = inspect(sync_conn)
if table_name not in insp.get_table_names():
@@ -289,6 +293,7 @@ class MigrationRunner:
def _extract_table_names(sql: str) -> list[str]:
"""Extract table names from CREATE TABLE statements in SQL."""
import re
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
return re.findall(pattern, sql, re.IGNORECASE)
+19 -15
View File
@@ -8,7 +8,7 @@ from typing import Any
from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.event_bus import EventBus, get_event_bus
from app.core.service_container import ServiceContainer, get_container
@@ -78,7 +78,8 @@ class PluginRegistry:
return discovered
import pkgutil
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
for _importer, modname, _ispkg in pkgutil.iter_modules(pkg_path):
if modname.startswith("_"):
continue
try:
@@ -144,9 +145,7 @@ class PluginRegistry:
# Run migrations
if plugin.manifest.migrations:
await self.migration_runner.run_all_migrations(
db, name, plugin.manifest.migrations
)
await self.migration_runner.run_all_migrations(db, name, plugin.manifest.migrations)
# Call on_install hook
await plugin.on_install(db, self._container)
@@ -234,7 +233,8 @@ class PluginRegistry:
paths_to_remove.add(route.path)
# Remove matching routes from app
self._app.router.routes = [
r for r in self._app.router.routes
r
for r in self._app.router.routes
if not (hasattr(r, "path") and r.path in paths_to_remove)
]
@@ -309,7 +309,8 @@ class PluginRegistry:
for name, plugin in self._plugins.items():
record = db_records.get(name)
if record is not None:
plugins_list.append({
plugins_list.append(
{
"name": name,
"display_name": record.display_name,
"version": record.version,
@@ -321,9 +322,11 @@ class PluginRegistry:
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
})
}
)
else:
plugins_list.append({
plugins_list.append(
{
"name": name,
"display_name": plugin.manifest.display_name,
"version": plugin.version,
@@ -335,12 +338,14 @@ class PluginRegistry:
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
})
}
)
# Also include DB-only records (plugins that were installed but no longer discovered)
for name, record in db_records.items():
if name not in self._plugins:
plugins_list.append({
plugins_list.append(
{
"name": name,
"display_name": record.display_name,
"version": record.version,
@@ -352,7 +357,8 @@ class PluginRegistry:
"events": [],
"migrations": [],
"permissions": [],
})
}
)
return plugins_list
@@ -360,9 +366,7 @@ class PluginRegistry:
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
"""Fetch a plugin record from DB by name."""
result = await db.execute(
select(PluginModel).where(PluginModel.name == name)
)
result = await db.execute(select(PluginModel).where(PluginModel.name == name))
return result.scalar_one_or_none()
+14 -1
View File
@@ -1,3 +1,16 @@
"""Routes package."""
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
from app.routes import (
ai_copilot, # noqa: F401
auth, # noqa: F401
companies, # noqa: F401
contacts, # noqa: F401
health, # noqa: F401
import_export, # noqa: F401
notifications, # noqa: F401
plugins, # noqa: F401
roles, # noqa: F401
tenants, # noqa: F401
users, # noqa: F401
workflows, # noqa: F401
)
+13 -6
View File
@@ -7,12 +7,11 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.schemas.ai_copilot import (
CopilotQueryRequest,
CopilotExecuteRequest,
CopilotQueryRequest,
)
from app.services import ai_copilot_service
@@ -34,7 +33,9 @@ async def copilot_query(
user_id = uuid.UUID(current_user["user_id"])
result = await ai_copilot_service.process_query(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
query=body.query,
conversation_id=body.conversation_id,
context=body.context,
@@ -65,7 +66,10 @@ async def copilot_execute(
role = current_user.get("role", "viewer")
result = await ai_copilot_service.execute_action(
db, tenant_id, user_id, role,
db,
tenant_id,
user_id,
role,
conversation_id=body.conversation_id,
action=body.action.model_dump(),
)
@@ -97,6 +101,9 @@ async def copilot_history(
user_id = uuid.UUID(current_user["user_id"])
return await ai_copilot_service.get_history(
db, tenant_id, user_id,
page=page, page_size=page_size,
db,
tenant_id,
user_id,
page=page,
page_size=page_size,
)
+10 -8
View File
@@ -3,19 +3,19 @@
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.core.auth import get_redis
from app.core.db import get_db
from app.deps import get_current_user
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.schemas.auth import (
LoginRequest, PasswordResetConfirm, PasswordResetRequest,
SwitchTenantRequest, AuthResponse,
LoginRequest,
PasswordResetConfirm,
PasswordResetRequest,
SwitchTenantRequest,
)
from app.services.auth_service import auth_service
@@ -64,6 +64,7 @@ async def login(
)
# Return auth info in body
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={
@@ -100,6 +101,7 @@ async def logout(
await auth_service.logout(redis, session_id)
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={"message": "Logged out"},
@@ -153,7 +155,7 @@ async def switch_tenant(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid tenant_id", "code": "invalid_tenant_id"},
)
) from None
result = await auth_service.switch_tenant(db, redis, session_id, new_tenant_id)
if result is None:
@@ -180,7 +182,7 @@ async def password_reset_request(
):
"""Request password reset. Always returns 200 (no user enumeration)."""
ip = get_client_ip(request)
redis = get_redis()
redis = get_redis() # noqa: F841
await check_rate_limit(
f"auth:reset:{ip}",
@@ -200,7 +202,7 @@ async def password_reset_confirm(
):
"""Reset password with a valid token."""
ip = get_client_ip(request)
redis = get_redis()
redis = get_redis() # noqa: F841
await check_rate_limit(
f"auth:reset_confirm:{ip}",
+38 -14
View File
@@ -30,10 +30,14 @@ async def list_companies(
"""List companies with pagination, FTS search, industry filter, and sorting."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await company_service.list_companies(
db, tenant_id,
page=page, page_size=page_size,
search=search, industry=industry,
sort_by=sort_by, sort_order=sort_order,
db,
tenant_id,
page=page,
page_size=page_size,
search=search,
industry=industry,
sort_by=sort_by,
sort_order=sort_order,
)
return result
@@ -72,7 +76,10 @@ async def export_companies(
if format == "csv":
csv_data = await company_service.export_companies_csv(
db, tenant_id, industry=industry, search=search,
db,
tenant_id,
industry=industry,
search=search,
)
return Response(
content=csv_data,
@@ -81,7 +88,10 @@ async def export_companies(
)
elif format == "xlsx":
xlsx_data = await company_service.export_companies_xlsx(
db, tenant_id, industry=industry, search=search,
db,
tenant_id,
industry=industry,
search=search,
)
return Response(
content=xlsx_data,
@@ -102,7 +112,9 @@ async def get_company(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
data = await company_service.get_company_detail(db, tenant_id, cid)
if data is None:
@@ -128,7 +140,9 @@ async def update_company(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
data = body.model_dump(exclude_unset=True)
result = await company_service.update_company(db, tenant_id, user_id, cid, data)
@@ -155,9 +169,13 @@ async def delete_company(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
deleted = await company_service.soft_delete_company(db, tenant_id, user_id, cid, cascade=cascade)
deleted = await company_service.soft_delete_company(
db, tenant_id, user_id, cid, cascade=cascade
)
if not deleted:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
@@ -183,11 +201,13 @@ async def link_company_contact(
comp_id = uuid.UUID(company_id)
cont_id = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
result = await company_service.link_contact(db, tenant_id, user_id, comp_id, cont_id)
if result is None:
raise HTTPException(404, detail={"detail": "Company or contact not found", "code": "not_found"})
raise HTTPException(
404, detail={"detail": "Company or contact not found", "code": "not_found"}
)
return result
@@ -210,7 +230,7 @@ async def unlink_company_contact(
comp_id = uuid.UUID(company_id)
cont_id = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
unlinked = await company_service.unlink_contact(db, tenant_id, user_id, comp_id, cont_id)
if not unlinked:
@@ -230,11 +250,15 @@ async def get_company_emails(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
# Verify company exists
from sqlalchemy import select
from app.models.company import Company
q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id,
+16 -6
View File
@@ -29,9 +29,13 @@ async def list_contacts(
"""List contacts with pagination and optional search."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await contact_service.list_contacts(
db, tenant_id,
page=page, page_size=page_size,
search=search, sort_by=sort_by, sort_order=sort_order,
db,
tenant_id,
page=page,
page_size=page_size,
search=search,
sort_by=sort_by,
sort_order=sort_order,
)
return result
@@ -68,7 +72,9 @@ async def get_contact(
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = await contact_service.get_contact_detail(db, tenant_id, cid)
if data is None:
@@ -94,7 +100,9 @@ async def update_contact(
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = body.model_dump(exclude_unset=True)
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
@@ -121,7 +129,9 @@ async def delete_contact(
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
if gdpr:
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
+20 -5
View File
@@ -3,16 +3,21 @@
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, status
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
UploadFile,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.services import import_export_service
from app.services import company_service
router = APIRouter(prefix="/api/v1", tags=["import_export"])
@@ -38,7 +43,12 @@ async def import_csv(
csv_content = content.decode("utf-8")
result = await import_export_service.import_csv(
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=False,
db,
tenant_id,
user_id,
csv_content,
entity_type=entity_type,
dry_run=False,
)
return result
@@ -62,6 +72,11 @@ async def import_csv_preview(
csv_content = content.decode("utf-8")
result = await import_export_service.import_csv(
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=True,
db,
tenant_id,
user_id,
csv_content,
entity_type=entity_type,
dry_run=True,
)
return result
+7 -3
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.notifications import (
get_unread_count, list_notifications, mark_notification_read,
get_unread_count,
list_notifications,
mark_notification_read,
)
from app.deps import get_current_user
@@ -41,7 +43,9 @@ async def mark_notification_read_endpoint(
try:
nid = uuid.UUID(notification_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid notification_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid notification_id", "code": "invalid_id"}
) from None
notif = await mark_notification_read(db, tenant_id, user_id, nid)
if notif is None:
+35 -15
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -44,20 +44,26 @@ async def install_plugin(
Idempotent: returns 200 if already installed.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.install_plugin(
db, name,
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(422, detail={"detail": str(exc), "code": "migration_validation_error"})
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
@router.post("/{name}/activate")
@@ -71,20 +77,26 @@ async def activate_plugin(
Idempotent: returns 200 if already active.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.activate_plugin(
db, name,
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not installed" in str(exc).lower():
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_not_installed"})
raise HTTPException(
400, detail={"detail": str(exc), "code": "plugin_not_installed"}
) from None
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
@router.post("/{name}/deactivate")
@@ -98,18 +110,22 @@ async def deactivate_plugin(
Idempotent: returns 200 if already inactive.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.deactivate_plugin(
db, name,
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
@router.delete("/{name}")
@@ -124,10 +140,12 @@ async def uninstall_plugin(
Returns 200 with uninstalled status. Idempotent for already-uninstalled plugins.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.uninstall_plugin(
db, name,
db,
name,
remove_data=remove_data,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
@@ -135,5 +153,7 @@ async def uninstall_plugin(
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
+18 -6
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import Response
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,7 +36,11 @@ async def create_role(
"""Create a custom role (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
role = await role_service.create_role(
db, tenant_id, body.name, body.permissions, body.field_permissions,
db,
tenant_id,
body.name,
body.permissions,
body.field_permissions,
)
return JSONResponse(
status_code=status.HTTP_201_CREATED,
@@ -62,10 +65,17 @@ async def update_role(
try:
rid = uuid.UUID(role_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid role_id", "code": "invalid_id"}
) from None
role = await role_service.update_role(
db, tenant_id, rid, body.name, body.permissions, body.field_permissions,
db,
tenant_id,
rid,
body.name,
body.permissions,
body.field_permissions,
)
if role is None:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
@@ -89,7 +99,9 @@ async def delete_role(
try:
rid = uuid.UUID(role_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid role_id", "code": "invalid_id"}
) from None
success = await role_service.delete_role(db, tenant_id, rid)
if not success:
+6 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -51,7 +51,9 @@ async def list_tenant_users(
try:
tid = uuid.UUID(tenant_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"}
) from None
users = await tenant_service.list_tenant_users(db, tid)
return {"items": users}
@@ -69,7 +71,7 @@ async def assign_user_to_tenant(
tid = uuid.UUID(tenant_id)
uid = uuid.UUID(body.user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
ut = await tenant_service.assign_user_to_tenant(db, tid, uid)
await tenant_service.assign_user_to_tenant(db, tid, uid)
return {"message": "User assigned to tenant"}
+40 -11
View File
@@ -5,14 +5,13 @@ from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.notifications import create_notification
from app.deps import get_current_user, require_admin, get_tenant_id, get_current_user_id
from app.deps import get_current_user, require_admin
from app.schemas.user import UserCreate, UserUpdate
from app.services.user_service import user_service
@@ -43,18 +42,32 @@ async def create_user(
user_id = uuid.UUID(current_user["user_id"])
user = await user_service.create_user(
db, tenant_id, body.email, body.name, body.password, body.role, body.is_active,
db,
tenant_id,
body.email,
body.name,
body.password,
body.role,
body.is_active,
)
# Audit log
await log_audit(
db, tenant_id, user_id, "create", "user", user.id,
db,
tenant_id,
user_id,
"create",
"user",
user.id,
changes={"email": body.email, "name": body.name, "role": body.role},
)
# Notification
await create_notification(
db, tenant_id, user.id, "info",
db,
tenant_id,
user.id,
"info",
"Account created",
f"Your account has been created by {current_user['name']}.",
)
@@ -80,7 +93,9 @@ async def get_user(
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
@@ -109,7 +124,9 @@ async def update_user(
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
changes: dict[str, Any] = {}
if body.name is not None:
@@ -120,7 +137,12 @@ async def update_user(
changes["is_active"] = body.is_active
user = await user_service.update_user(
db, tenant_id, uid, body.name, body.role, body.is_active,
db,
tenant_id,
uid,
body.name,
body.role,
body.is_active,
)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
@@ -149,7 +171,9 @@ async def delete_user(
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
# Get user snapshot for audit before deletion
user = await user_service.get_user(db, tenant_id, uid)
@@ -161,7 +185,12 @@ async def delete_user(
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(
db, tenant_id, acting_user_id, "delete", "user", uid,
db,
tenant_id,
acting_user_id,
"delete",
"user",
uid,
changes={"email": user.email, "name": user.name},
)
+20 -8
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.schemas.workflow import WorkflowCreate, WorkflowUpdate, InstanceCreate, AdvanceRequest
from app.schemas.workflow import AdvanceRequest, InstanceCreate, WorkflowCreate, WorkflowUpdate
from app.services import workflow_service
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
# ─── Workflow CRUD ───
@router.get("")
async def list_workflows(
page: int = Query(1, ge=1),
@@ -29,8 +30,10 @@ async def list_workflows(
"""List workflows with pagination."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await workflow_service.list_workflows(
db, tenant_id,
page=page, page_size=page_size,
db,
tenant_id,
page=page,
page_size=page_size,
is_active=is_active,
)
@@ -67,8 +70,10 @@ async def list_instances(
"""List workflow instances with optional status filter."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await workflow_service.list_instances(
db, tenant_id,
page=page, page_size=page_size,
db,
tenant_id,
page=page,
page_size=page_size,
status_filter=status,
)
@@ -146,6 +151,7 @@ async def delete_workflow(
# ─── Instance endpoints ───
@router.post("/{workflow_id}/instances", status_code=status.HTTP_201_CREATED)
async def create_instance(
workflow_id: str,
@@ -158,7 +164,9 @@ async def create_instance(
user_id = uuid.UUID(current_user["user_id"])
result = await workflow_service.create_instance(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
workflow_id=workflow_id,
context=body.context,
timeout_hours=body.timeout_hours,
@@ -204,7 +212,9 @@ async def advance_instance(
user_id = uuid.UUID(current_user["user_id"])
result = await workflow_service.advance_instance(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
instance_id=instance_id,
decision=body.decision,
comment=body.comment,
@@ -233,7 +243,9 @@ async def cancel_instance(
user_id = uuid.UUID(current_user["user_id"])
result = await workflow_service.cancel_instance(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
instance_id=instance_id,
)
if result is None:
+24 -7
View File
@@ -1,13 +1,30 @@
"""Pydantic schemas package."""
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
from app.schemas.ai_copilot import (
CopilotQueryRequest, CopilotAction, CopilotQueryResponse,
CopilotExecuteRequest, CopilotExecuteResponse,
CopilotHistoryResponse, CopilotMessageResponse,
CopilotAction, # noqa: F401
CopilotExecuteRequest, # noqa: F401
CopilotExecuteResponse, # noqa: F401
CopilotHistoryResponse, # noqa: F401
CopilotMessageResponse, # noqa: F401
CopilotQueryRequest, # noqa: F401
CopilotQueryResponse, # noqa: F401
)
from app.schemas.plugin import (
PluginActionResponse, # noqa: F401
PluginInfo, # noqa: F401
PluginListResponse, # noqa: F401
PluginUninstallResponse, # noqa: F401
)
from app.schemas.workflow import (
WorkflowCreate, WorkflowUpdate, WorkflowResponse, WorkflowListResponse,
InstanceCreate, InstanceResponse, InstanceDetailResponse, InstanceListResponse,
AdvanceRequest, StepHistoryResponse, WorkflowStep,
AdvanceRequest, # noqa: F401
InstanceCreate, # noqa: F401
InstanceDetailResponse, # noqa: F401
InstanceListResponse, # noqa: F401
InstanceResponse, # noqa: F401
StepHistoryResponse, # noqa: F401
WorkflowCreate, # noqa: F401
WorkflowListResponse, # noqa: F401
WorkflowResponse, # noqa: F401
WorkflowStep, # noqa: F401
WorkflowUpdate, # noqa: F401
)
+7
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
class CopilotQueryRequest(BaseModel):
"""Natural language query to the AI copilot."""
query: str = Field(..., min_length=1, max_length=2000)
conversation_id: str | None = None
context: dict = Field(default_factory=dict)
@@ -14,6 +15,7 @@ class CopilotQueryRequest(BaseModel):
class CopilotAction(BaseModel):
"""A proposed API action derived from NL input."""
method: str = Field(..., pattern="^(GET|POST|PATCH|DELETE)$")
path: str = Field(..., min_length=1)
body: dict | None = None
@@ -23,6 +25,7 @@ class CopilotAction(BaseModel):
class CopilotQueryResponse(BaseModel):
"""Response from copilot query — proposed actions for user confirmation."""
conversation_id: str
message: str
proposed_actions: list[CopilotAction] = Field(default_factory=list)
@@ -30,12 +33,14 @@ class CopilotQueryResponse(BaseModel):
class CopilotExecuteRequest(BaseModel):
"""Execute a proposed action after user confirmation."""
conversation_id: str
action: CopilotAction
class CopilotExecuteResponse(BaseModel):
"""Result of executing a proposed action."""
conversation_id: str
success: bool
status_code: int
@@ -45,6 +50,7 @@ class CopilotExecuteResponse(BaseModel):
class CopilotMessageResponse(BaseModel):
"""A single message in conversation history."""
id: str
role: str
content: str
@@ -56,6 +62,7 @@ class CopilotMessageResponse(BaseModel):
class CopilotHistoryResponse(BaseModel):
"""Paginated conversation history."""
items: list[CopilotMessageResponse]
total: int
page: int
+4 -1
View File
@@ -37,7 +37,9 @@ class PluginActionResponse(BaseModel):
status: str
installed: bool = False
active: bool = False
dropped_tables: list[str] = Field(default_factory=list, description="Tables dropped (uninstall with remove_data=true)")
dropped_tables: list[str] = Field(
default_factory=list, description="Tables dropped (uninstall with remove_data=true)"
)
message: str = ""
@@ -49,6 +51,7 @@ class PluginUninstallResponse(PluginActionResponse):
class ManifestFieldDoc(BaseModel):
"""Documentation for a single manifest field."""
type: str
required: str
description: str
-2
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr, Field
+11
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
class WorkflowStep(BaseModel):
"""A single step in a workflow definition."""
name: str = Field(..., min_length=1, max_length=200)
type: str = Field(..., pattern="^(action|approval|notification|condition)$")
config: dict = Field(default_factory=dict)
@@ -15,6 +16,7 @@ class WorkflowStep(BaseModel):
class WorkflowCreate(BaseModel):
"""Create a new workflow definition."""
name: str = Field(..., min_length=1, max_length=200)
description: str | None = None
trigger_event: str | None = None
@@ -24,6 +26,7 @@ class WorkflowCreate(BaseModel):
class WorkflowUpdate(BaseModel):
"""Update an existing workflow definition."""
name: str | None = Field(None, max_length=200)
description: str | None = None
trigger_event: str | None = None
@@ -33,6 +36,7 @@ class WorkflowUpdate(BaseModel):
class WorkflowResponse(BaseModel):
"""Workflow definition response."""
id: str
name: str
description: str | None = None
@@ -46,6 +50,7 @@ class WorkflowResponse(BaseModel):
class WorkflowListResponse(BaseModel):
"""Paginated workflow list."""
items: list[WorkflowResponse]
total: int
page: int
@@ -54,12 +59,14 @@ class WorkflowListResponse(BaseModel):
class InstanceCreate(BaseModel):
"""Create a new workflow instance."""
context: dict = Field(default_factory=dict)
timeout_hours: int | None = None
class InstanceResponse(BaseModel):
"""Workflow instance response with current state and history."""
id: str
workflow_id: str
status: str
@@ -75,12 +82,14 @@ class InstanceResponse(BaseModel):
class InstanceDetailResponse(InstanceResponse):
"""Instance with step history."""
history: list[dict] = Field(default_factory=list)
workflow_name: str | None = None
class InstanceListResponse(BaseModel):
"""Paginated instance list."""
items: list[InstanceResponse]
total: int
page: int
@@ -89,12 +98,14 @@ class InstanceListResponse(BaseModel):
class AdvanceRequest(BaseModel):
"""Advance or reject a workflow instance step."""
decision: str = Field(..., pattern="^(approve|reject)$")
comment: str | None = None
class StepHistoryResponse(BaseModel):
"""Step history entry."""
id: str
instance_id: str
step_index: int
+22 -10
View File
@@ -1,16 +1,28 @@
"""Service layer package."""
from app.services.plugin_service import PluginService, get_plugin_service
from app.services.ai_copilot_service import (
process_query as copilot_process_query,
execute_action as copilot_execute_action,
get_history as copilot_get_history,
execute_action as copilot_execute_action, # noqa: F401
)
from app.services.ai_copilot_service import (
get_history as copilot_get_history, # noqa: F401
)
from app.services.ai_copilot_service import (
process_query as copilot_process_query, # noqa: F401
)
from app.services.plugin_service import PluginService, get_plugin_service # noqa: F401
from app.services.workflow_service import (
create_workflow, list_workflows, get_workflow,
update_workflow, delete_workflow,
create_instance, list_instances, get_instance,
advance_instance, cancel_instance,
check_timeout, auto_reject_timeout,
find_workflows_for_event, start_instance_for_event,
advance_instance, # noqa: F401
auto_reject_timeout, # noqa: F401
cancel_instance, # noqa: F401
check_timeout, # noqa: F401
create_instance, # noqa: F401
create_workflow, # noqa: F401
delete_workflow, # noqa: F401
find_workflows_for_event, # noqa: F401
get_instance, # noqa: F401
get_workflow, # noqa: F401
list_instances, # noqa: F401
list_workflows, # noqa: F401
start_instance_for_event, # noqa: F401
update_workflow, # noqa: F401
)
+27 -19
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, desc, and_
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission, filter_fields_by_permission
from app.core.auth import check_permission
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.company import Company
from app.models.contact import Contact
@@ -136,7 +136,9 @@ async def process_query(
# Log to audit
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="query",
entity_type="ai_copilot",
entity_id=conversation.id,
@@ -222,7 +224,9 @@ async def execute_action(
# Log to audit
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="execute",
entity_type="ai_copilot",
entity_id=conversation.id,
@@ -266,17 +270,23 @@ async def get_history(
items: list[dict[str, Any]] = []
for conv in conversations:
# Get messages for each conversation
msg_q = select(AIMessage).where(
msg_q = (
select(AIMessage)
.where(
AIMessage.conversation_id == conv.id,
AIMessage.tenant_id == tenant_id,
).order_by(AIMessage.message_index)
)
.order_by(AIMessage.message_index)
)
msg_result = await db.execute(msg_q)
messages = msg_result.scalars().all()
items.append({
items.append(
{
**_conversation_to_dict(conv),
"messages": [_message_to_dict(m) for m in messages],
})
}
)
return {
"items": items,
@@ -333,10 +343,7 @@ async def _exec_companies(
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(c.id), "name": c.name, "industry": c.industry}
for c in companies
],
"data": [{"id": str(c.id), "name": c.name, "industry": c.industry} for c in companies],
}
elif method == "POST":
@@ -372,9 +379,13 @@ async def _exec_companies(
company = result.scalar_one_or_none()
if company is None:
return {"error": "Company not found", "status_code": 404, "success": False}
company.deleted_at = datetime.now(timezone.utc)
company.deleted_at = datetime.now(UTC)
await db.flush()
return {"success": True, "status_code": 200, "data": {"id": str(company.id), "deleted": True}}
return {
"success": True,
"status_code": 200,
"data": {"id": str(company.id), "deleted": True},
}
elif method == "PATCH":
if not entity_id or entity_id == "{id}":
@@ -424,10 +435,7 @@ async def _exec_contacts(
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(c.id), "name": c.name, "email": c.email}
for c in contacts
],
"data": [{"id": str(c.id), "name": c.name, "email": c.email} for c in contacts],
}
elif method == "POST":
+29 -17
View File
@@ -2,9 +2,8 @@
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
import redis.asyncio as aioredis
@@ -12,14 +11,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import (
create_session, get_session_data, hash_password, hash_token,
invalidate_session, update_session_tenant, verify_password,
)
from app.core.audit import log_audit
from app.core.auth import (
create_session,
get_session_data,
hash_password,
hash_token,
invalidate_session,
update_session_tenant,
verify_password,
)
from app.models.auth import PasswordResetToken
from app.models.user import User, UserTenant
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
class AuthService:
@@ -37,7 +41,7 @@ class AuthService:
Returns (session_id, csrf_token, user, tenant) or None.
"""
# Find user by email — need to check across tenants or use default tenant
q = select(User).where(User.email == email, User.is_active == True)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
@@ -53,7 +57,7 @@ class AuthService:
Tenant.slug == tenant_slug
)
else:
ut_q = ut_q.where(UserTenant.is_default == True)
ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
@@ -75,7 +79,12 @@ class AuthService:
# Log the login in audit trail
await log_audit(
db, tenant.id, user.id, "login", "user", user.id,
db,
tenant.id,
user.id,
"login",
"user",
user.id,
changes={"email": email},
)
@@ -153,7 +162,7 @@ class AuthService:
tenant_id: uuid.UUID | None = None,
) -> bool:
"""Create a password reset token. Always returns True (no user enumeration)."""
q = select(User).where(User.email == email, User.is_active == True)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
@@ -166,14 +175,15 @@ class AuthService:
)
prev_result = await db.execute(prev_q)
for prev_token in prev_result.scalars().all():
prev_token.used_at = datetime.now(timezone.utc)
prev_token.used_at = datetime.now(UTC)
# Create new token
import secrets
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
@@ -206,7 +216,7 @@ class AuthService:
if reset_token is None:
return False
if reset_token.expires_at < datetime.now(timezone.utc):
if reset_token.expires_at < datetime.now(UTC):
return False # Token expired
# Get user
@@ -218,7 +228,7 @@ class AuthService:
# Update password
user.password_hash = hash_password(new_password)
reset_token.used_at = datetime.now(timezone.utc)
reset_token.used_at = datetime.now(UTC)
await db.flush()
return True
@@ -229,6 +239,7 @@ class AuthService:
"""
# This is a test helper — in production the token goes via email only
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -238,7 +249,7 @@ class AuthService:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
@@ -253,6 +264,7 @@ class AuthService:
async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None:
"""Create an already-expired reset token for testing."""
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -261,7 +273,7 @@ class AuthService:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Already expired
expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
+79 -31
View File
@@ -5,15 +5,15 @@ from __future__ import annotations
import csv
import io
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, or_, desc, asc, delete
from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.core.audit import log_audit
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.contact import CompanyContact, Contact
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
@@ -66,9 +66,7 @@ async def list_companies(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -131,7 +129,8 @@ async def get_company_detail(
contacts_result = await db.execute(contacts_q)
contacts_list = []
for contact, link in contacts_result.all():
contacts_list.append({
contacts_list.append(
{
"id": str(contact.id),
"first_name": contact.first_name,
"last_name": contact.last_name,
@@ -140,7 +139,8 @@ async def get_company_detail(
"position": contact.position,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
})
}
)
data["contacts"] = contacts_list
return data
@@ -168,7 +168,12 @@ async def create_company(
await db.flush()
await db.refresh(company)
await log_audit(
db, tenant_id, user_id, "create", "company", company.id,
db,
tenant_id,
user_id,
"create",
"company",
company.id,
changes={"name": data["name"]},
)
return _company_to_dict(company)
@@ -224,7 +229,7 @@ async def soft_delete_company(
if company is None:
return False
company.deleted_at = datetime.now(timezone.utc)
company.deleted_at = datetime.now(UTC)
company.updated_by = user_id
if cascade:
@@ -238,7 +243,12 @@ async def soft_delete_company(
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "company", company_id,
db,
tenant_id,
user_id,
"delete",
"company",
company_id,
changes={"name": company.name, "cascade": cascade},
)
return True
@@ -288,7 +298,12 @@ async def link_contact(
existing.is_primary = is_primary
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", existing.id,
db,
tenant_id,
user_id,
"link",
"company_contact",
existing.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
@@ -309,7 +324,12 @@ async def link_contact(
db.add(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", link.id,
db,
tenant_id,
user_id,
"link",
"company_contact",
link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
@@ -342,7 +362,12 @@ async def unlink_contact(
await db.delete(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "unlink", "company_contact", link.id,
db,
tenant_id,
user_id,
"unlink",
"company_contact",
link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return True
@@ -365,9 +390,7 @@ async def export_companies_csv(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -379,12 +402,22 @@ async def export_companies_csv(
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "name", "account_number", "industry", "phone", "email", "website", "description"])
writer.writerow(
["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
)
for c in companies:
writer.writerow([
str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or "",
])
writer.writerow(
[
str(c.id),
c.name,
c.account_number or "",
c.industry or "",
c.phone or "",
c.email or "",
c.website or "",
c.description or "",
]
)
return output.getvalue()
@@ -407,9 +440,7 @@ async def export_companies_xlsx(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -422,13 +453,30 @@ async def export_companies_xlsx(
wb = Workbook()
ws = wb.active
ws.title = "Companies"
headers = ["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
headers = [
"id",
"name",
"account_number",
"industry",
"phone",
"email",
"website",
"description",
]
ws.append(headers)
for c in companies:
ws.append([
str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or "",
])
ws.append(
[
str(c.id),
c.name,
c.account_number or "",
c.industry or "",
c.phone or "",
c.email or "",
c.website or "",
c.description or "",
]
)
buf = io.BytesIO()
wb.save(buf)
+42 -11
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, desc, asc, delete
from sqlalchemy import asc, delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.models.contact import Contact, CompanyContact
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
@@ -113,13 +113,15 @@ async def get_contact_detail(
companies_result = await db.execute(companies_q)
companies_list = []
for company, link in companies_result.all():
companies_list.append({
companies_list.append(
{
"id": str(company.id),
"name": company.name,
"industry": company.industry,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
})
}
)
data["companies"] = companies_list
return data
@@ -177,8 +179,17 @@ async def create_contact(
await db.refresh(contact)
await log_audit(
db, tenant_id, user_id, "create", "contact", contact.id,
changes={"first_name": data["first_name"], "last_name": data["last_name"], "linked_companies": linked_companies},
db,
tenant_id,
user_id,
"create",
"contact",
contact.id,
changes={
"first_name": data["first_name"],
"last_name": data["last_name"],
"linked_companies": linked_companies,
},
)
return _contact_to_dict(contact)
@@ -202,7 +213,17 @@ async def update_contact(
return None
changes: dict[str, Any] = {}
for field in ("first_name", "last_name", "email", "phone", "mobile", "position", "department", "linkedin_url", "notes"):
for field in (
"first_name",
"last_name",
"email",
"phone",
"mobile",
"position",
"department",
"linkedin_url",
"notes",
):
if field in data and data[field] is not None:
old_val = getattr(contact, field)
changes[field] = {"old": old_val, "new": data[field]}
@@ -232,12 +253,17 @@ async def soft_delete_contact(
if contact is None:
return False
contact.deleted_at = datetime.now(timezone.utc)
contact.deleted_at = datetime.now(UTC)
contact.updated_by = user_id
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "contact", contact_id,
db,
tenant_id,
user_id,
"delete",
"contact",
contact_id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
return True
@@ -276,6 +302,11 @@ async def gdpr_hard_delete_contact(
# Deletion log (immutable snapshot)
await log_deletion(
db, tenant_id, user_id, "contact", contact_id, snapshot,
db,
tenant_id,
user_id,
"contact",
contact_id,
snapshot,
)
return True
+33 -10
View File
@@ -16,7 +16,6 @@ from app.models.contact import Contact
from app.services.company_service import _company_to_dict
from app.services.contact_service import _contact_to_dict
# Expected CSV columns for each entity type
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"]
@@ -88,7 +87,12 @@ async def import_companies(
db.add(company)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "company", company.id,
db,
tenant_id,
user_id,
"import",
"company",
company.id,
changes={"name": company.name},
)
created.append(_company_to_dict(company))
@@ -154,7 +158,12 @@ async def import_contacts(
db.add(contact)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "contact", contact.id,
db,
tenant_id,
user_id,
"import",
"contact",
contact.id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
created.append(_contact_to_dict(contact))
@@ -198,19 +207,33 @@ async def export_contacts_csv(
tenant_id: uuid.UUID,
) -> str:
"""Export contacts as CSV string."""
q = select(Contact).where(
q = (
select(Contact)
.where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
).order_by(Contact.last_name, Contact.first_name)
)
.order_by(Contact.last_name, Contact.first_name)
)
result = await db.execute(q)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"])
writer.writerow(
["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"]
)
for c in contacts:
writer.writerow([
str(c.id), c.first_name, c.last_name, c.email or "",
c.phone or "", c.mobile or "", c.position or "", c.department or "",
])
writer.writerow(
[
str(c.id),
c.first_name,
c.last_name,
c.email or "",
c.phone or "",
c.mobile or "",
c.position or "",
c.department or "",
]
)
return output.getvalue()
+30 -13
View File
@@ -9,8 +9,8 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.plugins.registry import PluginRegistry, get_registry
from app.plugins.migration_runner import MigrationValidationError
from app.plugins.registry import PluginRegistry, get_registry
logger = logging.getLogger(__name__)
@@ -48,7 +48,9 @@ class PluginService:
record = await self._registry.install(db, name)
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.install",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -64,9 +66,9 @@ class PluginService:
"message": "Plugin installed successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
except MigrationValidationError as exc:
raise MigrationValidationError(str(exc))
raise MigrationValidationError(str(exc)) from None
async def activate_plugin(
self,
@@ -84,7 +86,9 @@ class PluginService:
was_already_active = record.active and record.status == "active"
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.activate",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -97,10 +101,12 @@ class PluginService:
"status": record.status,
"installed": record.installed,
"active": record.active,
"message": "Plugin is already active" if was_already_active else "Plugin activated successfully",
"message": "Plugin is already active"
if was_already_active
else "Plugin activated successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
async def deactivate_plugin(
self,
@@ -118,7 +124,9 @@ class PluginService:
was_already_inactive = not record.active and record.status == "inactive"
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.deactivate",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -131,10 +139,12 @@ class PluginService:
"status": record.status,
"installed": record.installed,
"active": record.active,
"message": "Plugin is already inactive" if was_already_inactive else "Plugin deactivated successfully",
"message": "Plugin is already inactive"
if was_already_inactive
else "Plugin deactivated successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
async def uninstall_plugin(
self,
@@ -153,10 +163,16 @@ class PluginService:
dropped_tables = getattr(record, "dropped_tables", [])
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.uninstall",
entity_type="plugin",
changes={"name": name, "remove_data": remove_data, "dropped_tables": dropped_tables},
changes={
"name": name,
"remove_data": remove_data,
"dropped_tables": dropped_tables,
},
)
return {
"name": name,
@@ -169,11 +185,12 @@ class PluginService:
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
def get_manifest_schema(self) -> dict[str, Any]:
"""Return the manifest schema documentation."""
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
return MANIFEST_SCHEMA_DOC.model_dump()
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select, func, or_
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password
+90 -44
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select, func, desc, and_, or_
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.notification import Notification
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
def _safe_iso(dt) -> str | None:
@@ -47,7 +47,12 @@ def _workflow_to_dict(w: Workflow) -> dict[str, Any]:
}
def _instance_to_dict(i: WorkflowInstance, include_history: bool = False, history: list | None = None, workflow_name: str | None = None) -> dict[str, Any]:
def _instance_to_dict(
i: WorkflowInstance,
include_history: bool = False,
history: list | None = None,
workflow_name: str | None = None,
) -> dict[str, Any]:
data = {
"id": str(i.id),
"workflow_id": str(i.workflow_id),
@@ -107,6 +112,7 @@ async def _log_step_history(
# ─── Workflow CRUD ───
async def create_workflow(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -134,7 +140,9 @@ async def create_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="create",
entity_type="workflow",
entity_id=workflow.id,
@@ -229,7 +237,9 @@ async def update_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="update",
entity_type="workflow",
entity_id=workflow.id,
@@ -261,7 +271,9 @@ async def delete_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="delete",
entity_type="workflow",
entity_id=wf_uuid,
@@ -272,6 +284,7 @@ async def delete_workflow(
# ─── Instance Lifecycle ───
async def create_instance(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -294,7 +307,7 @@ async def create_instance(
timeout_at = None
if timeout_hours:
timeout_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours)
timeout_at = datetime.now(UTC) + timedelta(hours=timeout_hours)
instance = WorkflowInstance(
tenant_id=tenant_id,
@@ -314,7 +327,9 @@ async def create_instance(
steps = workflow.steps or []
first_step = steps[0] if steps else {}
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=0,
step_type=first_step.get("type", "action"),
action="entered",
@@ -323,7 +338,9 @@ async def create_instance(
)
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="create",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -383,16 +400,18 @@ async def get_instance(
return None
# Get workflow name
wf_result = await db.execute(
select(Workflow.name).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow.name).where(Workflow.id == instance.workflow_id))
workflow_name = wf_result.scalar_one_or_none()
# Get step history
hist_q = select(WorkflowStepHistory).where(
hist_q = (
select(WorkflowStepHistory)
.where(
WorkflowStepHistory.instance_id == inst_uuid,
WorkflowStepHistory.tenant_id == tenant_id,
).order_by(WorkflowStepHistory.created_at)
)
.order_by(WorkflowStepHistory.created_at)
)
hist_result = await db.execute(hist_q)
history = hist_result.scalars().all()
@@ -430,12 +449,13 @@ async def advance_instance(
return None
if instance.status not in ("pending", "in_progress"):
return {"error": f"Cannot advance instance with status {instance.status}", "status_code": 400}
return {
"error": f"Cannot advance instance with status {instance.status}",
"status_code": 400,
}
# Get workflow definition
wf_result = await db.execute(
select(Workflow).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
if workflow is None:
return {"error": "Workflow definition not found", "status_code": 404}
@@ -447,9 +467,11 @@ async def advance_instance(
if decision == "reject":
instance.status = "rejected"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=current_idx,
step_type=step_type,
action="rejected",
@@ -470,7 +492,9 @@ async def advance_instance(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="reject",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -486,7 +510,9 @@ async def advance_instance(
# Log approval
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=current_idx,
step_type=step_type,
action="approved",
@@ -498,9 +524,11 @@ async def advance_instance(
if next_idx >= len(steps):
# Workflow complete
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=current_idx,
step_type="complete",
action="completed",
@@ -510,7 +538,9 @@ async def advance_instance(
instance.current_step_index = next_idx
next_step = steps[next_idx]
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=next_idx,
step_type=next_step.get("type", "action"),
action="entered",
@@ -520,11 +550,17 @@ async def advance_instance(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="advance",
entity_type="workflow_instance",
entity_id=instance.id,
changes={"decision": decision, "step": current_idx, "next_step": next_idx if next_idx < len(steps) else None},
changes={
"decision": decision,
"step": current_idx,
"next_step": next_idx if next_idx < len(steps) else None,
},
)
return _instance_to_dict(instance)
@@ -549,21 +585,26 @@ async def cancel_instance(
return None
if instance.status in ("completed", "rejected", "cancelled"):
return {"error": f"Cannot cancel instance with status {instance.status}", "status_code": 400}
return {
"error": f"Cannot cancel instance with status {instance.status}",
"status_code": 400,
}
instance.status = "cancelled"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
# Get current step for history
wf_result = await db.execute(
select(Workflow).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
steps = workflow.steps if workflow else []
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
current_step = (
steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=current_step.get("type", "action"),
action="cancelled",
@@ -573,7 +614,9 @@ async def cancel_instance(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="cancel",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -591,7 +634,7 @@ async def check_timeout(instance: WorkflowInstance) -> bool:
return False
if instance.status not in ("pending", "in_progress"):
return False
return datetime.now(timezone.utc) > instance.timeout_at
return datetime.now(UTC) > instance.timeout_at
async def auto_reject_timeout(
@@ -601,17 +644,19 @@ async def auto_reject_timeout(
) -> dict[str, Any]:
"""Auto-reject a timed-out instance."""
instance.status = "rejected"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
wf_result = await db.execute(
select(Workflow).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
steps = workflow.steps if workflow else []
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
current_step = (
steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=current_step.get("type", "approval"),
action="auto_rejected",
@@ -665,7 +710,8 @@ async def start_instance_for_event(
instances: list[dict[str, Any]] = []
for wf in workflows:
inst = await create_instance(
db, tenant_id,
db,
tenant_id,
user_id or uuid.uuid4(),
str(wf.id),
context=context,
+9 -3
View File
@@ -67,9 +67,10 @@ async def ensure_onboarding_workflow_exists(
If it doesn't exist yet, create it. Returns the workflow ID.
"""
from app.models.workflow import Workflow
from sqlalchemy import select
from app.models.workflow import Workflow
result = await db.execute(
select(Workflow).where(
Workflow.tenant_id == tenant_id,
@@ -82,8 +83,11 @@ async def ensure_onboarding_workflow_exists(
return existing.id
from app.services.workflow_service import create_workflow
wf_dict = await create_workflow(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
get_onboarding_workflow_definition(),
)
return uuid.UUID(wf_dict["id"]) if wf_dict else None
@@ -105,7 +109,9 @@ async def trigger_onboarding(
return None
instance = await create_instance(
db, tenant_id, admin_user_id,
db,
tenant_id,
admin_user_id,
str(wf_id),
context={"new_user_id": str(new_user_id), "user_id": str(new_user_id)},
)
+38 -20
View File
@@ -7,20 +7,20 @@ Integrates with the event bus for event-triggered workflows.
from __future__ import annotations
import uuid
import logging
from datetime import datetime, timezone
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.notification import Notification
from app.models.workflow import Workflow, WorkflowInstance
from app.services.workflow_service import (
_log_step_history,
_instance_to_dict,
_log_step_history,
create_instance,
find_workflows_for_event,
)
@@ -59,7 +59,7 @@ class WorkflowEngine:
steps = workflow.steps or []
if instance.current_step_index >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
await self.db.flush()
return _instance_to_dict(instance)
@@ -68,7 +68,9 @@ class WorkflowEngine:
# Log step entry
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="processing",
@@ -103,7 +105,9 @@ class WorkflowEngine:
# Execute action based on type
if action_type == "create_notification":
user_id = config.get("user_id") or (str(instance.initiated_by) if instance.initiated_by else None)
user_id = config.get("user_id") or (
str(instance.initiated_by) if instance.initiated_by else None
)
if user_id:
notification = Notification(
tenant_id=self.tenant_id,
@@ -120,7 +124,9 @@ class WorkflowEngine:
# Log action executed
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type="action",
action="executed",
@@ -131,7 +137,7 @@ class WorkflowEngine:
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
instance.status = "in_progress"
@@ -139,12 +145,12 @@ class WorkflowEngine:
await self.db.flush()
return _instance_to_dict(instance)
async def _process_notification(
self, instance: WorkflowInstance, step: dict
) -> dict[str, Any]:
async def _process_notification(self, instance: WorkflowInstance, step: dict) -> dict[str, Any]:
"""Process a notification step — sends notification and advances."""
config = step.get("config", {})
user_id = config.get("user_id") or (str(instance.initiated_by) if instance.initiated_by else None)
user_id = config.get("user_id") or (
str(instance.initiated_by) if instance.initiated_by else None
)
if user_id:
notification = Notification(
@@ -158,7 +164,9 @@ class WorkflowEngine:
await self.db.flush()
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type="notification",
action="sent",
@@ -174,7 +182,7 @@ class WorkflowEngine:
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
@@ -211,10 +219,16 @@ class WorkflowEngine:
elif operator == "lt":
condition_met = actual is not None and expected is not None and actual < expected
elif operator == "contains":
condition_met = actual is not None and expected in actual if isinstance(actual, (str, list)) else False
condition_met = (
actual is not None and expected in actual
if isinstance(actual, str | list)
else False
)
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type="condition",
action="evaluated",
@@ -230,7 +244,7 @@ class WorkflowEngine:
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
@@ -253,8 +267,11 @@ async def handle_event(
instances: list[dict[str, Any]] = []
for wf in workflows:
inst = await create_instance(
db, tenant_id,
uuid.UUID(payload.get("user_id", str(uuid.uuid4()))) if payload.get("user_id") else None or uuid.uuid4(),
db,
tenant_id,
uuid.UUID(payload.get("user_id", str(uuid.uuid4())))
if payload.get("user_id")
else None or uuid.uuid4(),
str(wf.id),
context=payload,
)
@@ -274,6 +291,7 @@ def register_workflow_event_handlers() -> None:
async def _workflow_event_handler(payload: dict[str, Any]) -> None:
"""Handle events that may trigger workflows."""
from app.core.db import create_db_session
tenant_id_str = payload.get("tenant_id")
event_name = payload.get("event", "")
if not tenant_id_str or not event_name:
+27 -19
View File
@@ -7,8 +7,6 @@ Auth helpers talk to the HTTP API (integration tests).
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncGenerator
from typing import Any
@@ -24,23 +22,22 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from app.config import get_settings
from app.core.db import Base, reset_engine_for_testing, close_engine
from app.core.auth import hash_password
from app.core.db import Base, close_engine, reset_engine_for_testing
from app.main import create_app
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
from app.models.company import Company
from app.models.contact import CompanyContact, Contact # noqa: F401
from app.models.plugin import Plugin, PluginMigration # noqa: F401
from app.models.role import Role
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.role import Role
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.plugin import Plugin, PluginMigration
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
# Import plugin models so Base.metadata.create_all includes their tables
from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.permissions.models import Permission, ShareLink
from app.plugins.builtins.entity_links.models import EntityLink
from app.main import create_app
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.entity_links.models import EntityLink # noqa: F401
from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
# Import plugin models so Base.metadata.create_all includes their tables
TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
@@ -50,6 +47,7 @@ def _get_sync_engine():
Uses postgres superuser because leocrm user doesn't own the public schema.
"""
from sqlalchemy import create_engine
return create_engine(
"postgresql+psycopg2://postgres@localhost:5432/leocrm_test",
echo=False,
@@ -94,7 +92,11 @@ def clean_tables(db_setup):
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(text("TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
conn.execute(
text(
"TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
)
)
conn.commit()
sync_eng.dispose()
yield
@@ -125,7 +127,9 @@ async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSessio
@pytest_asyncio.fixture
async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]:
async def db_session(
session_factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
"""Database session for direct DB operations in tests."""
async with session_factory() as session:
yield session
@@ -260,7 +264,9 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
}
async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]:
async def login_client(
client: AsyncClient, email: str, password: str = "TestPass123!"
) -> dict[str, str]:
"""Login via HTTP API and return cookies dict."""
resp = await client.post(
"/api/v1/auth/login",
@@ -271,7 +277,9 @@ async def login_client(client: AsyncClient, email: str, password: str = "TestPas
return dict(resp.cookies)
async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient:
async def get_auth_client(
client: AsyncClient, email: str, password: str = "TestPass123!"
) -> AsyncClient:
"""Return a client that's logged in."""
await login_client(client, email, password)
return client
+244 -44
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
from datetime import UTC
import pytest
from httpx import ASGITransport, AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -68,7 +70,10 @@ async def test_ac3_copilot_execute_blocked_by_rbac(client: AsyncClient, db_sessi
# Query for a delete action
query_resp = await client.post(
"/api/v1/ai/copilot/query",
json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}},
json={
"query": "Delete company",
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
},
headers=ORIGIN_HEADER,
)
assert query_resp.status_code == 200
@@ -119,6 +124,7 @@ async def test_ac4_copilot_history_paginated(client: AsyncClient, db_session):
async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_session):
"""AC5: Copilot action logged in audit_log with entity_type=ai_copilot."""
from sqlalchemy import select
from app.models.audit import AuditLog
await seed_tenant_and_users(db_session)
@@ -133,9 +139,7 @@ async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_sessio
assert resp.status_code == 200
# Check audit log
result = await db_session.execute(
select(AuditLog).where(AuditLog.entity_type == "ai_copilot")
)
result = await db_session.execute(select(AuditLog).where(AuditLog.entity_type == "ai_copilot"))
logs = result.scalars().all()
assert len(logs) >= 1
assert logs[0].action == "query"
@@ -158,12 +162,14 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session):
conv_id_a = query_resp.json()["conversation_id"]
# Login as tenant B admin (different cookie jar)
client2 = AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test")
AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test")
# Need to use the same app — just re-login with a fresh client
# Actually we need a new client without tenant A cookies
from httpx import AsyncClient as AC
from httpx import AsyncClient as AC # noqa: N817
# Use the same app instance
import app.main
app_instance = app.main.app
async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b:
@@ -174,7 +180,12 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session):
"/api/v1/ai/copilot/execute",
json={
"conversation_id": conv_id_a,
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
@@ -254,9 +265,11 @@ async def test_copilot_unauthenticated(client: AsyncClient, db_session):
# ─── ActionMapper Unit Tests ───
def test_action_mapper_create_company():
"""ActionMapper: 'create company named X' → POST /api/v1/companies."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Create a company named Acme Corp")
assert len(actions) == 1
assert actions[0]["method"] == "POST"
@@ -268,6 +281,7 @@ def test_action_mapper_create_company():
def test_action_mapper_create_company_no_name():
"""ActionMapper: 'create company' without name → default 'New Company'."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Add a new company")
assert len(actions) == 1
assert actions[0]["body"]["name"] == "New Company"
@@ -276,6 +290,7 @@ def test_action_mapper_create_company_no_name():
def test_action_mapper_delete_company_with_context():
"""ActionMapper: 'delete company' with entity_id in context → DELETE with specific ID."""
from app.ai.action_mapper import map_query_to_actions
test_id = "12345678-1234-1234-1234-123456789abc"
actions = map_query_to_actions("Delete company", context={"entity_id": test_id})
assert len(actions) == 1
@@ -287,6 +302,7 @@ def test_action_mapper_delete_company_with_context():
def test_action_mapper_delete_company_no_context():
"""ActionMapper: 'delete company' without context → DELETE with {id} placeholder."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Remove company")
assert len(actions) == 1
assert actions[0]["method"] == "DELETE"
@@ -297,6 +313,7 @@ def test_action_mapper_delete_company_no_context():
def test_action_mapper_update_company():
"""ActionMapper: 'update company' → PATCH with extracted fields."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Update company industry to Tech, name to FooBar")
assert len(actions) == 1
assert actions[0]["method"] == "PATCH"
@@ -307,6 +324,7 @@ def test_action_mapper_update_company():
def test_action_mapper_update_company_with_context():
"""ActionMapper: 'update company' with entity_id → PATCH with specific path."""
from app.ai.action_mapper import map_query_to_actions
test_id = "12345678-1234-1234-1234-123456789abc"
actions = map_query_to_actions("Edit company", context={"company_id": test_id})
assert len(actions) == 1
@@ -316,6 +334,7 @@ def test_action_mapper_update_company_with_context():
def test_action_mapper_list_companies():
"""ActionMapper: 'list companies' → GET /api/v1/companies."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show all compan")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -325,6 +344,7 @@ def test_action_mapper_list_companies():
def test_action_mapper_list_companies_with_search():
"""ActionMapper: 'find companies named X' → GET with search description."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Find compan named Acme")
assert len(actions) == 1
assert "Acme" in actions[0]["description"]
@@ -333,6 +353,7 @@ def test_action_mapper_list_companies_with_search():
def test_action_mapper_create_contact():
"""ActionMapper: 'create contact named X' → POST /api/v1/contacts."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Create a contact named John Doe")
assert len(actions) == 1
assert actions[0]["method"] == "POST"
@@ -343,6 +364,7 @@ def test_action_mapper_create_contact():
def test_action_mapper_list_contacts():
"""ActionMapper: 'list contacts' → GET /api/v1/contacts."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show all contact")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -352,6 +374,7 @@ def test_action_mapper_list_contacts():
def test_action_mapper_list_workflows():
"""ActionMapper: 'list workflows' → GET /api/v1/workflows."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show all workflow")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -361,6 +384,7 @@ def test_action_mapper_list_workflows():
def test_action_mapper_create_workflow():
"""ActionMapper: 'create workflow named X' → POST /api/v1/workflows."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Create a new workflow named Approval Process")
assert len(actions) == 1
assert actions[0]["method"] == "POST"
@@ -371,6 +395,7 @@ def test_action_mapper_create_workflow():
def test_action_mapper_help_intent():
"""ActionMapper: 'help' query returns demo action with low confidence."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("What can you do? Help me please")
assert len(actions) == 1
assert actions[0]["confidence"] == 0.3
@@ -379,6 +404,7 @@ def test_action_mapper_help_intent():
def test_action_mapper_unknown_query():
"""ActionMapper: unrecognized query returns empty actions list."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("xyzzy flonk")
assert actions == []
@@ -386,6 +412,7 @@ def test_action_mapper_unknown_query():
def test_action_mapper_update_company_phone_email():
"""ActionMapper: update company with phone and email fields."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Update company phone to 123456, email to test@examplecom")
assert len(actions) == 1
assert actions[0]["body"]["phone"] == "123456"
@@ -395,6 +422,7 @@ def test_action_mapper_update_company_phone_email():
def test_action_mapper_update_company_no_fields():
"""ActionMapper: update company without explicit fields → default name."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Modify company details")
assert len(actions) == 1
assert "name" in actions[0]["body"]
@@ -403,6 +431,7 @@ def test_action_mapper_update_company_no_fields():
def test_action_mapper_company_list_all_pattern():
"""ActionMapper: 'companies all' matches list_company2 pattern."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show me companies all")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -411,16 +440,19 @@ def test_action_mapper_company_list_all_pattern():
def test_action_mapper_empty_query():
"""ActionMapper: empty query returns no actions."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("")
assert actions == []
# ─── LLMClient Unit Tests ───
@pytest.mark.asyncio
async def test_llm_client_mock_mode_generate():
"""LLMClient: mock mode generates actions from keyword matching."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
assert client.is_mock is True
@@ -434,6 +466,7 @@ async def test_llm_client_mock_mode_generate():
async def test_llm_client_mock_mode_no_actions():
"""LLMClient: mock mode with unrecognized query returns empty actions."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
response = await client.generate("xyzzy flonk")
@@ -445,6 +478,7 @@ async def test_llm_client_mock_mode_no_actions():
async def test_llm_client_mock_mode_with_context():
"""LLMClient: mock mode passes context to action mapper."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
response = await client.generate("Delete company", context={"entity_id": "abc123"})
@@ -455,6 +489,7 @@ async def test_llm_client_mock_mode_with_context():
def test_llm_client_api_mode_init():
"""LLMClient: with model and api_key set, is_mock is False."""
from app.ai.llm_client import LLMClient
client = LLMClient(model="gpt-4", api_key="test-key")
assert client.is_mock is False
assert client.model == "gpt-4"
@@ -464,6 +499,7 @@ def test_llm_client_api_mode_init():
def test_llm_client_api_base_default():
"""LLMClient: default api_base is OpenAI URL."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
assert client.api_base == "https://api.openai.com/v1"
@@ -471,6 +507,7 @@ def test_llm_client_api_base_default():
def test_llm_client_to_dict():
"""LLMResponse: to_dict returns structured response."""
from app.ai.llm_client import LLMResponse
resp = LLMResponse(message="Hello", proposed_actions=[{"method": "GET"}], confidence=0.9)
d = resp.to_dict()
assert d["message"] == "Hello"
@@ -480,14 +517,18 @@ def test_llm_client_to_dict():
def test_llm_client_parse_valid_json():
"""LLMClient: _parse_llm_response with valid JSON returns structured response."""
from app.ai.llm_client import LLMClient
import json
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
content = json.dumps({
content = json.dumps(
{
"message": "Here are actions",
"proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}],
"confidence": 0.95,
})
}
)
response = client._parse_llm_response(content)
assert response.message == "Here are actions"
assert len(response.proposed_actions) == 1
@@ -497,6 +538,7 @@ def test_llm_client_parse_valid_json():
def test_llm_client_parse_invalid_json():
"""LLMClient: _parse_llm_response with invalid JSON returns fallback response."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
response = client._parse_llm_response("not valid json at all")
assert response.proposed_actions == []
@@ -506,6 +548,7 @@ def test_llm_client_parse_invalid_json():
def test_llm_client_build_system_prompt():
"""LLMClient: _build_system_prompt contains API endpoints and context."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
prompt = client._build_system_prompt({"page": "companies"})
assert "LeoCRM" in prompt
@@ -516,6 +559,7 @@ def test_llm_client_build_system_prompt():
def test_llm_client_build_system_prompt_empty_context():
"""LLMClient: _build_system_prompt with no context uses empty dict."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
prompt = client._build_system_prompt({})
assert "LeoCRM" in prompt
@@ -523,7 +567,8 @@ def test_llm_client_build_system_prompt_empty_context():
def test_llm_client_get_and_reset():
"""LLMClient: get_llm_client returns singleton, reset clears it."""
from app.ai.llm_client import get_llm_client, reset_llm_client, LLMClient
from app.ai.llm_client import get_llm_client, reset_llm_client
reset_llm_client()
client1 = get_llm_client()
client2 = get_llm_client()
@@ -535,10 +580,12 @@ def test_llm_client_get_and_reset():
# ─── AI Copilot Service Unit Tests ───
@pytest.mark.asyncio
async def test_service_process_query_new_conversation(db_session):
"""Service: process_query creates new conversation and returns proposed actions."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -553,6 +600,7 @@ async def test_service_process_query_new_conversation(db_session):
async def test_service_process_query_existing_conversation(db_session):
"""Service: process_query with existing conversation_id appends to conversation."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -563,7 +611,9 @@ async def test_service_process_query_existing_conversation(db_session):
# Second query with same conversation_id
result2 = await process_query(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
"Create a company named FooBar",
conversation_id=conv_id,
)
@@ -574,12 +624,16 @@ async def test_service_process_query_existing_conversation(db_session):
async def test_service_process_query_invalid_conversation(db_session):
"""Service: process_query with invalid conversation_id returns 404 error."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
result = await process_query(
db_session, tenant_id, admin_id, "List companies",
db_session,
tenant_id,
admin_id,
"List companies",
conversation_id="00000000-0000-0000-0000-000000000000",
)
assert result["error"] == "Conversation not found"
@@ -590,6 +644,7 @@ async def test_service_process_query_invalid_conversation(db_session):
async def test_service_process_query_empty_query(db_session):
"""Service: process_query with empty query creates conversation with 'Untitled' title."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -602,17 +657,23 @@ async def test_service_process_query_empty_query(db_session):
async def test_service_execute_action_companies_get(db_session):
"""Service: execute_action with GET /api/v1/companies returns list."""
from app.services.ai_copilot_service import execute_action
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
# First create a conversation
from app.services.ai_copilot_service import process_query
query_result = await process_query(db_session, tenant_id, admin_id, "List companies")
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/companies", "body": None},
)
assert result["success"] is True
@@ -624,6 +685,7 @@ async def test_service_execute_action_companies_get(db_session):
async def test_service_execute_action_companies_post(db_session):
"""Service: execute_action with POST /api/v1/companies creates company."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -632,8 +694,16 @@ async def test_service_execute_action_companies_post(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "NewCo", "industry": "Tech"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "POST",
"path": "/api/v1/companies",
"body": {"name": "NewCo", "industry": "Tech"},
},
)
assert result["success"] is True
assert result["status_code"] == 201
@@ -644,6 +714,7 @@ async def test_service_execute_action_companies_post(db_session):
async def test_service_execute_action_companies_patch(db_session):
"""Service: execute_action with PATCH /api/v1/companies/{id} updates company."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -653,15 +724,27 @@ async def test_service_execute_action_companies_patch(db_session):
conv_id = query_result["conversation_id"]
create_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "PatchCo"}},
)
company_id = create_result["data"]["id"]
# Now patch it
patch_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "PATCH", "path": f"/api/v1/companies/{company_id}", "body": {"name": "PatchedCo"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "PATCH",
"path": f"/api/v1/companies/{company_id}",
"body": {"name": "PatchedCo"},
},
)
assert patch_result["success"] is True
assert patch_result["data"]["name"] == "PatchedCo"
@@ -671,6 +754,7 @@ async def test_service_execute_action_companies_patch(db_session):
async def test_service_execute_action_companies_patch_not_found(db_session):
"""Service: execute_action with PATCH non-existent company returns 404."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -679,8 +763,16 @@ async def test_service_execute_action_companies_patch_not_found(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "PATCH", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": {"name": "X"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "PATCH",
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
"body": {"name": "X"},
},
)
assert result["success"] is False
assert result["status_code"] == 404
@@ -690,6 +782,7 @@ async def test_service_execute_action_companies_patch_not_found(db_session):
async def test_service_execute_action_companies_patch_no_id(db_session):
"""Service: execute_action with PATCH /api/v1/companies/{id} returns 400."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -698,7 +791,11 @@ async def test_service_execute_action_companies_patch_no_id(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "PATCH", "path": "/api/v1/companies/{id}", "body": {"name": "X"}},
)
assert result["success"] is False
@@ -709,6 +806,7 @@ async def test_service_execute_action_companies_patch_no_id(db_session):
async def test_service_execute_action_companies_delete(db_session):
"""Service: execute_action with DELETE /api/v1/companies/{id} soft-deletes company."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -717,13 +815,21 @@ async def test_service_execute_action_companies_delete(db_session):
conv_id = query_result["conversation_id"]
create_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "DeleteMe"}},
)
company_id = create_result["data"]["id"]
del_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": f"/api/v1/companies/{company_id}", "body": None},
)
assert del_result["success"] is True
@@ -734,6 +840,7 @@ async def test_service_execute_action_companies_delete(db_session):
async def test_service_execute_action_companies_delete_not_found(db_session):
"""Service: execute_action with DELETE non-existent company returns 404."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -742,8 +849,16 @@ async def test_service_execute_action_companies_delete_not_found(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "DELETE",
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
"body": None,
},
)
assert result["success"] is False
assert result["status_code"] == 404
@@ -753,6 +868,7 @@ async def test_service_execute_action_companies_delete_not_found(db_session):
async def test_service_execute_action_companies_delete_no_id(db_session):
"""Service: execute_action with DELETE without ID returns 400."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -761,7 +877,11 @@ async def test_service_execute_action_companies_delete_no_id(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": "/api/v1/companies/{id}", "body": None},
)
assert result["success"] is False
@@ -772,6 +892,7 @@ async def test_service_execute_action_companies_delete_no_id(db_session):
async def test_service_execute_action_contacts_get(db_session):
"""Service: execute_action with GET /api/v1/contacts returns list."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -780,7 +901,11 @@ async def test_service_execute_action_contacts_get(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/contacts", "body": None},
)
assert result["success"] is True
@@ -792,6 +917,7 @@ async def test_service_execute_action_contacts_post(db_session):
"""Service: execute_action with POST /api/v1/contacts — Contact model uses first_name/last_name,
service passes 'name' which causes error, verify error handling works."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -800,8 +926,16 @@ async def test_service_execute_action_contacts_post(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "POST", "path": "/api/v1/contacts", "body": {"name": "John Doe", "email": "john@example.com"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "POST",
"path": "/api/v1/contacts",
"body": {"name": "John Doe", "email": "john@example.com"},
},
)
# Contact model has first_name/last_name, not name — service code attempts to set 'name'
# which raises TypeError, caught by execute_action's try/except
@@ -813,6 +947,7 @@ async def test_service_execute_action_contacts_post(db_session):
async def test_service_execute_action_contacts_unsupported_method(db_session):
"""Service: execute_action with DELETE /api/v1/contacts returns 400 (unsupported)."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -821,7 +956,11 @@ async def test_service_execute_action_contacts_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": "/api/v1/contacts/123", "body": None},
)
assert result["success"] is False
@@ -832,6 +971,7 @@ async def test_service_execute_action_contacts_unsupported_method(db_session):
async def test_service_execute_action_workflows_get(db_session):
"""Service: execute_action with GET /api/v1/workflows returns list."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -840,7 +980,11 @@ async def test_service_execute_action_workflows_get(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/workflows", "body": None},
)
assert result["success"] is True
@@ -851,6 +995,7 @@ async def test_service_execute_action_workflows_get(db_session):
async def test_service_execute_action_workflows_unsupported_method(db_session):
"""Service: execute_action with POST /api/v1/workflows returns 400 (unsupported)."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -859,7 +1004,11 @@ async def test_service_execute_action_workflows_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/workflows", "body": {"name": "test"}},
)
assert result["success"] is False
@@ -870,6 +1019,7 @@ async def test_service_execute_action_workflows_unsupported_method(db_session):
async def test_service_execute_action_unsupported_entity(db_session):
"""Service: execute_action with unknown entity returns 400."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -878,7 +1028,11 @@ async def test_service_execute_action_unsupported_entity(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/unknown", "body": None},
)
assert result["success"] is False
@@ -890,6 +1044,7 @@ async def test_service_execute_action_unsupported_entity(db_session):
async def test_service_execute_action_companies_unsupported_method(db_session):
"""Service: execute_action with PUT /api/v1/companies returns 400 (unsupported)."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -898,7 +1053,11 @@ async def test_service_execute_action_companies_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "PUT", "path": "/api/v1/companies", "body": {}},
)
assert result["success"] is False
@@ -909,12 +1068,16 @@ async def test_service_execute_action_companies_unsupported_method(db_session):
async def test_service_execute_action_invalid_conversation(db_session):
"""Service: execute_action with invalid conversation_id returns 404."""
from app.services.ai_copilot_service import execute_action
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
result = await execute_action(
db_session, tenant_id, admin_id, "admin",
db_session,
tenant_id,
admin_id,
"admin",
"00000000-0000-0000-0000-000000000000",
{"method": "GET", "path": "/api/v1/companies", "body": None},
)
@@ -926,6 +1089,7 @@ async def test_service_execute_action_invalid_conversation(db_session):
async def test_service_execute_action_rbac_blocked(db_session):
"""Service: execute_action as viewer with DELETE returns 403."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -934,8 +1098,16 @@ async def test_service_execute_action_rbac_blocked(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "viewer", conv_id,
{"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None},
db_session,
tenant_id,
admin_id,
"viewer",
conv_id,
{
"method": "DELETE",
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
"body": None,
},
)
assert result["status_code"] == 403
assert result["success"] is False
@@ -945,6 +1117,7 @@ async def test_service_execute_action_rbac_blocked(db_session):
async def test_service_get_history_pagination(db_session):
"""Service: get_history returns paginated results."""
from app.services.ai_copilot_service import get_history, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -963,6 +1136,7 @@ async def test_service_get_history_pagination(db_session):
async def test_service_get_history_empty(db_session):
"""Service: get_history returns empty when no conversations exist."""
from app.services.ai_copilot_service import get_history
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -975,6 +1149,7 @@ async def test_service_get_history_empty(db_session):
def test_service_derive_rbac_from_path_companies_get():
"""Service: _derive_rbac_from_path for GET /api/v1/companies → (companies, read)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("GET", "/api/v1/companies")
assert module == "companies"
assert action == "read"
@@ -983,6 +1158,7 @@ def test_service_derive_rbac_from_path_companies_get():
def test_service_derive_rbac_from_path_contacts_post():
"""Service: _derive_rbac_from_path for POST /api/v1/contacts → (contacts, create)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("POST", "/api/v1/contacts")
assert module == "contacts"
assert action == "create"
@@ -991,6 +1167,7 @@ def test_service_derive_rbac_from_path_contacts_post():
def test_service_derive_rbac_from_path_workflows_patch():
"""Service: _derive_rbac_from_path for PATCH /api/v1/workflows → (workflows, update)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("PATCH", "/api/v1/workflows")
assert module == "workflows"
assert action == "update"
@@ -999,6 +1176,7 @@ def test_service_derive_rbac_from_path_workflows_patch():
def test_service_derive_rbac_from_path_unknown_entity():
"""Service: _derive_rbac_from_path for unknown entity returns entity as module."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("DELETE", "/api/v1/foobar")
assert module == "foobar"
assert action == "delete"
@@ -1007,6 +1185,7 @@ def test_service_derive_rbac_from_path_unknown_entity():
def test_service_derive_rbac_from_path_ai_entity():
"""Service: _derive_rbac_from_path for /api/v1/ai → (ai_copilot, read)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("GET", "/api/v1/ai/copilot/query")
assert module == "ai_copilot"
assert action == "read"
@@ -1015,14 +1194,17 @@ def test_service_derive_rbac_from_path_ai_entity():
def test_service_safe_iso_none():
"""Service: _safe_iso with None returns None."""
from app.services.ai_copilot_service import _safe_iso
assert _safe_iso(None) is None
def test_service_safe_iso_datetime():
"""Service: _safe_iso with datetime returns ISO string."""
from datetime import datetime
from app.services.ai_copilot_service import _safe_iso
from datetime import datetime, timezone
dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
result = _safe_iso(dt)
assert result is not None
assert "2026-01-01" in result
@@ -1031,15 +1213,18 @@ def test_service_safe_iso_datetime():
def test_service_safe_iso_exception():
"""Service: _safe_iso with object that raises on isoformat returns None."""
from app.services.ai_copilot_service import _safe_iso
class Bad:
def isoformat(self):
raise ValueError("bad")
assert _safe_iso(Bad()) is None
def test_service_get_attr_missing():
"""Service: _get_attr with missing attribute returns default."""
from app.services.ai_copilot_service import _get_attr
obj = type("Obj", (), {"x": 1})()
assert _get_attr(obj, "x") == 1
assert _get_attr(obj, "y", "default") == "default"
@@ -1048,12 +1233,14 @@ def test_service_get_attr_missing():
def test_service_get_attr_none():
"""Service: _get_attr with None value returns default."""
from app.services.ai_copilot_service import _get_attr
obj = type("Obj", (), {"x": None})()
assert _get_attr(obj, "x", "fallback") == "fallback"
# ─── Route Error Path Tests for AI Copilot ───
@pytest.mark.asyncio
async def test_route_copilot_execute_not_found(client: AsyncClient, db_session):
"""Route: POST /execute with invalid conversation_id returns 404."""
@@ -1064,7 +1251,12 @@ async def test_route_copilot_execute_not_found(client: AsyncClient, db_session):
"/api/v1/ai/copilot/execute",
json={
"conversation_id": "00000000-0000-0000-0000-000000000000",
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
@@ -1080,7 +1272,10 @@ async def test_route_copilot_execute_rbac_blocked(client: AsyncClient, db_sessio
# First create a conversation as viewer
query_resp = await client.post(
"/api/v1/ai/copilot/query",
json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}},
json={
"query": "Delete company",
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
},
headers=ORIGIN_HEADER,
)
conv_id = query_resp.json()["conversation_id"]
@@ -1108,7 +1303,12 @@ async def test_route_copilot_execute_unauthenticated(client: AsyncClient, db_ses
"/api/v1/ai/copilot/execute",
json={
"conversation_id": "00000000-0000-0000-0000-000000000000",
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
+12 -5
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -21,8 +21,9 @@ class TestAuthLogin:
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert "leocrm_session" in resp.headers.get("set-cookie", "").lower() or \
"leocrm_session" in str(resp.cookies)
assert "leocrm_session" in resp.headers.get(
"set-cookie", ""
).lower() or "leocrm_session" in str(resp.cookies)
data = resp.json()
assert data["email"] == "admin@tenanta.com"
assert data["role"] == "admin"
@@ -64,7 +65,9 @@ class TestAuthMe:
class TestAuthLogout:
"""AC 5: logout."""
async def test_logout_returns_200_and_invalidates_session(self, client: AsyncClient, db_session):
async def test_logout_returns_200_and_invalidates_session(
self, client: AsyncClient, db_session
):
"""AC 5: POST /api/v1/auth/logout -> 200, session invalidated."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
@@ -104,6 +107,7 @@ class TestPasswordReset:
async def test_password_reset_confirm_valid_token(self, client: AsyncClient, db_session):
"""AC 7: POST /api/v1/auth/password-reset/confirm valid token -> 200."""
from app.services.auth_service import auth_service
await seed_tenant_and_users(db_session)
raw_token = await auth_service.get_password_reset_token_raw(db_session, "admin@tenanta.com")
await db_session.commit()
@@ -119,6 +123,7 @@ class TestPasswordReset:
async def test_password_reset_confirm_expired_token(self, client: AsyncClient, db_session):
"""AC 8: POST /api/v1/auth/password-reset/confirm expired token -> 400."""
from app.services.auth_service import auth_service
await seed_tenant_and_users(db_session)
raw_token = await auth_service.create_expired_reset_token(db_session, "admin@tenanta.com")
await db_session.commit()
@@ -147,8 +152,10 @@ class TestSwitchTenant:
initial_tenant = resp.json()["tenant_id"]
# Get tenant B ID
from app.models.tenant import Tenant
from sqlalchemy import select
from app.models.tenant import Tenant
q = select(Tenant).where(Tenant.slug == "tenant-b")
result = await db_session.execute(q)
tenant_b = result.scalar_one()
+4 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -186,7 +186,7 @@ class TestCompanyDelete:
json={"first_name": "John", "last_name": "Doe", "company_ids": [company_id]},
headers=ORIGIN_HEADER,
)
contact_id = cont_resp.json()["id"]
cont_resp.json()["id"]
# Verify link exists
detail_resp = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER)
assert len(detail_resp.json()["contacts"]) == 1
@@ -318,7 +318,9 @@ class TestCompanyAuditAndSoftDelete:
async def test_audit_log_on_company_create(self, client: AsyncClient, db_session):
"""AC 23: Audit log entry on every company/contact mutation."""
from sqlalchemy import select
from app.models.audit import AuditLog
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
await client.post(
+11 -4
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -29,7 +29,9 @@ class TestContactList:
class TestContactCreate:
"""AC 15: Create contact with company_ids array -> N:M links."""
async def test_create_contact_with_company_ids_returns_201(self, client: AsyncClient, db_session):
async def test_create_contact_with_company_ids_returns_201(
self, client: AsyncClient, db_session
):
"""AC 15: POST /api/v1/contacts mit company_ids array -> 201 + N:M links."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
@@ -132,12 +134,17 @@ class TestContactDelete:
names = [f"{item['first_name']} {item['last_name']}" for item in list_resp.json()["items"]]
assert "Delete Me" not in names
async def test_delete_contact_gdpr_hard_delete_returns_204(self, client: AsyncClient, db_session):
async def test_delete_contact_gdpr_hard_delete_returns_204(
self, client: AsyncClient, db_session
):
"""AC 19: DELETE /api/v1/contacts/{id}?gdpr=true -> 204, hard-delete + deletion_log."""
import uuid as uuid_mod
from sqlalchemy import select
from app.models.audit import DeletionLog
from app.models.contact import Contact
import uuid as uuid_mod
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
create_resp = await client.post(
+16 -8
View File
@@ -7,16 +7,16 @@ import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.db import reset_engine_for_testing, close_engine
from app.core.db import close_engine, reset_engine_for_testing
from app.core.event_bus import get_event_bus
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.registry import reset_registry_for_testing
from app.plugins.builtins.entity_links import EntityLinksPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest_asyncio.fixture
@@ -240,14 +240,18 @@ async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
# Publish company.deleted event
event_bus = get_event_bus()
await event_bus.publish("company.deleted", {
await event_bus.publish(
"company.deleted",
{
"entity_id": str(company_id),
"company_id": str(company_id),
"tenant_id": str(tenant_id),
})
},
)
# Allow async event handler to complete
import asyncio
await asyncio.sleep(0.1)
# Verify link is cleaned up
@@ -279,14 +283,18 @@ async def test_event_cleanup_on_contact_deleted(authed_client: AsyncClient):
# Publish contact.deleted event
event_bus = get_event_bus()
await event_bus.publish("contact.deleted", {
await event_bus.publish(
"contact.deleted",
{
"entity_id": str(contact_id),
"contact_id": str(contact_id),
"tenant_id": str(tenant_id),
})
},
)
# Allow async event handler to complete
import asyncio
await asyncio.sleep(0.1)
# Verify link is cleaned up
+1 -3
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import io
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
CSV_COMPANIES = """name,industry,phone,email,website,description
ImportCorp,IT,123456,import@example.com,https://import.example,Imported company
+35 -16
View File
@@ -2,16 +2,14 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from app.core.notifications import create_notification
from app.models.notification import Notification
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -23,22 +21,31 @@ class TestNotifications:
seed = await seed_tenant_and_users(db_session)
# Create some notifications for admin_a
await create_notification(
db_session, seed["tenant_a"].id, seed["admin_a"].id,
"info", "Read Notif", "Already read",
db_session,
seed["tenant_a"].id,
seed["admin_a"].id,
"info",
"Read Notif",
"Already read",
)
await db_session.flush()
# Mark the first one as read
from sqlalchemy import select, update
from sqlalchemy import select
q = select(Notification).where(Notification.title == "Read Notif")
result = await db_session.execute(q)
first_notif = result.scalar_one()
first_notif.read_at = datetime.now(timezone.utc)
first_notif.read_at = datetime.now(UTC)
await db_session.flush()
# Create an unread one
await create_notification(
db_session, seed["tenant_a"].id, seed["admin_a"].id,
"info", "Unread Notif", "Not read yet",
db_session,
seed["tenant_a"].id,
seed["admin_a"].id,
"info",
"Unread Notif",
"Not read yet",
)
await db_session.commit()
@@ -64,8 +71,12 @@ class TestNotifications:
"""AC 25: PATCH /api/v1/notifications/{id}/read -> 200."""
seed = await seed_tenant_and_users(db_session)
notif = await create_notification(
db_session, seed["tenant_a"].id, seed["admin_a"].id,
"info", "Test Notif", "Test body",
db_session,
seed["tenant_a"].id,
seed["admin_a"].id,
"info",
"Test Notif",
"Test body",
)
await db_session.commit()
@@ -81,12 +92,20 @@ class TestNotifications:
"""AC 26: GET /api/v1/notifications/unread-count -> 200 + integer count."""
seed = await seed_tenant_and_users(db_session)
await create_notification(
db_session, seed["tenant_a"].id, seed["admin_a"].id,
"info", "Unread 1", "Body 1",
db_session,
seed["tenant_a"].id,
seed["admin_a"].id,
"info",
"Unread 1",
"Body 1",
)
await create_notification(
db_session, seed["tenant_a"].id, seed["admin_a"].id,
"info", "Unread 2", "Body 2",
db_session,
seed["tenant_a"].id,
seed["admin_a"].id,
"info",
"Unread 2",
"Body 2",
)
await db_session.commit()
+13 -10
View File
@@ -3,20 +3,20 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.db import reset_engine_for_testing, close_engine
from app.core.db import close_engine, reset_engine_for_testing
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.registry import reset_registry_for_testing
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest_asyncio.fixture
@@ -182,7 +182,7 @@ async def test_expired_share_link(authed_client: AsyncClient):
file_id = str(uuid.uuid4())
# Create link with expiry in the past
past = datetime.now(timezone.utc) - timedelta(hours=1)
past = datetime.now(UTC) - timedelta(hours=1)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share-link",
json={"expires_at": past.isoformat(), "access_level": "download"},
@@ -235,21 +235,23 @@ async def test_revoke_share_link(authed_client: AsyncClient):
@pytest.mark.asyncio
async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, db_session: AsyncSession):
async def test_permission_403_for_unauthorized_user(
plugin_client: AsyncClient, db_session: AsyncSession
):
"""AC14: Folder permissions enforced: user without read → 403.
The permissions plugin does not intercept file access (no DMS file system yet).
We test the permission check helper directly: a user with no permission record
gets denied (False), which translates to 403 at the route layer.
"""
from app.plugins.builtins.permissions.routes import check_user_file_permission
from app.core.db import get_session_factory
from app.plugins.builtins.permissions.routes import check_user_file_permission
seed = await seed_tenant_and_users(db_session)
await login_client(plugin_client, "admin@tenanta.com")
# Install + activate permissions plugin
registry = reset_registry_for_testing()
reset_registry_for_testing()
# We need to set up the registry properly
# Since plugin_client fixture wasn't used, manually install
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
@@ -270,6 +272,7 @@ async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient,
# Grant read to admin
from app.plugins.builtins.permissions.models import Permission
perm = Permission(
tenant_id=tenant_id,
file_id=file_id,
@@ -422,7 +425,7 @@ async def test_public_access_post_expired(authed_client: AsyncClient):
"""POST /api/public/share/{token} with expired link → 410."""
client, seed = authed_client
file_id = str(uuid.uuid4())
past = datetime.now(timezone.utc) - timedelta(hours=1)
past = datetime.now(UTC) - timedelta(hours=1)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/share-link",
json={"expires_at": past.isoformat(), "access_level": "download"},
+163 -52
View File
@@ -3,31 +3,31 @@
from __future__ import annotations
import asyncio
import uuid
from typing import Any
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text, select, inspect
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from app.core.db import Base, reset_engine_for_testing, close_engine, get_engine
from app.core.db import close_engine, reset_engine_for_testing
from app.core.event_bus import get_event_bus
from app.core.service_container import get_container
from app.main import create_app
from app.models.plugin import Plugin as PluginModel, PluginMigration
from app.models.plugin import Plugin as PluginModel
from app.models.plugin import PluginMigration
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
from app.plugins.registry import get_registry, reset_registry_for_testing
from app.plugins.migration_runner import MigrationRunner, MigrationValidationError
from app.plugins.builtins.test_sample import TestSamplePlugin
from app.plugins.manifest import PluginManifest
from app.plugins.migration_runner import MigrationRunner, MigrationValidationError
from app.plugins.registry import get_registry, reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import TEST_DB_URL, seed_tenant_and_users, login_client, ORIGIN_HEADER
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
# ─── Bad Plugin for AC11 (migration without tenant_id) ───
class BadMigrationPlugin(BasePlugin):
"""Plugin with a migration that creates a table WITHOUT tenant_id."""
@@ -46,6 +46,7 @@ class BadMigrationPlugin(BasePlugin):
# ─── Fixtures ───
@pytest_asyncio.fixture
async def plugin_app(engine: AsyncEngine, redis_client):
"""FastAPI app with plugin registry initialized for testing."""
@@ -82,7 +83,7 @@ async def plugin_client(plugin_app) -> AsyncClient:
@pytest_asyncio.fixture
async def authed_plugin_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
"""Authenticated admin client for plugin tests."""
seed = await seed_tenant_and_users(db_session)
await seed_tenant_and_users(db_session)
await login_client(plugin_client, "admin@tenanta.com")
return plugin_client
@@ -98,6 +99,7 @@ async def db_session_for_plugins(engine: AsyncEngine) -> AsyncSession:
# ─── AC1: GET /api/v1/plugins → 200 + list of plugins with status ───
@pytest.mark.asyncio
async def test_ac01_list_plugins(authed_plugin_client: AsyncClient):
"""AC1: GET /api/v1/plugins returns 200 with plugin list and status."""
@@ -119,10 +121,15 @@ async def test_ac01_list_plugins(authed_plugin_client: AsyncClient):
# ─── AC2: POST /api/v1/plugins/{name}/install → 200, status=installed, migrations run ───
@pytest.mark.asyncio
async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession):
async def test_ac02_install_plugin(
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
):
"""AC2: Install plugin runs migrations and sets status=installed."""
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test_sample"
@@ -132,22 +139,29 @@ async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session
# Verify migration table was created (tenant_id column exists)
result = await db_session_for_plugins.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'")
text(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'"
)
)
assert result.fetchone() is not None, "plugin_test_data table should have tenant_id column"
# ─── AC3: POST /api/v1/plugins/{name}/activate → 200, status=active, routes registered ───
@pytest.mark.asyncio
async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient):
"""AC3: Activate plugin sets status=active and registers event listeners."""
# Install first
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
# Activate
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test_sample"
@@ -157,6 +171,7 @@ async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient):
# ─── AC4: POST /api/v1/plugins/{name}/deactivate → 200, status=inactive, routes unregistered ───
@pytest.mark.asyncio
async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient):
"""AC4: Deactivate plugin sets status=inactive and unregisters event listeners."""
@@ -165,7 +180,9 @@ async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient):
await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
# Deactivate
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test_sample"
@@ -175,8 +192,11 @@ async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient):
# ─── AC5: DELETE /api/v1/plugins/{name} → 200, plugin removed ───
@pytest.mark.asyncio
async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession):
async def test_ac05_uninstall_plugin(
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
):
"""AC5: Uninstall plugin removes DB record."""
# Install first
await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
@@ -197,6 +217,7 @@ async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_sessi
# ─── AC6: DELETE /api/v1/plugins/{name}?remove_data=true → 200, plugin tables dropped ───
@pytest.mark.asyncio
async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, engine: AsyncEngine):
"""AC6: Uninstall with remove_data=true drops plugin tables."""
@@ -207,6 +228,7 @@ async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, eng
def _check_table(sync_conn):
insp = inspect(sync_conn)
return "plugin_test_data" in insp.get_table_names()
async with engine.connect() as conn:
table_exists_before = await conn.run_sync(_check_table)
assert table_exists_before, "plugin_test_data table should exist after install"
@@ -223,11 +245,14 @@ async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, eng
# Verify table is gone
async with engine.connect() as conn:
table_exists_after = await conn.run_sync(_check_table)
assert not table_exists_after, "plugin_test_data table should be dropped after uninstall with remove_data=true"
assert (
not table_exists_after
), "plugin_test_data table should be dropped after uninstall with remove_data=true"
# ─── AC7: GET /api/v1/plugins/manifest → 200 + manifest schema documentation ───
@pytest.mark.asyncio
async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient):
"""AC7: GET /api/v1/plugins/manifest returns schema documentation."""
@@ -250,6 +275,7 @@ async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient):
# ─── AC8: Plugin activation registers event listeners on event bus ───
@pytest.mark.asyncio
async def test_ac08_activation_registers_event_listeners(
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
@@ -281,6 +307,7 @@ async def test_ac08_activation_registers_event_listeners(
# ─── AC9: Plugin deactivation unregisters event listeners ───
@pytest.mark.asyncio
async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_client: AsyncClient):
"""AC9: Deactivating a plugin unregisters its event listeners from the event bus."""
@@ -297,11 +324,15 @@ async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_clien
# Publish an event — handler should NOT be called after deactivation
initial_log_count = len(plugin.event_log)
await event_bus.publish("company.created", {"company_id": "test-456", "name": "After Deactivate"})
await event_bus.publish(
"company.created", {"company_id": "test-456", "name": "After Deactivate"}
)
await asyncio.sleep(0.1)
# Event log should not have grown
assert len(plugin.event_log) == initial_log_count, "Event handler should not be called after deactivation"
assert (
len(plugin.event_log) == initial_log_count
), "Event handler should not be called after deactivation"
# Check event bus no longer has the plugin's handlers
handlers = event_bus._handlers.get("company.created", [])
@@ -312,11 +343,16 @@ async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_clien
# ─── AC10: Plugin migration creates tables with tenant_id column ───
@pytest.mark.asyncio
async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClient, engine: AsyncEngine):
async def test_ac10_migration_creates_tenant_id(
authed_plugin_client: AsyncClient, engine: AsyncEngine
):
"""AC10: Plugin migration creates tables that have tenant_id column."""
# Install the test_sample plugin which has a migration creating plugin_test_data
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
# Verify the table has tenant_id column
@@ -335,6 +371,7 @@ async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClien
# ─── AC11: Plugin migration validator rejects tables without tenant_id ───
@pytest.mark.asyncio
async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncClient):
"""AC11: Migration validator rejects tables created without tenant_id column."""
@@ -357,11 +394,16 @@ async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncCl
# ─── AC12: Plugin DB migrations tracked in plugin_migrations table ───
@pytest.mark.asyncio
async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession):
async def test_ac12_migrations_tracked(
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
):
"""AC12: Plugin migrations are tracked in plugin_migrations table."""
# Install the test_sample plugin
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
# Check plugin_migrations table has a record
@@ -376,17 +418,22 @@ async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_ses
# ─── AC13: Activating already-active plugin → idempotent (200, no error) ───
@pytest.mark.asyncio
async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient):
"""AC13: Activating an already-active plugin is idempotent (returns 200, no error)."""
# Install and activate
await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
assert resp.json()["status"] == "active"
# Activate again — should be idempotent
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "active"
@@ -396,6 +443,7 @@ async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient):
# ─── AC14: Deactivating inactive plugin → idempotent (200) ───
@pytest.mark.asyncio
async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClient):
"""AC14: Deactivating an already-inactive plugin is idempotent (returns 200, no error)."""
@@ -404,12 +452,16 @@ async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClien
await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
# Deactivate
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
assert resp.json()["status"] == "inactive"
# Deactivate again — should be idempotent
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER)
resp = await authed_plugin_client.post(
"/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "inactive"
@@ -419,6 +471,7 @@ async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClien
# ─── Additional Tests: Direct MigrationRunner Unit Tests ───
@pytest.mark.asyncio
async def test_registry_discover_builtins(engine: AsyncEngine):
"""Test that registry can discover built-in plugins."""
@@ -431,7 +484,9 @@ async def test_registry_discover_builtins(engine: AsyncEngine):
@pytest.mark.asyncio
async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_list_plugins_mixed_states(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test listing plugins in various states (discovered, installed, active, inactive)."""
registry = reset_registry_for_testing()
registry.initialize(engine, None)
@@ -464,7 +519,9 @@ async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_sessio
@pytest.mark.asyncio
async def test_registry_install_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_install_idempotent(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test that installing an already-installed plugin is idempotent."""
registry = reset_registry_for_testing()
registry.initialize(engine, None)
@@ -500,7 +557,9 @@ async def test_registry_not_found_errors(engine: AsyncEngine, db_session_for_plu
@pytest.mark.asyncio
async def test_registry_activate_without_install(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_activate_without_install(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test that activating without install raises error."""
registry = reset_registry_for_testing()
registry.initialize(engine, None)
@@ -511,7 +570,9 @@ async def test_registry_activate_without_install(engine: AsyncEngine, db_session
@pytest.mark.asyncio
async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_migration_runner_drop_tables(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test MigrationRunner.drop_plugin_tables removes tables and migration records."""
runner = MigrationRunner(engine)
@@ -545,7 +606,9 @@ async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_
@pytest.mark.asyncio
async def test_migration_runner_file_not_found(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_migration_runner_file_not_found(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test MigrationRunner raises FileNotFoundError for missing migration file."""
runner = MigrationRunner(engine)
with pytest.raises(FileNotFoundError):
@@ -592,7 +655,7 @@ def test_plugin_manifest_validation():
assert m2.name == "myplugin"
# Invalid name with special chars
with pytest.raises(Exception):
with pytest.raises(Exception): # noqa: B017
PluginManifest(name="my-plugin!", version="1.0.0", display_name="Test")
@@ -606,6 +669,7 @@ def test_base_plugin_repr():
def test_base_plugin_no_manifest_error():
"""Test that BasePlugin without manifest raises ValueError."""
class NoManifestPlugin(BasePlugin):
pass
@@ -623,10 +687,14 @@ def test_base_plugin_name_version_properties():
@pytest.mark.asyncio
async def test_base_plugin_on_install_default():
"""Test default on_install returns None (no-op)."""
class MinimalPlugin(BasePlugin):
manifest = PluginManifest(
name="minimal", version="1.0.0", display_name="Minimal",
name="minimal",
version="1.0.0",
display_name="Minimal",
)
plugin = MinimalPlugin()
result = await plugin.on_install(None, None)
assert result is None
@@ -635,10 +703,14 @@ async def test_base_plugin_on_install_default():
@pytest.mark.asyncio
async def test_base_plugin_on_uninstall_default():
"""Test default on_uninstall returns None (no-op)."""
class MinimalPlugin(BasePlugin):
manifest = PluginManifest(
name="minimal2", version="1.0.0", display_name="Minimal",
name="minimal2",
version="1.0.0",
display_name="Minimal",
)
plugin = MinimalPlugin()
result = await plugin.on_uninstall(None, None)
assert result is None
@@ -653,11 +725,15 @@ def test_base_plugin_get_routes_empty():
def test_base_plugin_make_event_handler_noop():
"""Test that _make_event_handler creates noop for unknown events."""
class MinimalPlugin(BasePlugin):
manifest = PluginManifest(
name="minimal3", version="1.0.0", display_name="Minimal",
name="minimal3",
version="1.0.0",
display_name="Minimal",
events=["unknown.event"],
)
plugin = MinimalPlugin()
handler = plugin._make_event_handler("unknown.event")
assert handler is not None
@@ -669,6 +745,7 @@ def test_base_plugin_make_event_handler_noop():
async def test_service_manifest_schema():
"""Test plugin service get_manifest_schema returns dict."""
from app.services.plugin_service import PluginService
service = PluginService()
schema = service.get_manifest_schema()
assert isinstance(schema, dict)
@@ -694,10 +771,13 @@ async def test_uninstall_inactive_plugin(engine: AsyncEngine, db_session_for_plu
@pytest.mark.asyncio
async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_activate_with_app_routes(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test that activating a plugin with a FastAPI app registers routes."""
from fastapi import FastAPI, APIRouter
from app.plugins.manifest import PluginManifest, PluginRouteDef
from fastapi import APIRouter, FastAPI
from app.plugins.manifest import PluginManifest
# Create a plugin with a route
class RoutePlugin(BasePlugin):
@@ -707,11 +787,14 @@ async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session
display_name="Route Plugin",
events=["test.event"],
)
def get_routes(self) -> list:
test_router = APIRouter()
@test_router.get("/api/v1/route-plugin/test")
async def test_endpoint():
return {"status": "ok"}
return [test_router]
app = FastAPI()
@@ -737,7 +820,9 @@ async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session
@pytest.mark.asyncio
async def test_registry_uninstall_with_remove_data(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_uninstall_with_remove_data(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test registry uninstall with remove_data drops tables."""
registry = reset_registry_for_testing()
registry.initialize(engine, None)
@@ -763,7 +848,9 @@ async def test_registry_not_initialized_errors():
@pytest.mark.asyncio
async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_deactivate_already_inactive_direct(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test registry.deactivate is idempotent when already inactive."""
registry = reset_registry_for_testing()
registry.initialize(engine, None)
@@ -785,9 +872,12 @@ async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine,
@pytest.mark.asyncio
async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_plugin_service_install_error_handling(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test plugin service raises ValueError for unknown plugin."""
from app.services.plugin_service import PluginService
registry = reset_registry_for_testing()
registry.initialize(engine, None)
service = PluginService(registry=registry)
@@ -797,9 +887,12 @@ async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_ses
@pytest.mark.asyncio
async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_plugin_service_activate_error_handling(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test plugin service raises ValueError when activating uninstalled plugin."""
from app.services.plugin_service import PluginService
registry = reset_registry_for_testing()
registry.initialize(engine, None)
registry.register_plugin(TestSamplePlugin())
@@ -810,9 +903,12 @@ async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_se
@pytest.mark.asyncio
async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_plugin_service_deactivate_error_handling(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test plugin service raises ValueError for deactivating unknown plugin."""
from app.services.plugin_service import PluginService
registry = reset_registry_for_testing()
registry.initialize(engine, None)
service = PluginService(registry=registry)
@@ -822,9 +918,12 @@ async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_
@pytest.mark.asyncio
async def test_plugin_service_uninstall_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_plugin_service_uninstall_error_handling(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test plugin service raises ValueError for uninstalling unknown plugin."""
from app.services.plugin_service import PluginService
registry = reset_registry_for_testing()
registry.initialize(engine, None)
service = PluginService(registry=registry)
@@ -842,7 +941,9 @@ async def test_migration_runner_split_sql_dollar_quotes():
@pytest.mark.asyncio
async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_registry_list_plugins_db_only_record(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test listing plugins includes DB-only records (installed but not discovered)."""
registry = reset_registry_for_testing()
registry.initialize(engine, None)
@@ -869,11 +970,15 @@ async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_sess
@pytest.mark.asyncio
async def test_base_plugin_on_event_fallback():
"""Test that on_event fallback handler works."""
class FallbackPlugin(BasePlugin):
manifest = PluginManifest(
name="fallback", version="1.0.0", display_name="Fallback",
name="fallback",
version="1.0.0",
display_name="Fallback",
events=["custom.event"],
)
async def on_event(self, payload: dict[str, Any]) -> None:
self.event_received = payload
@@ -884,7 +989,9 @@ async def test_base_plugin_on_event_fallback():
@pytest.mark.asyncio
async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_migration_runner_valid_migration(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test MigrationRunner directly with a valid migration (has tenant_id)."""
runner = MigrationRunner(engine)
record = await runner.run_migration(
@@ -899,7 +1006,9 @@ async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_
@pytest.mark.asyncio
async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_migration_runner_invalid_migration(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test MigrationRunner directly rejects migration without tenant_id."""
runner = MigrationRunner(engine)
with pytest.raises(MigrationValidationError) as exc_info:
@@ -912,7 +1021,9 @@ async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_sessio
@pytest.mark.asyncio
async def test_migration_runner_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
async def test_migration_runner_idempotent(
engine: AsyncEngine, db_session_for_plugins: AsyncSession
):
"""Test MigrationRunner skips already-applied migrations."""
runner = MigrationRunner(engine)
# Run migration
+40 -17
View File
@@ -7,16 +7,15 @@ import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.db import Base, reset_engine_for_testing, close_engine
from app.core.db import close_engine, reset_engine_for_testing
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.registry import get_registry, reset_registry_for_testing
from app.plugins.builtins.tags import TagsPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest_asyncio.fixture
@@ -48,7 +47,7 @@ async def plugin_client(plugin_app) -> AsyncClient:
@pytest_asyncio.fixture
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
"""Authenticated admin client with seeded data."""
seed = await seed_tenant_and_users(db_session)
await seed_tenant_and_users(db_session)
await login_client(plugin_client, "admin@tenanta.com")
# Install + activate the tags plugin
resp = await plugin_client.post("/api/v1/plugins/tags/install", headers=ORIGIN_HEADER)
@@ -91,7 +90,7 @@ async def test_list_tags_with_counts(authed_client: AsyncClient):
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
tag_id = resp.json()["id"]
resp.json()["id"]
# List tags — should show entity_count=0
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
@@ -302,11 +301,15 @@ async def test_update_tag_invalid_id(authed_client: AsyncClient):
async def test_update_tag_duplicate_name(authed_client: AsyncClient):
"""PATCH /api/v1/tags/{id} with existing name → 409."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "TagA", "color": "#AAAAAA"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "TagA", "color": "#AAAAAA"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
resp = await authed_client.post(
"/api/v1/tags", json={"name": "TagB", "color": "#BBBBBB"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "TagB", "color": "#BBBBBB"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
tag_b_id = resp.json()["id"]
@@ -322,7 +325,9 @@ async def test_update_tag_duplicate_name(authed_client: AsyncClient):
async def test_update_tag_color_only(authed_client: AsyncClient):
"""PATCH /api/v1/tags/{id} with only color → 200."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "ColorTag", "color": "#CCCCCC"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "ColorTag", "color": "#CCCCCC"},
headers=ORIGIN_HEADER,
)
tag_id = resp.json()["id"]
resp = await authed_client.patch(
@@ -352,7 +357,9 @@ async def test_delete_tag_invalid_id(authed_client: AsyncClient):
async def test_assign_tag_invalid_entity_type(authed_client: AsyncClient):
"""POST /api/v1/tags/assign with invalid entity_type → 400."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "ETTag", "color": "#EEEEEE"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "ETTag", "color": "#EEEEEE"},
headers=ORIGIN_HEADER,
)
tag_id = resp.json()["id"]
resp = await authed_client.post(
@@ -368,7 +375,11 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
"""POST /api/v1/tags/assign with nonexistent tag → 404."""
resp = await authed_client.post(
"/api/v1/tags/assign",
json={"tag_id": str(uuid.uuid4()), "entity_type": "company", "entity_id": str(uuid.uuid4())},
json={
"tag_id": str(uuid.uuid4()),
"entity_type": "company",
"entity_id": str(uuid.uuid4()),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@@ -378,7 +389,9 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
async def test_assign_tag_already_assigned(authed_client: AsyncClient):
"""POST /api/v1/tags/assign twice → already_assigned=True."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "DupAssign", "color": "#FFFFFF"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "DupAssign", "color": "#FFFFFF"},
headers=ORIGIN_HEADER,
)
tag_id = resp.json()["id"]
entity_id = str(uuid.uuid4())
@@ -413,7 +426,9 @@ async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
async def test_unassign_tag_not_found(authed_client: AsyncClient):
"""DELETE /api/v1/tags/assign with nonexistent assignment → 404."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "Unassign404", "color": "#ABABAB"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "Unassign404", "color": "#ABABAB"},
headers=ORIGIN_HEADER,
)
tag_id = resp.json()["id"]
resp = await authed_client.request(
@@ -429,7 +444,9 @@ async def test_unassign_tag_not_found(authed_client: AsyncClient):
async def test_bulk_assign_invalid_entity_type(authed_client: AsyncClient):
"""POST /api/v1/tags/bulk-assign with invalid entity_type → 400."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "BulkBad", "color": "#000001"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "BulkBad", "color": "#000001"},
headers=ORIGIN_HEADER,
)
tag_id = resp.json()["id"]
resp = await authed_client.post(
@@ -445,7 +462,11 @@ async def test_bulk_assign_tag_not_found(authed_client: AsyncClient):
"""POST /api/v1/tags/bulk-assign with nonexistent tag → 404."""
resp = await authed_client.post(
"/api/v1/tags/bulk-assign",
json={"tag_ids": [str(uuid.uuid4())], "entity_type": "file", "entity_id": str(uuid.uuid4())},
json={
"tag_ids": [str(uuid.uuid4())],
"entity_type": "file",
"entity_id": str(uuid.uuid4()),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@@ -455,7 +476,9 @@ async def test_bulk_assign_tag_not_found(authed_client: AsyncClient):
async def test_bulk_assign_already_assigned(authed_client: AsyncClient):
"""POST /api/v1/tags/bulk-assign twice → already_assigned populated."""
resp = await authed_client.post(
"/api/v1/tags", json={"name": "BulkDup1", "color": "#001100"}, headers=ORIGIN_HEADER,
"/api/v1/tags",
json={"name": "BulkDup1", "color": "#001100"},
headers=ORIGIN_HEADER,
)
tag_id = resp.json()["id"]
entity_id = str(uuid.uuid4())
+14 -5
View File
@@ -5,12 +5,10 @@ from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from app.models.audit import AuditLog
from app.models.notification import Notification
from app.models.company import Company
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -60,6 +58,7 @@ class TestUserManagement:
await login_client(client, "admin@tenanta.com")
# Get viewer user ID
from app.models.user import User
q = select(User).where(User.email == "viewer@tenanta.com")
result = await db_session.execute(q)
viewer = result.scalar_one()
@@ -76,6 +75,7 @@ class TestUserManagement:
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
from app.models.user import User
q = select(User).where(User.email == "viewer@tenanta.com")
result = await db_session.execute(q)
viewer = result.scalar_one()
@@ -102,7 +102,9 @@ class TestRoleManagement:
assert "permissions" in data["items"][0]
assert "field_permissions" in data["items"][0]
async def test_create_role_with_custom_permissions_returns_201(self, client: AsyncClient, db_session):
async def test_create_role_with_custom_permissions_returns_201(
self, client: AsyncClient, db_session
):
"""AC 16: POST /api/v1/roles with custom permissions -> 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
@@ -110,7 +112,9 @@ class TestRoleManagement:
"/api/v1/roles",
json={
"name": "manager",
"permissions": {"companies": {"read": True, "create": True, "update": True, "delete": True}},
"permissions": {
"companies": {"read": True, "create": True, "update": True, "delete": True}
},
"field_permissions": {"annual_revenue": "read"},
},
headers=ORIGIN_HEADER,
@@ -172,6 +176,7 @@ class TestFieldPermissions:
# Create a user with sales_rep role
from app.core.auth import hash_password
from app.models.user import User, UserTenant
sales_user = User(
tenant_id=seed["tenant_a"].id,
email="sales@tenanta.com",
@@ -213,6 +218,7 @@ class TestAuditLog:
# Check audit_log table
from sqlalchemy import select as sel
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "create")
result = await db_session.execute(q)
entries = result.scalars().all()
@@ -230,6 +236,7 @@ class TestAuditLog:
assert resp.status_code == 200
from sqlalchemy import select as sel
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "update")
result = await db_session.execute(q)
entries = result.scalars().all()
@@ -246,6 +253,7 @@ class TestAuditLog:
assert resp.status_code == 204
from sqlalchemy import select as sel
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "delete")
result = await db_session.execute(q)
entries = result.scalars().all()
@@ -275,6 +283,7 @@ class TestNotificationOnAssign:
# Check notification was created for the new user
from sqlalchemy import select as sel
q = sel(Notification).where(Notification.user_id == new_user_id)
result = await db_session.execute(q)
notifs = result.scalars().all()
+316 -105
View File
@@ -3,24 +3,28 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from httpx import AsyncClient
from sqlalchemy import select
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.audit import AuditLog
from app.models.notification import Notification
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.services.workflow_service import (
create_workflow, list_workflows, get_workflow, update_workflow, delete_workflow,
create_instance, list_instances, get_instance, advance_instance, cancel_instance,
check_timeout, auto_reject_timeout, find_workflows_for_event, start_instance_for_event,
advance_instance,
auto_reject_timeout,
check_timeout,
create_instance,
create_workflow,
delete_workflow,
find_workflows_for_event,
get_instance,
list_workflows,
start_instance_for_event,
update_workflow,
)
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
# ─── Workflow CRUD (ACs 8-12) ───
@@ -144,6 +148,7 @@ async def test_ac12_delete_workflow(client: AsyncClient, db_session):
# ─── Instance Lifecycle (ACs 13-18) ───
@pytest.mark.asyncio
async def test_ac13_create_instance(client: AsyncClient, db_session):
"""AC13: POST /api/v1/workflows/{id}/instances returns 201, instance created with status=pending."""
@@ -328,6 +333,7 @@ async def test_ac18_cancel_instance(client: AsyncClient, db_session):
# ─── Event-Triggered & Code-Engine (ACs 19-22) ───
@pytest.mark.asyncio
async def test_ac19_event_triggered_workflow(db_session):
"""AC19: Event-triggered workflow — publish event → workflow instance auto-starts."""
@@ -336,8 +342,10 @@ async def test_ac19_event_triggered_workflow(db_session):
admin_id = seed["admin_a"].id
# Create a workflow with trigger_event
wf = await create_workflow(
db_session, tenant_id, admin_id,
await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Event Triggered WF",
"trigger_event": "company.created",
@@ -347,7 +355,9 @@ async def test_ac19_event_triggered_workflow(db_session):
# Simulate event by calling start_instance_for_event
instances = await start_instance_for_event(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
"company.created",
context={"company_id": "test"},
)
@@ -363,7 +373,9 @@ async def test_ac20_step_history_created(db_session):
admin_id = seed["admin_a"].id
wf = await create_workflow(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
{"name": "History WF", "steps": VALID_STEPS},
)
wf_id = wf["id"]
@@ -376,9 +388,7 @@ async def test_ac20_step_history_created(db_session):
# Check step history
result = await db_session.execute(
select(WorkflowStepHistory).where(
WorkflowStepHistory.instance_id == uuid.UUID(inst_id)
)
select(WorkflowStepHistory).where(WorkflowStepHistory.instance_id == uuid.UUID(inst_id))
)
history = result.scalars().all()
assert len(history) >= 3 # entered + approved + entered (next step)
@@ -387,7 +397,7 @@ async def test_ac20_step_history_created(db_session):
@pytest.mark.asyncio
async def test_ac21_onboarding_workflow_on_user_creation(db_session):
"""AC21: Code-engine workflow — onboarding workflow runs on user creation."""
from app.workflows.code.onboarding import trigger_onboarding, get_onboarding_workflow_definition
from app.workflows.code.onboarding import trigger_onboarding
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
@@ -419,7 +429,9 @@ async def test_ac22_approval_timeout_auto_reject(db_session):
# Create workflow with approval step
wf = await create_workflow(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
{
"name": "Timeout WF",
"steps": [
@@ -430,7 +442,9 @@ async def test_ac22_approval_timeout_auto_reject(db_session):
# Create instance with timeout_hours=1
inst = await create_instance(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
wf["id"],
timeout_hours=1,
)
@@ -441,7 +455,7 @@ async def test_ac22_approval_timeout_auto_reject(db_session):
select(WorkflowInstance).where(WorkflowInstance.id == inst_id)
)
instance = result.scalar_one()
instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1)
instance.timeout_at = datetime.now(UTC) - timedelta(hours=1)
await db_session.flush()
# Check timeout detection
@@ -461,6 +475,7 @@ async def test_ac22_approval_timeout_auto_reject(db_session):
# ─── Edge Cases ───
@pytest.mark.asyncio
async def test_workflow_not_found(client: AsyncClient, db_session):
"""Edge case: Get non-existent workflow returns 404."""
@@ -489,7 +504,10 @@ async def test_cancel_completed_instance_fails(client: AsyncClient, db_session):
create_resp = await client.post(
"/api/v1/workflows",
json={"name": "Complete WF", "steps": [{"name": "Only step", "type": "action", "config": {"action_type": "noop"}}]},
json={
"name": "Complete WF",
"steps": [{"name": "Only step", "type": "action", "config": {"action_type": "noop"}}],
},
headers=ORIGIN_HEADER,
)
wf_id = create_resp.json()["id"]
@@ -530,8 +548,11 @@ async def test_workflow_tenant_isolation(client: AsyncClient, db_session):
wf_id = create_resp.json()["id"]
# Login as tenant B
from httpx import ASGITransport, AsyncClient as AC
from httpx import ASGITransport
from httpx import AsyncClient as AC # noqa: N817
import app.main
app_instance = app.main.app
async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b:
@@ -557,6 +578,7 @@ async def test_workflow_create_rbac_viewer_blocked(client: AsyncClient, db_sessi
# ─── WorkflowEngine Unit Tests (engine.py direct coverage) ───
@pytest.mark.asyncio
async def test_engine_process_action_step_noop(db_session):
"""Engine: action step with noop config advances to next step."""
@@ -566,13 +588,18 @@ async def test_engine_process_action_step_noop(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Engine Noop WF",
"steps": [
{"name": "Action", "type": "action", "config": {"action_type": "noop"}},
{"name": "Approval", "type": "approval", "config": {}},
],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
# Fetch the instance object
@@ -596,7 +623,11 @@ async def test_engine_process_action_step_create_notification(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Engine Notif WF",
"steps": [
{
@@ -611,7 +642,8 @@ async def test_engine_process_action_step_create_notification(db_session):
},
},
],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -638,10 +670,15 @@ async def test_engine_process_action_completes_last_step(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Single Step WF",
"steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -664,10 +701,15 @@ async def test_engine_process_approval_step(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Approval WF",
"steps": [{"name": "Approval", "type": "approval", "config": {}}],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -690,7 +732,11 @@ async def test_engine_process_notification_step(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Notif Step WF",
"steps": [
{
@@ -704,7 +750,8 @@ async def test_engine_process_notification_step(db_session):
},
},
],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -730,7 +777,11 @@ async def test_engine_process_condition_eq_true(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond Eq WF",
"steps": [
{
@@ -747,9 +798,13 @@ async def test_engine_process_condition_eq_true(db_session):
{"name": "False Step", "type": "action", "config": {"action_type": "noop"}},
{"name": "True Step", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"status": "active"},
)
@@ -772,7 +827,11 @@ async def test_engine_process_condition_eq_false(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond Eq False WF",
"steps": [
{
@@ -789,9 +848,13 @@ async def test_engine_process_condition_eq_false(db_session):
{"name": "False Step", "type": "action", "config": {"action_type": "noop"}},
{"name": "True Step", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"status": "inactive"},
)
@@ -814,7 +877,11 @@ async def test_engine_process_condition_ne(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond NE WF",
"steps": [
{
@@ -828,9 +895,13 @@ async def test_engine_process_condition_ne(db_session):
},
{"name": "Next", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"level": "high"},
)
@@ -853,7 +924,11 @@ async def test_engine_process_condition_gt(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond GT WF",
"steps": [
{
@@ -863,9 +938,13 @@ async def test_engine_process_condition_gt(db_session):
},
{"name": "Next", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"count": 10},
)
@@ -888,7 +967,11 @@ async def test_engine_process_condition_lt(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond LT WF",
"steps": [
{
@@ -898,9 +981,13 @@ async def test_engine_process_condition_lt(db_session):
},
{"name": "Next", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"count": 50},
)
@@ -923,7 +1010,11 @@ async def test_engine_process_condition_contains(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond Contains WF",
"steps": [
{
@@ -933,9 +1024,13 @@ async def test_engine_process_condition_contains(db_session):
},
{"name": "Next", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"tags": "vip,premium"},
)
@@ -958,7 +1053,11 @@ async def test_engine_process_condition_contains_list(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond List WF",
"steps": [
{
@@ -968,9 +1067,13 @@ async def test_engine_process_condition_contains_list(db_session):
},
{"name": "Next", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"roles": ["admin", "editor"]},
)
@@ -993,7 +1096,11 @@ async def test_engine_process_condition_no_match_advances(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond No Branch WF",
"steps": [
{
@@ -1003,9 +1110,13 @@ async def test_engine_process_condition_no_match_advances(db_session):
},
{"name": "Next", "type": "action", "config": {"action_type": "noop"}},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"status": "inactive"},
)
@@ -1028,7 +1139,11 @@ async def test_engine_process_condition_completes_last_step(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Cond Last WF",
"steps": [
{
@@ -1037,9 +1152,13 @@ async def test_engine_process_condition_completes_last_step(db_session):
"config": {"field": "status", "operator": "eq", "value": "active"},
},
],
})
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"],
db_session,
tenant_id,
admin_id,
wf["id"],
context={"status": "active"},
)
@@ -1062,10 +1181,15 @@ async def test_engine_unknown_step_type(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Unknown Type WF",
"steps": [{"name": "Bad", "type": "unknown_type", "config": {}}],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -1082,19 +1206,25 @@ async def test_engine_unknown_step_type(db_session):
@pytest.mark.asyncio
async def test_engine_workflow_not_found(db_session):
"""Engine: process_step with non-existent workflow returns 404 error."""
from unittest.mock import AsyncMock, MagicMock, patch
from app.workflows.engine import WorkflowEngine
from unittest.mock import MagicMock, patch
from app.models.workflow import WorkflowInstance
from app.workflows.engine import WorkflowEngine
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
# Create a real workflow and instance
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Temp WF for Not Found Test",
"steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -1130,10 +1260,15 @@ async def test_engine_step_index_beyond_steps_completes(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Beyond Steps WF",
"steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}],
})
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -1157,14 +1292,21 @@ async def test_engine_handle_event(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Event Handler WF",
"trigger_event": "company.created",
"steps": VALID_STEPS,
})
},
)
instances = await handle_event(
db_session, tenant_id, "company.created",
db_session,
tenant_id,
"company.created",
{"user_id": str(admin_id), "company_id": "test"},
)
assert len(instances) >= 1
@@ -1180,7 +1322,9 @@ async def test_engine_handle_event_no_match(db_session):
tenant_id = seed["tenant_a"].id
instances = await handle_event(
db_session, tenant_id, "nonexistent.event",
db_session,
tenant_id,
"nonexistent.event",
{"user_id": str(seed["admin_a"].id)},
)
assert instances == []
@@ -1189,8 +1333,9 @@ async def test_engine_handle_event_no_match(db_session):
def test_engine_register_workflow_event_handlers():
"""Engine: register_workflow_event_handlers subscribes to event bus."""
from collections import defaultdict
from app.workflows.engine import register_workflow_event_handlers
from app.core.event_bus import get_event_bus
from app.workflows.engine import register_workflow_event_handlers
event_bus = get_event_bus()
# Reset handlers to a fresh defaultdict for test isolation
@@ -1206,6 +1351,7 @@ def test_engine_register_workflow_event_handlers():
# ─── Route Error Path Tests ───
@pytest.mark.asyncio
async def test_route_update_workflow_not_found(client: AsyncClient, db_session):
"""Route: PATCH non-existent workflow returns 404."""
@@ -1234,11 +1380,16 @@ async def test_route_update_workflow_rbac_blocked(client: AsyncClient, db_sessio
wf_id = create_resp.json()["id"]
# Login as viewer
from httpx import ASGITransport, AsyncClient as AC
from httpx import ASGITransport
from httpx import AsyncClient as AC # noqa: N817
import app.main
app_instance = app.main.app
async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as viewer_client:
async with AC(
transport=ASGITransport(app=app_instance), base_url="http://test"
) as viewer_client:
await login_client(viewer_client, "viewer@tenanta.com")
resp = await viewer_client.patch(
f"/api/v1/workflows/{wf_id}",
@@ -1271,11 +1422,16 @@ async def test_route_delete_workflow_rbac_blocked(client: AsyncClient, db_sessio
)
wf_id = create_resp.json()["id"]
from httpx import ASGITransport, AsyncClient as AC
from httpx import ASGITransport
from httpx import AsyncClient as AC # noqa: N817
import app.main
app_instance = app.main.app
async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as viewer_client:
async with AC(
transport=ASGITransport(app=app_instance), base_url="http://test"
) as viewer_client:
await login_client(viewer_client, "viewer@tenanta.com")
resp = await viewer_client.delete(f"/api/v1/workflows/{wf_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 403
@@ -1330,7 +1486,10 @@ async def test_route_advance_completed_instance_returns_400(client: AsyncClient,
create_resp = await client.post(
"/api/v1/workflows",
json={"name": "Single Step", "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}]},
json={
"name": "Single Step",
"steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}],
},
headers=ORIGIN_HEADER,
)
wf_id = create_resp.json()["id"]
@@ -1395,6 +1554,7 @@ async def test_route_cancel_rejected_instance_returns_400(client: AsyncClient, d
# ─── Service Edge Case Tests ───
@pytest.mark.asyncio
async def test_service_check_timeout_no_timeout_at(db_session):
"""Service: check_timeout returns False when timeout_at is None."""
@@ -1402,9 +1562,15 @@ async def test_service_check_timeout_no_timeout_at(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
"name": "No Timeout WF", "steps": VALID_STEPS,
})
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "No Timeout WF",
"steps": VALID_STEPS,
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"])
result = await db_session.execute(
@@ -1423,11 +1589,21 @@ async def test_service_check_timeout_completed_status(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
"name": "Completed Timeout WF", "steps": VALID_STEPS,
})
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Completed Timeout WF",
"steps": VALID_STEPS,
},
)
inst = await create_instance(
db_session, tenant_id, admin_id, wf["id"], timeout_hours=1,
db_session,
tenant_id,
admin_id,
wf["id"],
timeout_hours=1,
)
result = await db_session.execute(
@@ -1435,7 +1611,7 @@ async def test_service_check_timeout_completed_status(db_session):
)
instance = result.scalar_one()
instance.status = "completed"
instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1)
instance.timeout_at = datetime.now(UTC) - timedelta(hours=1)
await db_session.flush()
is_timed_out = await check_timeout(instance)
@@ -1449,9 +1625,15 @@ async def test_service_auto_reject_no_initiated_by(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
"name": "No Initiator WF", "steps": VALID_STEPS,
})
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "No Initiator WF",
"steps": VALID_STEPS,
},
)
inst = await create_instance(db_session, tenant_id, admin_id, wf["id"], timeout_hours=1)
result = await db_session.execute(
@@ -1459,7 +1641,7 @@ async def test_service_auto_reject_no_initiated_by(db_session):
)
instance = result.scalar_one()
instance.initiated_by = None
instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1)
instance.timeout_at = datetime.now(UTC) - timedelta(hours=1)
await db_session.flush()
reject_result = await auto_reject_timeout(db_session, tenant_id, instance)
@@ -1484,7 +1666,9 @@ async def test_service_start_instance_no_match(db_session):
admin_id = seed["admin_a"].id
instances = await start_instance_for_event(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
"nonexistent.event",
context={},
)
@@ -1499,13 +1683,27 @@ async def test_service_list_workflows_with_active_filter(db_session):
admin_id = seed["admin_a"].id
# Create active workflow
await create_workflow(db_session, tenant_id, admin_id, {
"name": "Active WF", "steps": VALID_STEPS, "is_active": True,
})
await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Active WF",
"steps": VALID_STEPS,
"is_active": True,
},
)
# Create inactive workflow
await create_workflow(db_session, tenant_id, admin_id, {
"name": "Inactive WF", "steps": VALID_STEPS, "is_active": False,
})
await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Inactive WF",
"steps": VALID_STEPS,
"is_active": False,
},
)
result = await list_workflows(db_session, tenant_id, is_active=True)
assert result["total"] == 1
@@ -1519,15 +1717,27 @@ async def test_service_update_workflow_with_steps(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
wf = await create_workflow(db_session, tenant_id, admin_id, {
"name": "Steps Update WF", "steps": VALID_STEPS,
})
wf = await create_workflow(
db_session,
tenant_id,
admin_id,
{
"name": "Steps Update WF",
"steps": VALID_STEPS,
},
)
new_steps = [{"name": "New Step", "type": "action", "config": {"action_type": "noop"}}]
result = await update_workflow(db_session, tenant_id, admin_id, wf["id"], {
result = await update_workflow(
db_session,
tenant_id,
admin_id,
wf["id"],
{
"steps": new_steps,
"is_active": False,
})
},
)
assert result is not None
assert len(result["steps"]) == 1
assert result["steps"][0]["name"] == "New Step"
@@ -1541,7 +1751,9 @@ async def test_service_update_workflow_not_found(db_session):
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
result = await update_workflow(db_session, tenant_id, admin_id, str(uuid.uuid4()), {"name": "X"})
result = await update_workflow(
db_session, tenant_id, admin_id, str(uuid.uuid4()), {"name": "X"}
)
assert result is None
@@ -1559,7 +1771,6 @@ async def test_service_delete_workflow_not_found(db_session):
@pytest.mark.asyncio
async def test_service_get_instance_not_found(db_session):
"""Service: get_instance returns None for non-existent instance."""
from app.services.workflow_service import get_instance
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id