chore: fix all ruff lint errors + format — 0 errors, 306 tests pass
This commit is contained in:
+136
-105
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -9,7 +9,6 @@ import redis.asyncio as aioredis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_cache_redis: aioredis.Redis | None = None
|
||||
|
||||
|
||||
|
||||
+13
-15
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+20
-10
@@ -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,9 +43,13 @@ 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(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
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
|
||||
|
||||
@@ -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,10 +108,14 @@ 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(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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}>"
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
+61
-15
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+52
-48
@@ -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,50 +309,56 @@ class PluginRegistry:
|
||||
for name, plugin in self._plugins.items():
|
||||
record = db_records.get(name)
|
||||
if record is not None:
|
||||
plugins_list.append({
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"description": plugin.manifest.description,
|
||||
"dependencies": plugin.manifest.dependencies,
|
||||
"events": plugin.manifest.events,
|
||||
"migrations": plugin.manifest.migrations,
|
||||
"permissions": plugin.manifest.permissions,
|
||||
})
|
||||
plugins_list.append(
|
||||
{
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"description": plugin.manifest.description,
|
||||
"dependencies": plugin.manifest.dependencies,
|
||||
"events": plugin.manifest.events,
|
||||
"migrations": plugin.manifest.migrations,
|
||||
"permissions": plugin.manifest.permissions,
|
||||
}
|
||||
)
|
||||
else:
|
||||
plugins_list.append({
|
||||
"name": name,
|
||||
"display_name": plugin.manifest.display_name,
|
||||
"version": plugin.version,
|
||||
"status": "discovered",
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"description": plugin.manifest.description,
|
||||
"dependencies": plugin.manifest.dependencies,
|
||||
"events": plugin.manifest.events,
|
||||
"migrations": plugin.manifest.migrations,
|
||||
"permissions": plugin.manifest.permissions,
|
||||
})
|
||||
plugins_list.append(
|
||||
{
|
||||
"name": name,
|
||||
"display_name": plugin.manifest.display_name,
|
||||
"version": plugin.version,
|
||||
"status": "discovered",
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"description": plugin.manifest.description,
|
||||
"dependencies": plugin.manifest.dependencies,
|
||||
"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({
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"description": "",
|
||||
"dependencies": [],
|
||||
"events": [],
|
||||
"migrations": [],
|
||||
"permissions": [],
|
||||
})
|
||||
plugins_list.append(
|
||||
{
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"description": "",
|
||||
"dependencies": [],
|
||||
"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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
@@ -173,7 +175,7 @@ async def execute_action(
|
||||
select(AIConversation).where(
|
||||
AIConversation.id == conv_uuid,
|
||||
AIConversation.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
if conversation is None:
|
||||
@@ -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(
|
||||
AIMessage.conversation_id == conv.id,
|
||||
AIMessage.tenant_id == tenant_id,
|
||||
).order_by(AIMessage.message_index)
|
||||
msg_q = (
|
||||
select(AIMessage)
|
||||
.where(
|
||||
AIMessage.conversation_id == conv.id,
|
||||
AIMessage.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(AIMessage.message_index)
|
||||
)
|
||||
msg_result = await db.execute(msg_q)
|
||||
messages = msg_result.scalars().all()
|
||||
|
||||
items.append({
|
||||
**_conversation_to_dict(conv),
|
||||
"messages": [_message_to_dict(m) for m in messages],
|
||||
})
|
||||
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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,16 +129,18 @@ async def get_company_detail(
|
||||
contacts_result = await db.execute(contacts_q)
|
||||
contacts_list = []
|
||||
for contact, link in contacts_result.all():
|
||||
contacts_list.append({
|
||||
"id": str(contact.id),
|
||||
"first_name": contact.first_name,
|
||||
"last_name": contact.last_name,
|
||||
"email": contact.email,
|
||||
"phone": contact.phone,
|
||||
"position": contact.position,
|
||||
"role_at_company": link.role_at_company,
|
||||
"is_primary": link.is_primary,
|
||||
})
|
||||
contacts_list.append(
|
||||
{
|
||||
"id": str(contact.id),
|
||||
"first_name": contact.first_name,
|
||||
"last_name": contact.last_name,
|
||||
"email": contact.email,
|
||||
"phone": contact.phone,
|
||||
"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)
|
||||
|
||||
@@ -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({
|
||||
"id": str(company.id),
|
||||
"name": company.name,
|
||||
"industry": company.industry,
|
||||
"role_at_company": link.role_at_company,
|
||||
"is_primary": link.is_primary,
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
).order_by(Contact.last_name, Contact.first_name)
|
||||
q = (
|
||||
select(Contact)
|
||||
.where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
.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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
WorkflowStepHistory.instance_id == inst_uuid,
|
||||
WorkflowStepHistory.tenant_id == tenant_id,
|
||||
).order_by(WorkflowStepHistory.created_at)
|
||||
hist_q = (
|
||||
select(WorkflowStepHistory)
|
||||
.where(
|
||||
WorkflowStepHistory.instance_id == inst_uuid,
|
||||
WorkflowStepHistory.tenant_id == tenant_id,
|
||||
)
|
||||
.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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user