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

This commit is contained in:
leocrm-bot
2026-06-29 17:43:56 +02:00
parent 316f323ff4
commit a2452cc04b
81 changed files with 2317 additions and 1128 deletions
+136 -105
View File
@@ -9,19 +9,26 @@ from __future__ import annotations
import re
from typing import Any
# Precompiled patterns for intent detection
_PATTERNS = {
'create_company': re.compile(r'\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b', re.IGNORECASE),
'delete_company': re.compile(r'\b(delete|remove)\b.*\b(company|firm)\b', re.IGNORECASE),
'update_company': re.compile(r'\b(update|edit|modify|change)\b.*\b(company|firm)\b', re.IGNORECASE),
'list_company': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b', re.IGNORECASE),
'list_company2': re.compile(r'\bcompan.*\b(list|all)\b', re.IGNORECASE),
'create_contact': re.compile(r'\b(create|add|new)\b.*\b(contact|person)\b', re.IGNORECASE),
'list_contact': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(contact|person)\b', re.IGNORECASE),
'list_workflow': re.compile(r'\b(list|show|get|display)\b.*\b(workflow)\b', re.IGNORECASE),
'create_workflow': re.compile(r'\b(create|new|add)\b.*\b(workflow)\b', re.IGNORECASE),
'help': re.compile(r'\b(help|what can you do|assist)\b', re.IGNORECASE),
"create_company": re.compile(
r"\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b", re.IGNORECASE
),
"delete_company": re.compile(r"\b(delete|remove)\b.*\b(company|firm)\b", re.IGNORECASE),
"update_company": re.compile(
r"\b(update|edit|modify|change)\b.*\b(company|firm)\b", re.IGNORECASE
),
"list_company": re.compile(
r"\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b", re.IGNORECASE
),
"list_company2": re.compile(r"\bcompan.*\b(list|all)\b", re.IGNORECASE),
"create_contact": re.compile(r"\b(create|add|new)\b.*\b(contact|person)\b", re.IGNORECASE),
"list_contact": re.compile(
r"\b(list|show|find|search|get|display)\b.*\b(contact|person)\b", re.IGNORECASE
),
"list_workflow": re.compile(r"\b(list|show|get|display)\b.*\b(workflow)\b", re.IGNORECASE),
"create_workflow": re.compile(r"\b(create|new|add)\b.*\b(workflow)\b", re.IGNORECASE),
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
}
# Name extraction patterns - using single-quoted strings to avoid escaping issues
@@ -36,10 +43,14 @@ _NAME_PATTERNS = [
# Field extraction patterns
_INDUSTRY_PAT = re.compile(r"industry\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_NAME_UPDATE_PAT = re.compile(r"(?:name|rename)\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_NAME_UPDATE_PAT = re.compile(
r"(?:name|rename)\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE
)
_PHONE_PAT = re.compile(r"phone\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_EMAIL_PAT = re.compile(r"email\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_SEARCH_PAT = re.compile(r"\b(?:named|called|matching|with name)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
_SEARCH_PAT = re.compile(
r"\b(?:named|called|matching|with name)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE
)
def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> list[dict[str, Any]]:
@@ -53,109 +64,129 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
actions: list[dict[str, Any]] = []
# --- Company intents ---
if _PATTERNS['create_company'].search(q):
if _PATTERNS["create_company"].search(q):
name = _extract_name(query)
actions.append({
'method': 'POST',
'path': '/api/v1/companies',
'body': {'name': name or 'New Company'},
'description': f"Create a new company named '{name or 'New Company'}'",
'confidence': 0.9,
})
actions.append(
{
"method": "POST",
"path": "/api/v1/companies",
"body": {"name": name or "New Company"},
"description": f"Create a new company named '{name or 'New Company'}'",
"confidence": 0.9,
}
)
elif _PATTERNS['delete_company'].search(q):
entity_id = context.get('company_id') or context.get('entity_id')
elif _PATTERNS["delete_company"].search(q):
entity_id = context.get("company_id") or context.get("entity_id")
if entity_id:
actions.append({
'method': 'DELETE',
'path': f'/api/v1/companies/{entity_id}',
'body': None,
'description': f'Delete company {entity_id}',
'confidence': 0.9,
})
actions.append(
{
"method": "DELETE",
"path": f"/api/v1/companies/{entity_id}",
"body": None,
"description": f"Delete company {entity_id}",
"confidence": 0.9,
}
)
else:
actions.append({
'method': 'DELETE',
'path': '/api/v1/companies/{id}',
'body': None,
'description': 'Delete a company (requires company ID in context or selection)',
'confidence': 0.5,
})
actions.append(
{
"method": "DELETE",
"path": "/api/v1/companies/{id}",
"body": None,
"description": "Delete a company (requires company ID in context or selection)",
"confidence": 0.5,
}
)
elif _PATTERNS['update_company'].search(q):
entity_id = context.get('company_id') or context.get('entity_id')
path = f'/api/v1/companies/{entity_id}' if entity_id else '/api/v1/companies/{id}'
actions.append({
'method': 'PATCH',
'path': path,
'body': _extract_update_fields(query),
'description': 'Update company information',
'confidence': 0.8,
})
elif _PATTERNS["update_company"].search(q):
entity_id = context.get("company_id") or context.get("entity_id")
path = f"/api/v1/companies/{entity_id}" if entity_id else "/api/v1/companies/{id}"
actions.append(
{
"method": "PATCH",
"path": path,
"body": _extract_update_fields(query),
"description": "Update company information",
"confidence": 0.8,
}
)
elif _PATTERNS['list_company'].search(q) or _PATTERNS['list_company2'].search(q):
elif _PATTERNS["list_company"].search(q) or _PATTERNS["list_company2"].search(q):
search_term = _extract_search_term(query)
desc = 'List companies'
desc = "List companies"
if search_term:
desc += f" matching '{search_term}'"
actions.append({
'method': 'GET',
'path': '/api/v1/companies',
'body': None,
'description': desc,
'confidence': 0.85,
})
actions.append(
{
"method": "GET",
"path": "/api/v1/companies",
"body": None,
"description": desc,
"confidence": 0.85,
}
)
# --- Contact intents ---
elif _PATTERNS['create_contact'].search(q):
elif _PATTERNS["create_contact"].search(q):
name = _extract_name(query)
actions.append({
'method': 'POST',
'path': '/api/v1/contacts',
'body': {'name': name or 'New Contact'},
'description': f"Create a new contact named '{name or 'New Contact'}'",
'confidence': 0.9,
})
actions.append(
{
"method": "POST",
"path": "/api/v1/contacts",
"body": {"name": name or "New Contact"},
"description": f"Create a new contact named '{name or 'New Contact'}'",
"confidence": 0.9,
}
)
elif _PATTERNS['list_contact'].search(q):
actions.append({
'method': 'GET',
'path': '/api/v1/contacts',
'body': None,
'description': 'List contacts',
'confidence': 0.85,
})
elif _PATTERNS["list_contact"].search(q):
actions.append(
{
"method": "GET",
"path": "/api/v1/contacts",
"body": None,
"description": "List contacts",
"confidence": 0.85,
}
)
# --- Workflow intents ---
elif _PATTERNS['list_workflow'].search(q):
actions.append({
'method': 'GET',
'path': '/api/v1/workflows',
'body': None,
'description': 'List workflows',
'confidence': 0.85,
})
elif _PATTERNS["list_workflow"].search(q):
actions.append(
{
"method": "GET",
"path": "/api/v1/workflows",
"body": None,
"description": "List workflows",
"confidence": 0.85,
}
)
elif _PATTERNS['create_workflow'].search(q):
elif _PATTERNS["create_workflow"].search(q):
name = _extract_name(query)
actions.append({
'method': 'POST',
'path': '/api/v1/workflows',
'body': {'name': name or 'New Workflow', 'steps': []},
'description': 'Create a new workflow',
'confidence': 0.8,
})
actions.append(
{
"method": "POST",
"path": "/api/v1/workflows",
"body": {"name": name or "New Workflow", "steps": []},
"description": "Create a new workflow",
"confidence": 0.8,
}
)
# --- Generic fallback ---
if not actions:
if _PATTERNS['help'].search(q):
actions.append({
'method': 'GET',
'path': '/api/v1/companies',
'body': None,
'description': 'Show available companies (demonstration action)',
'confidence': 0.3,
})
if _PATTERNS["help"].search(q):
actions.append(
{
"method": "GET",
"path": "/api/v1/companies",
"body": None,
"description": "Show available companies (demonstration action)",
"confidence": 0.3,
}
)
return actions
@@ -180,20 +211,20 @@ def _extract_search_term(query: str) -> str | None:
def _extract_update_fields(query: str) -> dict[str, Any]:
"""Extract fields to update from the query."""
fields: dict[str, Any] = {}
if re.search(r'\bindustry\b', query, re.IGNORECASE):
if re.search(r"\bindustry\b", query, re.IGNORECASE):
match = _INDUSTRY_PAT.search(query)
if match:
fields['industry'] = match.group(1).strip()
if re.search(r'\b(name|rename)\b', query, re.IGNORECASE):
fields["industry"] = match.group(1).strip()
if re.search(r"\b(name|rename)\b", query, re.IGNORECASE):
match = _NAME_UPDATE_PAT.search(query)
if match:
fields['name'] = match.group(1).strip()
if re.search(r'\bphone\b', query, re.IGNORECASE):
fields["name"] = match.group(1).strip()
if re.search(r"\bphone\b", query, re.IGNORECASE):
match = _PHONE_PAT.search(query)
if match:
fields['phone'] = match.group(1).strip()
if re.search(r'\bemail\b', query, re.IGNORECASE):
fields["phone"] = match.group(1).strip()
if re.search(r"\bemail\b", query, re.IGNORECASE):
match = _EMAIL_PAT.search(query)
if match:
fields['email'] = match.group(1).strip()
return fields or {'name': 'Updated Name'}
fields["email"] = match.group(1).strip()
return fields or {"name": "Updated Name"}
+22 -6
View File
@@ -7,9 +7,9 @@ tests to run without external API dependencies.
from __future__ import annotations
import os
import json
import logging
import os
from typing import Any
import httpx
@@ -20,7 +20,9 @@ logger = logging.getLogger(__name__)
class LLMResponse:
"""Structured LLM response containing proposed actions."""
def __init__(self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8):
def __init__(
self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8
):
self.message = message
self.proposed_actions = proposed_actions
self.confidence = confidence
@@ -41,7 +43,9 @@ class LLMClient:
- Otherwise: mock/stub mode with keyword-based action mapping
"""
def __init__(self, model: str | None = None, api_key: str | None = None, api_base: str | None = None):
def __init__(
self, model: str | None = None, api_key: str | None = None, api_base: str | None = None
):
self.model = model or os.environ.get("AI_MODEL", "")
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
self.api_base = api_base or os.environ.get("AI_API_BASE", "https://api.openai.com/v1")
@@ -118,9 +122,21 @@ class LLMClient:
available_apis = [
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"},
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"},
{"method": "GET", "path": "/api/v1/companies/{id}", "description": "Get company details"},
{"method": "PATCH", "path": "/api/v1/companies/{id}", "description": "Update a company"},
{"method": "DELETE", "path": "/api/v1/companies/{id}", "description": "Delete a company"},
{
"method": "GET",
"path": "/api/v1/companies/{id}",
"description": "Get company details",
},
{
"method": "PATCH",
"path": "/api/v1/companies/{id}",
"description": "Update a company",
},
{
"method": "DELETE",
"path": "/api/v1/companies/{id}",
"description": "Delete a company",
},
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts"},
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact"},
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
+12 -7
View File
@@ -5,20 +5,20 @@ from __future__ import annotations
import hashlib
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
import redis.asyncio as aioredis
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.user import User, UserTenant
from app.models.role import Role
from app.models.session import Session as SessionModel
from app.models.user import User
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds)
_pwd_context = CryptContext(
schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds
)
def hash_password(password: str) -> str:
@@ -63,7 +63,7 @@ async def create_session(
settings = get_settings()
session_id = str(uuid.uuid4())
csrf_token = generate_csrf_token()
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.session_ttl_seconds)
expires_at = datetime.now(UTC) + timedelta(seconds=settings.session_ttl_seconds)
# Redis runtime session
session_data: dict[str, Any] = {
@@ -76,6 +76,7 @@ async def create_session(
"is_active": user.is_active,
}
import json
await redis.setex(
f"session:{session_id}",
settings.session_ttl_seconds,
@@ -99,6 +100,7 @@ async def create_session(
async def get_session_data(redis: aioredis.Redis, session_id: str) -> dict[str, Any] | None:
"""Retrieve session data from Redis."""
import json
raw = await redis.get(f"session:{session_id}")
if raw is None:
return None
@@ -117,6 +119,7 @@ async def update_session_tenant(
) -> dict[str, Any] | None:
"""Update the active tenant in a Redis session."""
import json
settings = get_settings()
raw = await redis.get(f"session:{session_id}")
if raw is None:
@@ -130,7 +133,9 @@ async def update_session_tenant(
return data
def check_permission(role_name: str, module: str, action: str, permissions: dict | None = None) -> bool:
def check_permission(
role_name: str, module: str, action: str, permissions: dict | None = None
) -> bool:
"""Check if a role has permission for a module+action.
Built-in roles: admin (all), editor (read+write), viewer (read only).
Custom roles use the permissions dict.
-1
View File
@@ -9,7 +9,6 @@ import redis.asyncio as aioredis
from app.config import get_settings
_cache_redis: aioredis.Redis | None = None
+13 -15
View File
@@ -3,10 +3,14 @@
from __future__ import annotations
import contextlib
import uuid
from collections.abc import AsyncGenerator
from typing import Any
from datetime import datetime
from typing import Any # noqa: F401
from sqlalchemy import event as sa_event
from sqlalchemy import DateTime, String, func, text # noqa: F401
from sqlalchemy import event as sa_event # noqa: F401
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
@@ -14,10 +18,6 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy import text, String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
import uuid
from datetime import datetime
from app.config import get_settings
@@ -26,8 +26,8 @@ class Base(DeclarativeBase):
"""Declarative base with shared columns and tenant-scoping support."""
@declared_attr.directive
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"
def __tablename__(self) -> str:
return self.__name__.lower() + "s"
class TimestampMixin:
@@ -44,9 +44,7 @@ class TimestampMixin:
class TenantMixin(TimestampMixin):
"""Adds tenant_id column and enables ORM-level auto-filtering."""
tenant_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), nullable=False, index=True
)
tenant_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False, index=True)
# Global engine and session factory
@@ -101,7 +99,9 @@ async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str)
@contextlib.asynccontextmanager
async def create_db_session(tenant_id: uuid.UUID | str | None = None) -> AsyncGenerator[AsyncSession, None]:
async def create_db_session(
tenant_id: uuid.UUID | str | None = None,
) -> AsyncGenerator[AsyncSession, None]:
"""Create a standalone session outside FastAPI (e.g. for tests/workers)."""
factory = get_session_factory()
async with factory() as session:
@@ -128,7 +128,5 @@ def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSes
"""Replace the global engine with a test engine. Returns a session factory."""
global _engine, _session_factory
_engine = engine
_session_factory = async_sessionmaker(
bind=engine, expire_on_commit=False, class_=AsyncSession
)
_session_factory = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
return _session_factory
+3 -1
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any, Callable, Coroutine
from collections.abc import Callable, Coroutine
from typing import Any
EventHandler = Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
@@ -48,4 +49,5 @@ def register_workflow_event_handlers() -> None:
Should be called during application startup.
"""
from app.workflows.engine import register_workflow_event_handlers as _register
_register()
+20 -10
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import select, func, update
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
@@ -42,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
-1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from fastapi import HTTPException, Request, status
from app.core.auth import get_redis
from app.config import get_settings
async def check_rate_limit(
+1 -3
View File
@@ -3,10 +3,7 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import event as sa_event
from sqlalchemy.orm import Session, with_loader_criteria
from sqlalchemy.sql import Select
from app.core.db import TenantMixin
@@ -21,4 +18,5 @@ def apply_tenant_filter(query: Select, tenant_id: uuid.UUID) -> Select:
async def set_rls_context(session, tenant_id: uuid.UUID | str) -> None:
"""Set PostgreSQL RLS session variable."""
from app.core.db import set_tenant_context
await set_tenant_context(session, tenant_id)
-2
View File
@@ -7,13 +7,11 @@ from typing import Any
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis, get_session_data
from app.core.db import get_db, set_tenant_context
from app.models.user import User
async def get_redis_dep() -> aioredis.Redis:
+15 -2
View File
@@ -8,11 +8,24 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.core.middleware import CSRFMiddleware
from app.core.db import close_engine, get_engine
from app.core.middleware import CSRFMiddleware
from app.core.service_container import get_container
from app.plugins.registry import get_registry
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
from app.routes import (
ai_copilot,
auth,
companies,
contacts,
health,
import_export,
notifications,
plugins,
roles,
tenants,
users,
workflows,
)
@asynccontextmanager
+43 -20
View File
@@ -1,32 +1,55 @@
"""SQLAlchemy models for LeoCRM."""
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.audit import AuditLog, DeletionLog
from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.models.notification import Notification
from app.models.plugin import Plugin, PluginMigration
from app.models.role import Role
from app.models.session import Session
from app.models.audit import AuditLog, DeletionLog
from app.models.notification import Notification
from app.models.auth import PasswordResetToken, ApiToken
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.plugin import Plugin, PluginMigration
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
__all__ = [
"Tenant", "User", "UserTenant", "Role", "Session",
"AuditLog", "DeletionLog", "Notification",
"PasswordResetToken", "ApiToken", "Company",
"Contact", "CompanyContact",
"Plugin", "PluginMigration",
"Tenant",
"User",
"UserTenant",
"Role",
"Session",
"AuditLog",
"DeletionLog",
"Notification",
"PasswordResetToken",
"ApiToken",
"Company",
"Contact",
"CompanyContact",
"Plugin",
"PluginMigration",
]
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
__all__ = [
"Tenant", "User", "UserTenant", "Role", "Session",
"AuditLog", "DeletionLog", "Notification",
"PasswordResetToken", "ApiToken", "Company",
"Contact", "CompanyContact",
"Plugin", "PluginMigration",
"AIConversation", "AIMessage",
"Workflow", "WorkflowInstance", "WorkflowStepHistory",
"Tenant",
"User",
"UserTenant",
"Role",
"Session",
"AuditLog",
"DeletionLog",
"Notification",
"PasswordResetToken",
"ApiToken",
"Company",
"Contact",
"CompanyContact",
"Plugin",
"PluginMigration",
"AIConversation",
"AIMessage",
"Workflow",
"WorkflowInstance",
"WorkflowStepHistory",
]
+9 -10
View File
@@ -3,11 +3,11 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -17,9 +17,7 @@ class AIConversation(Base, TenantMixin):
"""AI Copilot conversation thread — tenant-scoped."""
__tablename__ = "ai_conversations"
__table_args__ = (
Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),
)
__table_args__ = (Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
@@ -35,15 +33,16 @@ class AIMessage(Base, TenantMixin):
"""Individual messages within an AI conversation — user input, AI response, actions."""
__tablename__ = "ai_messages"
__table_args__ = (
Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),
)
__table_args__ = (Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False, index=True
PGUUID(as_uuid=True),
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
content: Mapped[str] = mapped_column(Text, nullable=False)
+3 -2
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, DateTime, ForeignKey, func, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
+4 -5
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Index
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -32,9 +33,7 @@ class ApiToken(Base, TenantMixin):
"""API token for programmatic access."""
__tablename__ = "api_tokens"
__table_args__ = (
Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),
)
__table_args__ = (Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+4 -5
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, Computed
from sqlalchemy.dialects.postgresql import UUID as PGUUID, TSVECTOR
from sqlalchemy import Computed, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -34,9 +35,7 @@ class Company(Base, TenantMixin):
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# FTS vector — PostgreSQL generated column, auto-updated on insert/update
search_tsv: Mapped[Any] = mapped_column(
TSVECTOR,
+5 -9
View File
@@ -6,12 +6,12 @@ import uuid
from datetime import datetime
from sqlalchemy import (
String,
Text,
DateTime,
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
@@ -42,9 +42,7 @@ class Contact(Base, TenantMixin):
department: Mapped[str | None] = mapped_column(String(100), nullable=True)
linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -62,9 +60,7 @@ class CompanyContact(Base, TenantMixin):
__tablename__ = "company_contacts"
__table_args__ = (
UniqueConstraint(
"company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"
),
UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
Index("ix_cc_company", "company_id"),
Index("ix_cc_contact", "contact_id"),
)
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text, func, Index
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
+8 -18
View File
@@ -3,10 +3,9 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Boolean, Text, DateTime, func, Index
from sqlalchemy import Boolean, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -21,9 +20,7 @@ class Plugin(Base, TimestampMixin):
"""
__tablename__ = "plugins"
__table_args__ = (
Index("ix_plugins_name", "name", unique=True),
)
__table_args__ = (Index("ix_plugins_name", "name", unique=True),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
@@ -31,17 +28,12 @@ class Plugin(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
version: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="installed"
)
installed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed")
installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
config: Mapped[dict[str, Any] | None] = mapped_column(
Text, nullable=True # JSON string for plugin configuration
Text,
nullable=True, # JSON string for plugin configuration
)
# Transient attribute for response (not persisted)
@@ -63,6 +55,4 @@ class PluginMigration(Base, TimestampMixin):
)
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="applied"
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
+3 -2
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
+5 -6
View File
@@ -6,9 +6,10 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -17,9 +18,7 @@ class User(Base, TenantMixin):
"""User entity — belongs to a tenant, can be member of multiple tenants."""
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+17 -14
View File
@@ -6,8 +6,9 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer, Boolean
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
@@ -48,7 +49,10 @@ class WorkflowInstance(Base, TenantMixin):
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
workflow_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False, index=True
PGUUID(as_uuid=True),
ForeignKey("workflows.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending")
# pending → in_progress → completed / rejected / cancelled
@@ -57,32 +61,31 @@ class WorkflowInstance(Base, TenantMixin):
initiated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timeout_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
timeout_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
timeout_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class WorkflowStepHistory(Base, TenantMixin):
"""Immutable record of every step transition in a workflow instance."""
__tablename__ = "workflow_step_history"
__table_args__ = (
Index("ix_wf_step_history_tenant_instance", "tenant_id", "instance_id"),
)
__table_args__ = (Index("ix_wf_step_history_tenant_instance", "tenant_id", "instance_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
instance_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("workflow_instances.id", ondelete="CASCADE"), nullable=False, index=True
PGUUID(as_uuid=True),
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False) # entered, approved, rejected, skipped, cancelled
action: Mapped[str] = mapped_column(
String(50), nullable=False
) # entered, approved, rejected, skipped, cancelled
actor_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+2 -2
View File
@@ -1,8 +1,8 @@
"""Plugin system framework for LeoCRM."""
from app.plugins.manifest import PluginManifest
from app.plugins.base import BasePlugin
from app.plugins.registry import get_registry
from app.plugins.manifest import PluginManifest
from app.plugins.migration_runner import MigrationRunner
from app.plugins.registry import get_registry
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
+7 -1
View File
@@ -11,6 +11,7 @@ from app.plugins.manifest import PluginManifest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import EventBus
from app.core.service_container import ServiceContainer
@@ -85,6 +86,7 @@ class BasePlugin(ABC):
routers: list[APIRouter] = []
for route_def in self.manifest.routes:
import importlib
module = importlib.import_module(route_def.module)
router: APIRouter = getattr(module, route_def.router_attr)
routers.append(router)
@@ -106,9 +108,11 @@ class BasePlugin(ABC):
fallback = getattr(self, "on_event", None)
if fallback is not None:
return fallback
# Default no-op handler
async def _noop_handler(payload: dict[str, Any]) -> None:
pass
return _noop_handler
# ─── Utility ───
@@ -122,4 +126,6 @@ class BasePlugin(ABC):
return self.manifest.version
def __repr__(self) -> str:
return f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
return (
f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
)
+2 -2
View File
@@ -7,8 +7,8 @@ Subdirectory plugins (tags, permissions, entity_links) export their plugin
class via __init__.py so the registry can discover them as packages.
"""
from app.plugins.builtins.tags import TagsPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.entity_links import EntityLinksPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.tags import TagsPlugin
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin"]
+6 -5
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy import Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -17,7 +17,10 @@ class EntityLink(Base, TenantMixin):
__tablename__ = "entity_links"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "entity_type", "entity_id",
"tenant_id",
"file_id",
"entity_type",
"entity_id",
name="uq_entity_links_file_entity",
),
Index("ix_entity_links_file", "tenant_id", "file_id"),
@@ -30,6 +33,4 @@ class EntityLink(Base, TenantMixin):
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
entity_type: Mapped[str] = mapped_column(String(20), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
@@ -42,6 +42,7 @@ class EntityLinksPlugin(BasePlugin):
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
"""Handle company.deleted event — remove all EntityLink rows for that company."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
@@ -51,6 +52,7 @@ class EntityLinksPlugin(BasePlugin):
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
@@ -65,6 +67,7 @@ class EntityLinksPlugin(BasePlugin):
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
@@ -74,6 +77,7 @@ class EntityLinksPlugin(BasePlugin):
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
+7 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -24,7 +24,9 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.post("/files/{file_id}/link")
@@ -41,7 +43,9 @@ async def link_file_to_entity(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Check if link already exists
existing = await db.execute(
+8 -11
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy import DateTime, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -18,7 +18,10 @@ class Permission(Base, TenantMixin):
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "user_id", "access_level",
"tenant_id",
"file_id",
"user_id",
"access_level",
name="uq_permissions_file_user_level",
),
Index("ix_permissions_file", "tenant_id", "file_id"),
@@ -30,9 +33,7 @@ class Permission(Base, TenantMixin):
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
group_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read")
@@ -51,10 +52,6 @@ class ShareLink(Base, TenantMixin):
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="download")
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
@@ -2,10 +2,6 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
+27 -16
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password, verify_password
@@ -28,11 +28,14 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
# ─── Permissions CRUD ───
@router.get("/files/{file_id}/permissions")
async def list_permissions(
file_id: str,
@@ -85,7 +88,9 @@ async def grant_permission(
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Permission already exists", "code": "duplicate"})
raise HTTPException(
409, detail={"detail": "Permission already exists", "code": "duplicate"}
)
perm = Permission(
tenant_id=tenant_id,
@@ -135,8 +140,13 @@ async def revoke_permission(
# ─── Permission Check Helper ───
async def check_user_file_permission(
db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID, user_id: uuid.UUID, required: str = "read"
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
required: str = "read",
) -> bool:
"""Check if user has required permission on a file."""
result = await db.execute(
@@ -158,6 +168,7 @@ async def check_user_file_permission(
# ─── Share Links ───
@router.post("/files/{file_id}/share-link")
async def create_share_link(
file_id: str,
@@ -219,6 +230,7 @@ async def revoke_share_link(
# ─── Public Share Access (NO AUTH) ───
@public_router.get("/share/{token}")
async def public_access(
token: str,
@@ -229,23 +241,20 @@ async def public_access(
Returns 410 Gone if link is expired.
Returns 403 if password is required but not provided/invalid.
"""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
result = await db.execute(select(ShareLink).where(ShareLink.token == token))
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Check password
if link.password_hash is not None:
# Password must be provided via query param or header — check query param
from fastapi import Request
# We need the request to get the password param
# For simplicity, return that password is required
raise HTTPException(
@@ -267,25 +276,27 @@ async def public_access_with_password(
db: AsyncSession = Depends(get_db),
):
"""Public share access with password verification — no auth required."""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
result = await db.execute(select(ShareLink).where(ShareLink.token == token))
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Verify password if required
if link.password_hash is not None:
if body.password is None:
raise HTTPException(401, detail={"detail": "Password required", "code": "password_required"})
raise HTTPException(
401, detail={"detail": "Password required", "code": "password_required"}
)
if not verify_password(body.password, link.password_hash):
raise HTTPException(403, detail={"detail": "Invalid password", "code": "invalid_password"})
raise HTTPException(
403, detail={"detail": "Invalid password", "code": "invalid_password"}
)
return {
"file_id": str(link.file_id),
+5 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -33,7 +33,10 @@ class TagAssignment(Base, TenantMixin):
__tablename__ = "tag_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id", "tag_id", "entity_type", "entity_id",
"tenant_id",
"tag_id",
"entity_type",
"entity_id",
name="uq_tag_assignments_entity",
),
Index("ix_tag_assignments_tag", "tenant_id", "tag_id"),
+41 -28
View File
@@ -5,18 +5,18 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, func, delete
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import (
TagCreate,
TagUpdate,
TagAssignRequest,
TagUnassignRequest,
TagBulkAssignRequest,
TagCreate,
TagUnassignRequest,
TagUpdate,
)
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
@@ -28,7 +28,9 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.get("")
@@ -107,9 +109,7 @@ async def update_tag(
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
@@ -122,7 +122,9 @@ async def update_tag(
select(Tag).where(Tag.tenant_id == tenant_id, Tag.name == data["name"])
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
raise HTTPException(
409, detail={"detail": "Tag name already exists", "code": "duplicate"}
)
tag.name = data["name"]
if "color" in data:
tag.color = data["color"]
@@ -148,12 +150,12 @@ async def assign_tag(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id)
)
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id))
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "tag_not_found"})
@@ -167,7 +169,13 @@ async def assign_tag(
)
)
if existing.scalar_one_or_none() is not None:
return {"id": str(tag_id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": True}
return {
"id": str(tag_id),
"tag_id": str(tag_id),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_assigned": True,
}
assignment = TagAssignment(
tenant_id=tenant_id,
@@ -177,7 +185,13 @@ async def assign_tag(
)
db.add(assignment)
await db.flush()
return {"id": str(assignment.id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": False}
return {
"id": str(assignment.id),
"tag_id": str(tag_id),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_assigned": False,
}
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT)
@@ -217,22 +231,21 @@ async def delete_tag(
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
# Cascade delete assignments
await db.execute(
delete(TagAssignment).where(TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id)
delete(TagAssignment).where(
TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id
)
)
await db.delete(tag)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/bulk-assign")
async def bulk_assign_tags(
body: TagBulkAssignRequest,
@@ -244,17 +257,19 @@ async def bulk_assign_tags(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
tag_ids = [_parse_uuid(tid, "tag_id") for tid in body.tag_ids]
# Verify all tags exist
for tid in tag_ids:
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
if result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"})
raise HTTPException(
404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"}
)
assigned = []
already = []
@@ -299,9 +314,7 @@ async def list_tag_entities(
tid = _parse_uuid(tag_id, "tag_id")
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
tag_result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
-2
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
+61 -15
View File
@@ -10,21 +10,35 @@ class PluginRouteDef(BaseModel):
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
router_attr: str = Field(
default="router", description="Attribute name of the APIRouter in the module"
)
class PluginManifest(BaseModel):
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
name: str = Field(
..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)"
)
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
display_name: str = Field(..., min_length=1, max_length=120)
description: str = Field(default="", max_length=500)
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
dependencies: list[str] = Field(
default_factory=list, description="Other plugin names this plugin depends on"
)
routes: list[PluginRouteDef] = Field(
default_factory=list, description="Route definitions to register on activation"
)
events: list[str] = Field(
default_factory=list, description="Event names this plugin listens to"
)
migrations: list[str] = Field(
default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)"
)
permissions: list[str] = Field(
default_factory=list, description="Required permissions for this plugin"
)
@field_validator("name")
@classmethod
@@ -46,15 +60,47 @@ class ManifestSchemaResponse(BaseModel):
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
fields={
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
"name": {
"type": "str",
"required": "true",
"description": "Unique plugin identifier (snake_case, max 80 chars)",
},
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
"display_name": {
"type": "str",
"required": "true",
"description": "Human-readable plugin name",
},
"description": {
"type": "str",
"required": "false",
"description": "Plugin description (max 500 chars)",
},
"dependencies": {
"type": "list[str]",
"required": "false",
"description": "Other plugin names required",
},
"routes": {
"type": "list[PluginRouteDef]",
"required": "false",
"description": "Route definitions to register",
},
"events": {
"type": "list[str]",
"required": "false",
"description": "Event names to listen to",
},
"migrations": {
"type": "list[str]",
"required": "false",
"description": "Migration file names (ordered)",
},
"permissions": {
"type": "list[str]",
"required": "false",
"description": "Required permissions",
},
},
example=PluginManifest(
name="example_plugin",
+7 -2
View File
@@ -6,7 +6,7 @@ import os
from pathlib import Path
from typing import Any
from sqlalchemy import text, inspect
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.models.plugin import PluginMigration
@@ -21,7 +21,9 @@ class MigrationRunner:
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
self._engine = engine
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
self._migrations_dir = Path(
migrations_dir or os.path.join(os.path.dirname(__file__), "migrations")
)
def _resolve_migration_path(self, filename: str, plugin_name: str | None = None) -> Path:
"""Resolve a migration filename to an absolute path.
@@ -213,6 +215,7 @@ class MigrationRunner:
async def _get_table_names(self) -> set[str]:
"""Get current table names from the database (separate connection)."""
def _get_names(sync_conn):
insp = inspect(sync_conn)
return set(insp.get_table_names())
@@ -222,6 +225,7 @@ class MigrationRunner:
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
"""Check if a table has a specific column (separate connection)."""
def _has_col(sync_conn):
insp = inspect(sync_conn)
if table_name not in insp.get_table_names():
@@ -289,6 +293,7 @@ class MigrationRunner:
def _extract_table_names(sql: str) -> list[str]:
"""Extract table names from CREATE TABLE statements in SQL."""
import re
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
return re.findall(pattern, sql, re.IGNORECASE)
+52 -48
View File
@@ -8,7 +8,7 @@ from typing import Any
from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.event_bus import EventBus, get_event_bus
from app.core.service_container import ServiceContainer, get_container
@@ -78,7 +78,8 @@ class PluginRegistry:
return discovered
import pkgutil
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
for _importer, modname, _ispkg in pkgutil.iter_modules(pkg_path):
if modname.startswith("_"):
continue
try:
@@ -144,9 +145,7 @@ class PluginRegistry:
# Run migrations
if plugin.manifest.migrations:
await self.migration_runner.run_all_migrations(
db, name, plugin.manifest.migrations
)
await self.migration_runner.run_all_migrations(db, name, plugin.manifest.migrations)
# Call on_install hook
await plugin.on_install(db, self._container)
@@ -234,7 +233,8 @@ class PluginRegistry:
paths_to_remove.add(route.path)
# Remove matching routes from app
self._app.router.routes = [
r for r in self._app.router.routes
r
for r in self._app.router.routes
if not (hasattr(r, "path") and r.path in paths_to_remove)
]
@@ -309,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
View File
@@ -1,3 +1,16 @@
"""Routes package."""
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
from app.routes import (
ai_copilot, # noqa: F401
auth, # noqa: F401
companies, # noqa: F401
contacts, # noqa: F401
health, # noqa: F401
import_export, # noqa: F401
notifications, # noqa: F401
plugins, # noqa: F401
roles, # noqa: F401
tenants, # noqa: F401
users, # noqa: F401
workflows, # noqa: F401
)
+13 -6
View File
@@ -7,12 +7,11 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.schemas.ai_copilot import (
CopilotQueryRequest,
CopilotExecuteRequest,
CopilotQueryRequest,
)
from app.services import ai_copilot_service
@@ -34,7 +33,9 @@ async def copilot_query(
user_id = uuid.UUID(current_user["user_id"])
result = await ai_copilot_service.process_query(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
query=body.query,
conversation_id=body.conversation_id,
context=body.context,
@@ -65,7 +66,10 @@ async def copilot_execute(
role = current_user.get("role", "viewer")
result = await ai_copilot_service.execute_action(
db, tenant_id, user_id, role,
db,
tenant_id,
user_id,
role,
conversation_id=body.conversation_id,
action=body.action.model_dump(),
)
@@ -97,6 +101,9 @@ async def copilot_history(
user_id = uuid.UUID(current_user["user_id"])
return await ai_copilot_service.get_history(
db, tenant_id, user_id,
page=page, page_size=page_size,
db,
tenant_id,
user_id,
page=page,
page_size=page_size,
)
+10 -8
View File
@@ -3,19 +3,19 @@
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.core.auth import get_redis
from app.core.db import get_db
from app.deps import get_current_user
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.schemas.auth import (
LoginRequest, PasswordResetConfirm, PasswordResetRequest,
SwitchTenantRequest, AuthResponse,
LoginRequest,
PasswordResetConfirm,
PasswordResetRequest,
SwitchTenantRequest,
)
from app.services.auth_service import auth_service
@@ -64,6 +64,7 @@ async def login(
)
# Return auth info in body
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={
@@ -100,6 +101,7 @@ async def logout(
await auth_service.logout(redis, session_id)
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={"message": "Logged out"},
@@ -153,7 +155,7 @@ async def switch_tenant(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid tenant_id", "code": "invalid_tenant_id"},
)
) from None
result = await auth_service.switch_tenant(db, redis, session_id, new_tenant_id)
if result is None:
@@ -180,7 +182,7 @@ async def password_reset_request(
):
"""Request password reset. Always returns 200 (no user enumeration)."""
ip = get_client_ip(request)
redis = get_redis()
redis = get_redis() # noqa: F841
await check_rate_limit(
f"auth:reset:{ip}",
@@ -200,7 +202,7 @@ async def password_reset_confirm(
):
"""Reset password with a valid token."""
ip = get_client_ip(request)
redis = get_redis()
redis = get_redis() # noqa: F841
await check_rate_limit(
f"auth:reset_confirm:{ip}",
+38 -14
View File
@@ -30,10 +30,14 @@ async def list_companies(
"""List companies with pagination, FTS search, industry filter, and sorting."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await company_service.list_companies(
db, tenant_id,
page=page, page_size=page_size,
search=search, industry=industry,
sort_by=sort_by, sort_order=sort_order,
db,
tenant_id,
page=page,
page_size=page_size,
search=search,
industry=industry,
sort_by=sort_by,
sort_order=sort_order,
)
return result
@@ -72,7 +76,10 @@ async def export_companies(
if format == "csv":
csv_data = await company_service.export_companies_csv(
db, tenant_id, industry=industry, search=search,
db,
tenant_id,
industry=industry,
search=search,
)
return Response(
content=csv_data,
@@ -81,7 +88,10 @@ async def export_companies(
)
elif format == "xlsx":
xlsx_data = await company_service.export_companies_xlsx(
db, tenant_id, industry=industry, search=search,
db,
tenant_id,
industry=industry,
search=search,
)
return Response(
content=xlsx_data,
@@ -102,7 +112,9 @@ async def get_company(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
data = await company_service.get_company_detail(db, tenant_id, cid)
if data is None:
@@ -128,7 +140,9 @@ async def update_company(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
data = body.model_dump(exclude_unset=True)
result = await company_service.update_company(db, tenant_id, user_id, cid, data)
@@ -155,9 +169,13 @@ async def delete_company(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
deleted = await company_service.soft_delete_company(db, tenant_id, user_id, cid, cascade=cascade)
deleted = await company_service.soft_delete_company(
db, tenant_id, user_id, cid, cascade=cascade
)
if not deleted:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
@@ -183,11 +201,13 @@ async def link_company_contact(
comp_id = uuid.UUID(company_id)
cont_id = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
result = await company_service.link_contact(db, tenant_id, user_id, comp_id, cont_id)
if result is None:
raise HTTPException(404, detail={"detail": "Company or contact not found", "code": "not_found"})
raise HTTPException(
404, detail={"detail": "Company or contact not found", "code": "not_found"}
)
return result
@@ -210,7 +230,7 @@ async def unlink_company_contact(
comp_id = uuid.UUID(company_id)
cont_id = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
unlinked = await company_service.unlink_contact(db, tenant_id, user_id, comp_id, cont_id)
if not unlinked:
@@ -230,11 +250,15 @@ async def get_company_emails(
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None
# Verify company exists
from sqlalchemy import select
from app.models.company import Company
q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id,
+16 -6
View File
@@ -29,9 +29,13 @@ async def list_contacts(
"""List contacts with pagination and optional search."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await contact_service.list_contacts(
db, tenant_id,
page=page, page_size=page_size,
search=search, sort_by=sort_by, sort_order=sort_order,
db,
tenant_id,
page=page,
page_size=page_size,
search=search,
sort_by=sort_by,
sort_order=sort_order,
)
return result
@@ -68,7 +72,9 @@ async def get_contact(
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = await contact_service.get_contact_detail(db, tenant_id, cid)
if data is None:
@@ -94,7 +100,9 @@ async def update_contact(
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = body.model_dump(exclude_unset=True)
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
@@ -121,7 +129,9 @@ async def delete_contact(
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
if gdpr:
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
+20 -5
View File
@@ -3,16 +3,21 @@
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, status
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
UploadFile,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.services import import_export_service
from app.services import company_service
router = APIRouter(prefix="/api/v1", tags=["import_export"])
@@ -38,7 +43,12 @@ async def import_csv(
csv_content = content.decode("utf-8")
result = await import_export_service.import_csv(
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=False,
db,
tenant_id,
user_id,
csv_content,
entity_type=entity_type,
dry_run=False,
)
return result
@@ -62,6 +72,11 @@ async def import_csv_preview(
csv_content = content.decode("utf-8")
result = await import_export_service.import_csv(
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=True,
db,
tenant_id,
user_id,
csv_content,
entity_type=entity_type,
dry_run=True,
)
return result
+7 -3
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.notifications import (
get_unread_count, list_notifications, mark_notification_read,
get_unread_count,
list_notifications,
mark_notification_read,
)
from app.deps import get_current_user
@@ -41,7 +43,9 @@ async def mark_notification_read_endpoint(
try:
nid = uuid.UUID(notification_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid notification_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid notification_id", "code": "invalid_id"}
) from None
notif = await mark_notification_read(db, tenant_id, user_id, nid)
if notif is None:
+35 -15
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -44,20 +44,26 @@ async def install_plugin(
Idempotent: returns 200 if already installed.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.install_plugin(
db, name,
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(422, detail={"detail": str(exc), "code": "migration_validation_error"})
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
@router.post("/{name}/activate")
@@ -71,20 +77,26 @@ async def activate_plugin(
Idempotent: returns 200 if already active.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.activate_plugin(
db, name,
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not installed" in str(exc).lower():
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_not_installed"})
raise HTTPException(
400, detail={"detail": str(exc), "code": "plugin_not_installed"}
) from None
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
@router.post("/{name}/deactivate")
@@ -98,18 +110,22 @@ async def deactivate_plugin(
Idempotent: returns 200 if already inactive.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.deactivate_plugin(
db, name,
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
@router.delete("/{name}")
@@ -124,10 +140,12 @@ async def uninstall_plugin(
Returns 200 with uninstalled status. Idempotent for already-uninstalled plugins.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.uninstall_plugin(
db, name,
db,
name,
remove_data=remove_data,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
@@ -135,5 +153,7 @@ async def uninstall_plugin(
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
+18 -6
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import Response
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,7 +36,11 @@ async def create_role(
"""Create a custom role (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
role = await role_service.create_role(
db, tenant_id, body.name, body.permissions, body.field_permissions,
db,
tenant_id,
body.name,
body.permissions,
body.field_permissions,
)
return JSONResponse(
status_code=status.HTTP_201_CREATED,
@@ -62,10 +65,17 @@ async def update_role(
try:
rid = uuid.UUID(role_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid role_id", "code": "invalid_id"}
) from None
role = await role_service.update_role(
db, tenant_id, rid, body.name, body.permissions, body.field_permissions,
db,
tenant_id,
rid,
body.name,
body.permissions,
body.field_permissions,
)
if role is None:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
@@ -89,7 +99,9 @@ async def delete_role(
try:
rid = uuid.UUID(role_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid role_id", "code": "invalid_id"}
) from None
success = await role_service.delete_role(db, tenant_id, rid)
if not success:
+6 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -51,7 +51,9 @@ async def list_tenant_users(
try:
tid = uuid.UUID(tenant_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"}
) from None
users = await tenant_service.list_tenant_users(db, tid)
return {"items": users}
@@ -69,7 +71,7 @@ async def assign_user_to_tenant(
tid = uuid.UUID(tenant_id)
uid = uuid.UUID(body.user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
ut = await tenant_service.assign_user_to_tenant(db, tid, uid)
await tenant_service.assign_user_to_tenant(db, tid, uid)
return {"message": "User assigned to tenant"}
+40 -11
View File
@@ -5,14 +5,13 @@ from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.notifications import create_notification
from app.deps import get_current_user, require_admin, get_tenant_id, get_current_user_id
from app.deps import get_current_user, require_admin
from app.schemas.user import UserCreate, UserUpdate
from app.services.user_service import user_service
@@ -43,18 +42,32 @@ async def create_user(
user_id = uuid.UUID(current_user["user_id"])
user = await user_service.create_user(
db, tenant_id, body.email, body.name, body.password, body.role, body.is_active,
db,
tenant_id,
body.email,
body.name,
body.password,
body.role,
body.is_active,
)
# Audit log
await log_audit(
db, tenant_id, user_id, "create", "user", user.id,
db,
tenant_id,
user_id,
"create",
"user",
user.id,
changes={"email": body.email, "name": body.name, "role": body.role},
)
# Notification
await create_notification(
db, tenant_id, user.id, "info",
db,
tenant_id,
user.id,
"info",
"Account created",
f"Your account has been created by {current_user['name']}.",
)
@@ -80,7 +93,9 @@ async def get_user(
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
@@ -109,7 +124,9 @@ async def update_user(
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
changes: dict[str, Any] = {}
if body.name is not None:
@@ -120,7 +137,12 @@ async def update_user(
changes["is_active"] = body.is_active
user = await user_service.update_user(
db, tenant_id, uid, body.name, body.role, body.is_active,
db,
tenant_id,
uid,
body.name,
body.role,
body.is_active,
)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
@@ -149,7 +171,9 @@ async def delete_user(
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
# Get user snapshot for audit before deletion
user = await user_service.get_user(db, tenant_id, uid)
@@ -161,7 +185,12 @@ async def delete_user(
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(
db, tenant_id, acting_user_id, "delete", "user", uid,
db,
tenant_id,
acting_user_id,
"delete",
"user",
uid,
changes={"email": user.email, "name": user.name},
)
+20 -8
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.schemas.workflow import WorkflowCreate, WorkflowUpdate, InstanceCreate, AdvanceRequest
from app.schemas.workflow import AdvanceRequest, InstanceCreate, WorkflowCreate, WorkflowUpdate
from app.services import workflow_service
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
# ─── Workflow CRUD ───
@router.get("")
async def list_workflows(
page: int = Query(1, ge=1),
@@ -29,8 +30,10 @@ async def list_workflows(
"""List workflows with pagination."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await workflow_service.list_workflows(
db, tenant_id,
page=page, page_size=page_size,
db,
tenant_id,
page=page,
page_size=page_size,
is_active=is_active,
)
@@ -67,8 +70,10 @@ async def list_instances(
"""List workflow instances with optional status filter."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await workflow_service.list_instances(
db, tenant_id,
page=page, page_size=page_size,
db,
tenant_id,
page=page,
page_size=page_size,
status_filter=status,
)
@@ -146,6 +151,7 @@ async def delete_workflow(
# ─── Instance endpoints ───
@router.post("/{workflow_id}/instances", status_code=status.HTTP_201_CREATED)
async def create_instance(
workflow_id: str,
@@ -158,7 +164,9 @@ async def create_instance(
user_id = uuid.UUID(current_user["user_id"])
result = await workflow_service.create_instance(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
workflow_id=workflow_id,
context=body.context,
timeout_hours=body.timeout_hours,
@@ -204,7 +212,9 @@ async def advance_instance(
user_id = uuid.UUID(current_user["user_id"])
result = await workflow_service.advance_instance(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
instance_id=instance_id,
decision=body.decision,
comment=body.comment,
@@ -233,7 +243,9 @@ async def cancel_instance(
user_id = uuid.UUID(current_user["user_id"])
result = await workflow_service.cancel_instance(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
instance_id=instance_id,
)
if result is None:
+24 -7
View File
@@ -1,13 +1,30 @@
"""Pydantic schemas package."""
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
from app.schemas.ai_copilot import (
CopilotQueryRequest, CopilotAction, CopilotQueryResponse,
CopilotExecuteRequest, CopilotExecuteResponse,
CopilotHistoryResponse, CopilotMessageResponse,
CopilotAction, # noqa: F401
CopilotExecuteRequest, # noqa: F401
CopilotExecuteResponse, # noqa: F401
CopilotHistoryResponse, # noqa: F401
CopilotMessageResponse, # noqa: F401
CopilotQueryRequest, # noqa: F401
CopilotQueryResponse, # noqa: F401
)
from app.schemas.plugin import (
PluginActionResponse, # noqa: F401
PluginInfo, # noqa: F401
PluginListResponse, # noqa: F401
PluginUninstallResponse, # noqa: F401
)
from app.schemas.workflow import (
WorkflowCreate, WorkflowUpdate, WorkflowResponse, WorkflowListResponse,
InstanceCreate, InstanceResponse, InstanceDetailResponse, InstanceListResponse,
AdvanceRequest, StepHistoryResponse, WorkflowStep,
AdvanceRequest, # noqa: F401
InstanceCreate, # noqa: F401
InstanceDetailResponse, # noqa: F401
InstanceListResponse, # noqa: F401
InstanceResponse, # noqa: F401
StepHistoryResponse, # noqa: F401
WorkflowCreate, # noqa: F401
WorkflowListResponse, # noqa: F401
WorkflowResponse, # noqa: F401
WorkflowStep, # noqa: F401
WorkflowUpdate, # noqa: F401
)
+7
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
class CopilotQueryRequest(BaseModel):
"""Natural language query to the AI copilot."""
query: str = Field(..., min_length=1, max_length=2000)
conversation_id: str | None = None
context: dict = Field(default_factory=dict)
@@ -14,6 +15,7 @@ class CopilotQueryRequest(BaseModel):
class CopilotAction(BaseModel):
"""A proposed API action derived from NL input."""
method: str = Field(..., pattern="^(GET|POST|PATCH|DELETE)$")
path: str = Field(..., min_length=1)
body: dict | None = None
@@ -23,6 +25,7 @@ class CopilotAction(BaseModel):
class CopilotQueryResponse(BaseModel):
"""Response from copilot query — proposed actions for user confirmation."""
conversation_id: str
message: str
proposed_actions: list[CopilotAction] = Field(default_factory=list)
@@ -30,12 +33,14 @@ class CopilotQueryResponse(BaseModel):
class CopilotExecuteRequest(BaseModel):
"""Execute a proposed action after user confirmation."""
conversation_id: str
action: CopilotAction
class CopilotExecuteResponse(BaseModel):
"""Result of executing a proposed action."""
conversation_id: str
success: bool
status_code: int
@@ -45,6 +50,7 @@ class CopilotExecuteResponse(BaseModel):
class CopilotMessageResponse(BaseModel):
"""A single message in conversation history."""
id: str
role: str
content: str
@@ -56,6 +62,7 @@ class CopilotMessageResponse(BaseModel):
class CopilotHistoryResponse(BaseModel):
"""Paginated conversation history."""
items: list[CopilotMessageResponse]
total: int
page: int
+4 -1
View File
@@ -37,7 +37,9 @@ class PluginActionResponse(BaseModel):
status: str
installed: bool = False
active: bool = False
dropped_tables: list[str] = Field(default_factory=list, description="Tables dropped (uninstall with remove_data=true)")
dropped_tables: list[str] = Field(
default_factory=list, description="Tables dropped (uninstall with remove_data=true)"
)
message: str = ""
@@ -49,6 +51,7 @@ class PluginUninstallResponse(PluginActionResponse):
class ManifestFieldDoc(BaseModel):
"""Documentation for a single manifest field."""
type: str
required: str
description: str
-2
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr, Field
+11
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
class WorkflowStep(BaseModel):
"""A single step in a workflow definition."""
name: str = Field(..., min_length=1, max_length=200)
type: str = Field(..., pattern="^(action|approval|notification|condition)$")
config: dict = Field(default_factory=dict)
@@ -15,6 +16,7 @@ class WorkflowStep(BaseModel):
class WorkflowCreate(BaseModel):
"""Create a new workflow definition."""
name: str = Field(..., min_length=1, max_length=200)
description: str | None = None
trigger_event: str | None = None
@@ -24,6 +26,7 @@ class WorkflowCreate(BaseModel):
class WorkflowUpdate(BaseModel):
"""Update an existing workflow definition."""
name: str | None = Field(None, max_length=200)
description: str | None = None
trigger_event: str | None = None
@@ -33,6 +36,7 @@ class WorkflowUpdate(BaseModel):
class WorkflowResponse(BaseModel):
"""Workflow definition response."""
id: str
name: str
description: str | None = None
@@ -46,6 +50,7 @@ class WorkflowResponse(BaseModel):
class WorkflowListResponse(BaseModel):
"""Paginated workflow list."""
items: list[WorkflowResponse]
total: int
page: int
@@ -54,12 +59,14 @@ class WorkflowListResponse(BaseModel):
class InstanceCreate(BaseModel):
"""Create a new workflow instance."""
context: dict = Field(default_factory=dict)
timeout_hours: int | None = None
class InstanceResponse(BaseModel):
"""Workflow instance response with current state and history."""
id: str
workflow_id: str
status: str
@@ -75,12 +82,14 @@ class InstanceResponse(BaseModel):
class InstanceDetailResponse(InstanceResponse):
"""Instance with step history."""
history: list[dict] = Field(default_factory=list)
workflow_name: str | None = None
class InstanceListResponse(BaseModel):
"""Paginated instance list."""
items: list[InstanceResponse]
total: int
page: int
@@ -89,12 +98,14 @@ class InstanceListResponse(BaseModel):
class AdvanceRequest(BaseModel):
"""Advance or reject a workflow instance step."""
decision: str = Field(..., pattern="^(approve|reject)$")
comment: str | None = None
class StepHistoryResponse(BaseModel):
"""Step history entry."""
id: str
instance_id: str
step_index: int
+22 -10
View File
@@ -1,16 +1,28 @@
"""Service layer package."""
from app.services.plugin_service import PluginService, get_plugin_service
from app.services.ai_copilot_service import (
process_query as copilot_process_query,
execute_action as copilot_execute_action,
get_history as copilot_get_history,
execute_action as copilot_execute_action, # noqa: F401
)
from app.services.ai_copilot_service import (
get_history as copilot_get_history, # noqa: F401
)
from app.services.ai_copilot_service import (
process_query as copilot_process_query, # noqa: F401
)
from app.services.plugin_service import PluginService, get_plugin_service # noqa: F401
from app.services.workflow_service import (
create_workflow, list_workflows, get_workflow,
update_workflow, delete_workflow,
create_instance, list_instances, get_instance,
advance_instance, cancel_instance,
check_timeout, auto_reject_timeout,
find_workflows_for_event, start_instance_for_event,
advance_instance, # noqa: F401
auto_reject_timeout, # noqa: F401
cancel_instance, # noqa: F401
check_timeout, # noqa: F401
create_instance, # noqa: F401
create_workflow, # noqa: F401
delete_workflow, # noqa: F401
find_workflows_for_event, # noqa: F401
get_instance, # noqa: F401
get_workflow, # noqa: F401
list_instances, # noqa: F401
list_workflows, # noqa: F401
start_instance_for_event, # noqa: F401
update_workflow, # noqa: F401
)
+32 -24
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, desc, and_
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission, filter_fields_by_permission
from app.core.auth import check_permission
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.company import Company
from app.models.contact import Contact
@@ -136,7 +136,9 @@ async def process_query(
# Log to audit
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="query",
entity_type="ai_copilot",
entity_id=conversation.id,
@@ -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":
+29 -17
View File
@@ -2,9 +2,8 @@
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
import redis.asyncio as aioredis
@@ -12,14 +11,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import (
create_session, get_session_data, hash_password, hash_token,
invalidate_session, update_session_tenant, verify_password,
)
from app.core.audit import log_audit
from app.core.auth import (
create_session,
get_session_data,
hash_password,
hash_token,
invalidate_session,
update_session_tenant,
verify_password,
)
from app.models.auth import PasswordResetToken
from app.models.user import User, UserTenant
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
class AuthService:
@@ -37,7 +41,7 @@ class AuthService:
Returns (session_id, csrf_token, user, tenant) or None.
"""
# Find user by email — need to check across tenants or use default tenant
q = select(User).where(User.email == email, User.is_active == True)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
@@ -53,7 +57,7 @@ class AuthService:
Tenant.slug == tenant_slug
)
else:
ut_q = ut_q.where(UserTenant.is_default == True)
ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
@@ -75,7 +79,12 @@ class AuthService:
# Log the login in audit trail
await log_audit(
db, tenant.id, user.id, "login", "user", user.id,
db,
tenant.id,
user.id,
"login",
"user",
user.id,
changes={"email": email},
)
@@ -153,7 +162,7 @@ class AuthService:
tenant_id: uuid.UUID | None = None,
) -> bool:
"""Create a password reset token. Always returns True (no user enumeration)."""
q = select(User).where(User.email == email, User.is_active == True)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
@@ -166,14 +175,15 @@ class AuthService:
)
prev_result = await db.execute(prev_q)
for prev_token in prev_result.scalars().all():
prev_token.used_at = datetime.now(timezone.utc)
prev_token.used_at = datetime.now(UTC)
# Create new token
import secrets
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
@@ -206,7 +216,7 @@ class AuthService:
if reset_token is None:
return False
if reset_token.expires_at < datetime.now(timezone.utc):
if reset_token.expires_at < datetime.now(UTC):
return False # Token expired
# Get user
@@ -218,7 +228,7 @@ class AuthService:
# Update password
user.password_hash = hash_password(new_password)
reset_token.used_at = datetime.now(timezone.utc)
reset_token.used_at = datetime.now(UTC)
await db.flush()
return True
@@ -229,6 +239,7 @@ class AuthService:
"""
# This is a test helper — in production the token goes via email only
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -238,7 +249,7 @@ class AuthService:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
@@ -253,6 +264,7 @@ class AuthService:
async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None:
"""Create an already-expired reset token for testing."""
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -261,7 +273,7 @@ class AuthService:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Already expired
expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
+87 -39
View File
@@ -5,15 +5,15 @@ from __future__ import annotations
import csv
import io
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, or_, desc, asc, delete
from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.core.audit import log_audit
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.contact import CompanyContact, Contact
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
@@ -66,9 +66,7 @@ async def list_companies(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -131,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)
+47 -16
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, desc, asc, delete
from sqlalchemy import asc, delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.models.contact import Contact, CompanyContact
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
@@ -113,13 +113,15 @@ async def get_contact_detail(
companies_result = await db.execute(companies_q)
companies_list = []
for company, link in companies_result.all():
companies_list.append({
"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
+35 -12
View File
@@ -16,7 +16,6 @@ from app.models.contact import Contact
from app.services.company_service import _company_to_dict
from app.services.contact_service import _contact_to_dict
# Expected CSV columns for each entity type
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"]
@@ -88,7 +87,12 @@ async def import_companies(
db.add(company)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "company", company.id,
db,
tenant_id,
user_id,
"import",
"company",
company.id,
changes={"name": company.name},
)
created.append(_company_to_dict(company))
@@ -154,7 +158,12 @@ async def import_contacts(
db.add(contact)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "contact", contact.id,
db,
tenant_id,
user_id,
"import",
"contact",
contact.id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
created.append(_contact_to_dict(contact))
@@ -198,19 +207,33 @@ async def export_contacts_csv(
tenant_id: uuid.UUID,
) -> str:
"""Export contacts as CSV string."""
q = select(Contact).where(
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()
+30 -13
View File
@@ -9,8 +9,8 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.plugins.registry import PluginRegistry, get_registry
from app.plugins.migration_runner import MigrationValidationError
from app.plugins.registry import PluginRegistry, get_registry
logger = logging.getLogger(__name__)
@@ -48,7 +48,9 @@ class PluginService:
record = await self._registry.install(db, name)
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.install",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -64,9 +66,9 @@ class PluginService:
"message": "Plugin installed successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
except MigrationValidationError as exc:
raise MigrationValidationError(str(exc))
raise MigrationValidationError(str(exc)) from None
async def activate_plugin(
self,
@@ -84,7 +86,9 @@ class PluginService:
was_already_active = record.active and record.status == "active"
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.activate",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -97,10 +101,12 @@ class PluginService:
"status": record.status,
"installed": record.installed,
"active": record.active,
"message": "Plugin is already active" if was_already_active else "Plugin activated successfully",
"message": "Plugin is already active"
if was_already_active
else "Plugin activated successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
async def deactivate_plugin(
self,
@@ -118,7 +124,9 @@ class PluginService:
was_already_inactive = not record.active and record.status == "inactive"
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.deactivate",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -131,10 +139,12 @@ class PluginService:
"status": record.status,
"installed": record.installed,
"active": record.active,
"message": "Plugin is already inactive" if was_already_inactive else "Plugin deactivated successfully",
"message": "Plugin is already inactive"
if was_already_inactive
else "Plugin deactivated successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
async def uninstall_plugin(
self,
@@ -153,10 +163,16 @@ class PluginService:
dropped_tables = getattr(record, "dropped_tables", [])
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.uninstall",
entity_type="plugin",
changes={"name": name, "remove_data": remove_data, "dropped_tables": dropped_tables},
changes={
"name": name,
"remove_data": remove_data,
"dropped_tables": dropped_tables,
},
)
return {
"name": name,
@@ -169,11 +185,12 @@ class PluginService:
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
def get_manifest_schema(self) -> dict[str, Any]:
"""Return the manifest schema documentation."""
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
return MANIFEST_SCHEMA_DOC.model_dump()
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select, func, or_
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password
+92 -46
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select, func, desc, and_, or_
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.notification import Notification
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
def _safe_iso(dt) -> str | None:
@@ -47,7 +47,12 @@ def _workflow_to_dict(w: Workflow) -> dict[str, Any]:
}
def _instance_to_dict(i: WorkflowInstance, include_history: bool = False, history: list | None = None, workflow_name: str | None = None) -> dict[str, Any]:
def _instance_to_dict(
i: WorkflowInstance,
include_history: bool = False,
history: list | None = None,
workflow_name: str | None = None,
) -> dict[str, Any]:
data = {
"id": str(i.id),
"workflow_id": str(i.workflow_id),
@@ -107,6 +112,7 @@ async def _log_step_history(
# ─── Workflow CRUD ───
async def create_workflow(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -134,7 +140,9 @@ async def create_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="create",
entity_type="workflow",
entity_id=workflow.id,
@@ -229,7 +237,9 @@ async def update_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="update",
entity_type="workflow",
entity_id=workflow.id,
@@ -261,7 +271,9 @@ async def delete_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="delete",
entity_type="workflow",
entity_id=wf_uuid,
@@ -272,6 +284,7 @@ async def delete_workflow(
# ─── Instance Lifecycle ───
async def create_instance(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -294,7 +307,7 @@ async def create_instance(
timeout_at = None
if timeout_hours:
timeout_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours)
timeout_at = datetime.now(UTC) + timedelta(hours=timeout_hours)
instance = WorkflowInstance(
tenant_id=tenant_id,
@@ -314,7 +327,9 @@ async def create_instance(
steps = workflow.steps or []
first_step = steps[0] if steps else {}
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=0,
step_type=first_step.get("type", "action"),
action="entered",
@@ -323,7 +338,9 @@ async def create_instance(
)
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="create",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -383,16 +400,18 @@ async def get_instance(
return None
# Get workflow name
wf_result = await db.execute(
select(Workflow.name).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow.name).where(Workflow.id == instance.workflow_id))
workflow_name = wf_result.scalar_one_or_none()
# Get step history
hist_q = select(WorkflowStepHistory).where(
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,
+9 -3
View File
@@ -67,9 +67,10 @@ async def ensure_onboarding_workflow_exists(
If it doesn't exist yet, create it. Returns the workflow ID.
"""
from app.models.workflow import Workflow
from sqlalchemy import select
from app.models.workflow import Workflow
result = await db.execute(
select(Workflow).where(
Workflow.tenant_id == tenant_id,
@@ -82,8 +83,11 @@ async def ensure_onboarding_workflow_exists(
return existing.id
from app.services.workflow_service import create_workflow
wf_dict = await create_workflow(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
get_onboarding_workflow_definition(),
)
return uuid.UUID(wf_dict["id"]) if wf_dict else None
@@ -105,7 +109,9 @@ async def trigger_onboarding(
return None
instance = await create_instance(
db, tenant_id, admin_user_id,
db,
tenant_id,
admin_user_id,
str(wf_id),
context={"new_user_id": str(new_user_id), "user_id": str(new_user_id)},
)
+38 -20
View File
@@ -7,20 +7,20 @@ Integrates with the event bus for event-triggered workflows.
from __future__ import annotations
import uuid
import logging
from datetime import datetime, timezone
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.notification import Notification
from app.models.workflow import Workflow, WorkflowInstance
from app.services.workflow_service import (
_log_step_history,
_instance_to_dict,
_log_step_history,
create_instance,
find_workflows_for_event,
)
@@ -59,7 +59,7 @@ class WorkflowEngine:
steps = workflow.steps or []
if instance.current_step_index >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
await self.db.flush()
return _instance_to_dict(instance)
@@ -68,7 +68,9 @@ class WorkflowEngine:
# Log step entry
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="processing",
@@ -103,7 +105,9 @@ class WorkflowEngine:
# Execute action based on type
if action_type == "create_notification":
user_id = config.get("user_id") or (str(instance.initiated_by) if instance.initiated_by else None)
user_id = config.get("user_id") or (
str(instance.initiated_by) if instance.initiated_by else None
)
if user_id:
notification = Notification(
tenant_id=self.tenant_id,
@@ -120,7 +124,9 @@ class WorkflowEngine:
# Log action executed
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type="action",
action="executed",
@@ -131,7 +137,7 @@ class WorkflowEngine:
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
instance.status = "in_progress"
@@ -139,12 +145,12 @@ class WorkflowEngine:
await self.db.flush()
return _instance_to_dict(instance)
async def _process_notification(
self, instance: WorkflowInstance, step: dict
) -> dict[str, Any]:
async def _process_notification(self, instance: WorkflowInstance, step: dict) -> dict[str, Any]:
"""Process a notification step — sends notification and advances."""
config = step.get("config", {})
user_id = config.get("user_id") or (str(instance.initiated_by) if instance.initiated_by else None)
user_id = config.get("user_id") or (
str(instance.initiated_by) if instance.initiated_by else None
)
if user_id:
notification = Notification(
@@ -158,7 +164,9 @@ class WorkflowEngine:
await self.db.flush()
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type="notification",
action="sent",
@@ -174,7 +182,7 @@ class WorkflowEngine:
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
@@ -211,10 +219,16 @@ class WorkflowEngine:
elif operator == "lt":
condition_met = actual is not None and expected is not None and actual < expected
elif operator == "contains":
condition_met = actual is not None and expected in actual if isinstance(actual, (str, list)) else False
condition_met = (
actual is not None and expected in actual
if isinstance(actual, str | list)
else False
)
await _log_step_history(
self.db, self.tenant_id, instance.id,
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type="condition",
action="evaluated",
@@ -230,7 +244,7 @@ class WorkflowEngine:
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
@@ -253,8 +267,11 @@ async def handle_event(
instances: list[dict[str, Any]] = []
for wf in workflows:
inst = await create_instance(
db, tenant_id,
uuid.UUID(payload.get("user_id", str(uuid.uuid4()))) if payload.get("user_id") else None or uuid.uuid4(),
db,
tenant_id,
uuid.UUID(payload.get("user_id", str(uuid.uuid4())))
if payload.get("user_id")
else None or uuid.uuid4(),
str(wf.id),
context=payload,
)
@@ -274,6 +291,7 @@ def register_workflow_event_handlers() -> None:
async def _workflow_event_handler(payload: dict[str, Any]) -> None:
"""Handle events that may trigger workflows."""
from app.core.db import create_db_session
tenant_id_str = payload.get("tenant_id")
event_name = payload.get("event", "")
if not tenant_id_str or not event_name: