diff --git a/alembic/env.py b/alembic/env.py index f1b4c98..28da21f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -5,11 +5,11 @@ from __future__ import annotations import asyncio from logging.config import fileConfig -from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context from app.config import get_settings from app.core.db import Base from app.models import * # noqa: F401,F403 @@ -25,7 +25,12 @@ config.set_main_option("sqlalchemy.url", settings.database_url) def run_migrations_offline() -> None: url = config.get_main_option("sqlalchemy.url") - context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) with context.begin_transaction(): context.run_migrations() @@ -37,7 +42,11 @@ def do_run_migrations(connection: Connection) -> None: async def run_async_migrations() -> None: - connectable = async_engine_from_config(config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool) + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) await connectable.dispose() diff --git a/app/ai/action_mapper.py b/app/ai/action_mapper.py index c1bf5af..85e77f2 100644 --- a/app/ai/action_mapper.py +++ b/app/ai/action_mapper.py @@ -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"} diff --git a/app/ai/llm_client.py b/app/ai/llm_client.py index 7c947d2..a5631d3 100644 --- a/app/ai/llm_client.py +++ b/app/ai/llm_client.py @@ -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"}, diff --git a/app/core/auth.py b/app/core/auth.py index 8ac63ab..ffdb69c 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -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. diff --git a/app/core/cache.py b/app/core/cache.py index 2f1f03f..5652e07 100644 --- a/app/core/cache.py +++ b/app/core/cache.py @@ -9,7 +9,6 @@ import redis.asyncio as aioredis from app.config import get_settings - _cache_redis: aioredis.Redis | None = None diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py index b12aeb8..8455b40 100644 --- a/app/core/db/__init__.py +++ b/app/core/db/__init__.py @@ -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 diff --git a/app/core/event_bus.py b/app/core/event_bus.py index d65cbe4..edd615f 100644 --- a/app/core/event_bus.py +++ b/app/core/event_bus.py @@ -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() diff --git a/app/core/notifications.py b/app/core/notifications.py index 4945c02..a6b8b0f 100644 --- a/app/core/notifications.py +++ b/app/core/notifications.py @@ -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 diff --git a/app/core/rate_limit.py b/app/core/rate_limit.py index d8b1b66..3e9e95d 100644 --- a/app/core/rate_limit.py +++ b/app/core/rate_limit.py @@ -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( diff --git a/app/core/tenant.py b/app/core/tenant.py index 5464ea2..1aee823 100644 --- a/app/core/tenant.py +++ b/app/core/tenant.py @@ -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) diff --git a/app/deps.py b/app/deps.py index 2568948..6784349 100644 --- a/app/deps.py +++ b/app/deps.py @@ -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: diff --git a/app/main.py b/app/main.py index aa6b9d4..80c6926 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 30150ee..aa6c284 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/ai_conversation.py b/app/models/ai_conversation.py index f2a1165..e2a25ae 100644 --- a/app/models/ai_conversation.py +++ b/app/models/ai_conversation.py @@ -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) diff --git a/app/models/audit.py b/app/models/audit.py index 1889fc1..c009d30 100644 --- a/app/models/audit.py +++ b/app/models/audit.py @@ -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 diff --git a/app/models/auth.py b/app/models/auth.py index d302c69..8e34822 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -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 diff --git a/app/models/company.py b/app/models/company.py index eb74f95..fc68a75 100644 --- a/app/models/company.py +++ b/app/models/company.py @@ -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, diff --git a/app/models/contact.py b/app/models/contact.py index cecd12c..400a82c 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -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"), ) diff --git a/app/models/notification.py b/app/models/notification.py index 523ebb6..98c144f 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -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 diff --git a/app/models/plugin.py b/app/models/plugin.py index fa4aeea..c06e4c9 100644 --- a/app/models/plugin.py +++ b/app/models/plugin.py @@ -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") diff --git a/app/models/role.py b/app/models/role.py index a8111f9..320fbaa 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -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 diff --git a/app/models/session.py b/app/models/session.py index 0741bcc..e6961d9 100644 --- a/app/models/session.py +++ b/app/models/session.py @@ -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 diff --git a/app/models/tenant.py b/app/models/tenant.py index ce58608..74fe916 100644 --- a/app/models/tenant.py +++ b/app/models/tenant.py @@ -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 diff --git a/app/models/user.py b/app/models/user.py index 9258d76..a2469c8 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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 diff --git a/app/models/workflow.py b/app/models/workflow.py index 8b02162..d870926 100644 --- a/app/models/workflow.py +++ b/app/models/workflow.py @@ -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 ) diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 75ff29a..5c1135e 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -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"] diff --git a/app/plugins/base.py b/app/plugins/base.py index d08bf48..990c568 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -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}>" + ) diff --git a/app/plugins/builtins/__init__.py b/app/plugins/builtins/__init__.py index 35f4c9e..ae0aa19 100644 --- a/app/plugins/builtins/__init__.py +++ b/app/plugins/builtins/__init__.py @@ -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"] diff --git a/app/plugins/builtins/entity_links/models.py b/app/plugins/builtins/entity_links/models.py index e724436..c231336 100644 --- a/app/plugins/builtins/entity_links/models.py +++ b/app/plugins/builtins/entity_links/models.py @@ -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) diff --git a/app/plugins/builtins/entity_links/plugin.py b/app/plugins/builtins/entity_links/plugin.py index f4023e3..33fd946 100644 --- a/app/plugins/builtins/entity_links/plugin.py +++ b/app/plugins/builtins/entity_links/plugin.py @@ -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( diff --git a/app/plugins/builtins/entity_links/routes.py b/app/plugins/builtins/entity_links/routes.py index 596e6c2..74a67b7 100644 --- a/app/plugins/builtins/entity_links/routes.py +++ b/app/plugins/builtins/entity_links/routes.py @@ -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( diff --git a/app/plugins/builtins/permissions/models.py b/app/plugins/builtins/permissions/models.py index 3b7502b..e8594b1 100644 --- a/app/plugins/builtins/permissions/models.py +++ b/app/plugins/builtins/permissions/models.py @@ -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) diff --git a/app/plugins/builtins/permissions/plugin.py b/app/plugins/builtins/permissions/plugin.py index 8421d5e..184fb29 100644 --- a/app/plugins/builtins/permissions/plugin.py +++ b/app/plugins/builtins/permissions/plugin.py @@ -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 diff --git a/app/plugins/builtins/permissions/routes.py b/app/plugins/builtins/permissions/routes.py index 36869e1..c3e6eab 100644 --- a/app/plugins/builtins/permissions/routes.py +++ b/app/plugins/builtins/permissions/routes.py @@ -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), diff --git a/app/plugins/builtins/tags/models.py b/app/plugins/builtins/tags/models.py index aabb9a8..dbf3550 100644 --- a/app/plugins/builtins/tags/models.py +++ b/app/plugins/builtins/tags/models.py @@ -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"), diff --git a/app/plugins/builtins/tags/routes.py b/app/plugins/builtins/tags/routes.py index a62d7fd..b1f6349 100644 --- a/app/plugins/builtins/tags/routes.py +++ b/app/plugins/builtins/tags/routes.py @@ -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"}) diff --git a/app/plugins/builtins/tags/schemas.py b/app/plugins/builtins/tags/schemas.py index e2411de..7317d5a 100644 --- a/app/plugins/builtins/tags/schemas.py +++ b/app/plugins/builtins/tags/schemas.py @@ -2,8 +2,6 @@ from __future__ import annotations -import uuid - from pydantic import BaseModel, Field diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 82f3b48..9c849ee 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -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", diff --git a/app/plugins/migration_runner.py b/app/plugins/migration_runner.py index d8bd40a..afd0de2 100644 --- a/app/plugins/migration_runner.py +++ b/app/plugins/migration_runner.py @@ -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) diff --git a/app/plugins/registry.py b/app/plugins/registry.py index eb905b0..364619c 100644 --- a/app/plugins/registry.py +++ b/app/plugins/registry.py @@ -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() diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 7c749fb..3e147e3 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,3 +1,16 @@ """Routes package.""" -from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows \ No newline at end of file +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 +) diff --git a/app/routes/ai_copilot.py b/app/routes/ai_copilot.py index 9e7174b..c5a40a0 100644 --- a/app/routes/ai_copilot.py +++ b/app/routes/ai_copilot.py @@ -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, ) diff --git a/app/routes/auth.py b/app/routes/auth.py index a4bb520..302ca8e 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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}", diff --git a/app/routes/companies.py b/app/routes/companies.py index 17316cd..c09e5f8 100644 --- a/app/routes/companies.py +++ b/app/routes/companies.py @@ -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, diff --git a/app/routes/contacts.py b/app/routes/contacts.py index 0aace96..65283d8 100644 --- a/app/routes/contacts.py +++ b/app/routes/contacts.py @@ -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) diff --git a/app/routes/import_export.py b/app/routes/import_export.py index ef718a3..3005473 100644 --- a/app/routes/import_export.py +++ b/app/routes/import_export.py @@ -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 diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 63972a6..85cb368 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -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: diff --git a/app/routes/plugins.py b/app/routes/plugins.py index b59aa06..d30f018 100644 --- a/app/routes/plugins.py +++ b/app/routes/plugins.py @@ -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 diff --git a/app/routes/roles.py b/app/routes/roles.py index 86c7a86..57853d3 100644 --- a/app/routes/roles.py +++ b/app/routes/roles.py @@ -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: diff --git a/app/routes/tenants.py b/app/routes/tenants.py index f56cf62..ade4c4b 100644 --- a/app/routes/tenants.py +++ b/app/routes/tenants.py @@ -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"} diff --git a/app/routes/users.py b/app/routes/users.py index dac2695..ec8e902 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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}, ) diff --git a/app/routes/workflows.py b/app/routes/workflows.py index 358d39e..9ad715b 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -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: diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 7618b5d..cf8942f 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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 ) diff --git a/app/schemas/ai_copilot.py b/app/schemas/ai_copilot.py index b42de8b..6f9b1f5 100644 --- a/app/schemas/ai_copilot.py +++ b/app/schemas/ai_copilot.py @@ -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 diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index fd45b69..e729d95 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -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 diff --git a/app/schemas/user.py b/app/schemas/user.py index 4a4849c..8749ee0 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Any - from pydantic import BaseModel, EmailStr, Field diff --git a/app/schemas/workflow.py b/app/schemas/workflow.py index d00acdf..61af206 100644 --- a/app/schemas/workflow.py +++ b/app/schemas/workflow.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py index 050e956..767800a 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -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 ) diff --git a/app/services/ai_copilot_service.py b/app/services/ai_copilot_service.py index 6b56efe..dbd8163 100644 --- a/app/services/ai_copilot_service.py +++ b/app/services/ai_copilot_service.py @@ -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": diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 900580e..9faf141 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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, diff --git a/app/services/company_service.py b/app/services/company_service.py index 5750770..90c8c8e 100644 --- a/app/services/company_service.py +++ b/app/services/company_service.py @@ -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) diff --git a/app/services/contact_service.py b/app/services/contact_service.py index 7ce3b75..edefd89 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -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 diff --git a/app/services/import_export_service.py b/app/services/import_export_service.py index cf0d7a7..8a4260f 100644 --- a/app/services/import_export_service.py +++ b/app/services/import_export_service.py @@ -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() diff --git a/app/services/plugin_service.py b/app/services/plugin_service.py index 6d00ca3..bf76272 100644 --- a/app/services/plugin_service.py +++ b/app/services/plugin_service.py @@ -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() diff --git a/app/services/user_service.py b/app/services/user_service.py index 1151a27..83c14f5 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -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 diff --git a/app/services/workflow_service.py b/app/services/workflow_service.py index 319cf16..6c24e4e 100644 --- a/app/services/workflow_service.py +++ b/app/services/workflow_service.py @@ -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, diff --git a/app/workflows/code/onboarding.py b/app/workflows/code/onboarding.py index 7f57d47..3ed58a8 100644 --- a/app/workflows/code/onboarding.py +++ b/app/workflows/code/onboarding.py @@ -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)}, ) diff --git a/app/workflows/engine.py b/app/workflows/engine.py index 77ed2e5..28d2e79 100644 --- a/app/workflows/engine.py +++ b/app/workflows/engine.py @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index 88e8321..779b15e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,8 +7,6 @@ Auth helpers talk to the HTTP API (integration tests). from __future__ import annotations import asyncio -import json -import uuid from collections.abc import AsyncGenerator from typing import Any @@ -24,23 +22,22 @@ from sqlalchemy.ext.asyncio import ( create_async_engine, ) -from app.config import get_settings -from app.core.db import Base, reset_engine_for_testing, close_engine from app.core.auth import hash_password +from app.core.db import Base, close_engine, reset_engine_for_testing +from app.main import create_app +from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401 +from app.models.company import Company +from app.models.contact import CompanyContact, Contact # noqa: F401 +from app.models.plugin import Plugin, PluginMigration # noqa: F401 +from app.models.role import Role from app.models.tenant import Tenant from app.models.user import User, UserTenant -from app.models.role import Role -from app.models.company import Company -from app.models.contact import Contact, CompanyContact -from app.models.plugin import Plugin, PluginMigration -from app.models.ai_conversation import AIConversation, AIMessage -from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory -# Import plugin models so Base.metadata.create_all includes their tables -from app.plugins.builtins.tags.models import Tag, TagAssignment -from app.plugins.builtins.permissions.models import Permission, ShareLink -from app.plugins.builtins.entity_links.models import EntityLink -from app.main import create_app +from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401 +from app.plugins.builtins.entity_links.models import EntityLink # noqa: F401 +from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401 +from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401 +# Import plugin models so Base.metadata.create_all includes their tables TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test" @@ -50,6 +47,7 @@ def _get_sync_engine(): Uses postgres superuser because leocrm user doesn't own the public schema. """ from sqlalchemy import create_engine + return create_engine( "postgresql+psycopg2://postgres@localhost:5432/leocrm_test", echo=False, @@ -94,7 +92,11 @@ def clean_tables(db_setup): sync_eng = _get_sync_engine() with sync_eng.connect() as conn: # TRUNCATE all tables with CASCADE — fast and reliable isolation - conn.execute(text("TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;")) + conn.execute( + text( + "TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;" + ) + ) conn.commit() sync_eng.dispose() yield @@ -125,7 +127,9 @@ async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSessio @pytest_asyncio.fixture -async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]: +async def db_session( + session_factory: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[AsyncSession, None]: """Database session for direct DB operations in tests.""" async with session_factory() as session: yield session @@ -260,7 +264,9 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]: } -async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]: +async def login_client( + client: AsyncClient, email: str, password: str = "TestPass123!" +) -> dict[str, str]: """Login via HTTP API and return cookies dict.""" resp = await client.post( "/api/v1/auth/login", @@ -271,7 +277,9 @@ async def login_client(client: AsyncClient, email: str, password: str = "TestPas return dict(resp.cookies) -async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient: +async def get_auth_client( + client: AsyncClient, email: str, password: str = "TestPass123!" +) -> AsyncClient: """Return a client that's logged in.""" await login_client(client, email, password) return client diff --git a/tests/test_ai_copilot.py b/tests/test_ai_copilot.py index 7ed4f80..544a4e1 100644 --- a/tests/test_ai_copilot.py +++ b/tests/test_ai_copilot.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import UTC + import pytest from httpx import ASGITransport, AsyncClient -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio @@ -68,7 +70,10 @@ async def test_ac3_copilot_execute_blocked_by_rbac(client: AsyncClient, db_sessi # Query for a delete action query_resp = await client.post( "/api/v1/ai/copilot/query", - json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}}, + json={ + "query": "Delete company", + "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}, + }, headers=ORIGIN_HEADER, ) assert query_resp.status_code == 200 @@ -119,6 +124,7 @@ async def test_ac4_copilot_history_paginated(client: AsyncClient, db_session): async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_session): """AC5: Copilot action logged in audit_log with entity_type=ai_copilot.""" from sqlalchemy import select + from app.models.audit import AuditLog await seed_tenant_and_users(db_session) @@ -133,9 +139,7 @@ async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_sessio assert resp.status_code == 200 # Check audit log - result = await db_session.execute( - select(AuditLog).where(AuditLog.entity_type == "ai_copilot") - ) + result = await db_session.execute(select(AuditLog).where(AuditLog.entity_type == "ai_copilot")) logs = result.scalars().all() assert len(logs) >= 1 assert logs[0].action == "query" @@ -158,12 +162,14 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session): conv_id_a = query_resp.json()["conversation_id"] # Login as tenant B admin (different cookie jar) - client2 = AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test") + AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test") # Need to use the same app — just re-login with a fresh client # Actually we need a new client without tenant A cookies - from httpx import AsyncClient as AC + from httpx import AsyncClient as AC # noqa: N817 + # Use the same app instance import app.main + app_instance = app.main.app async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b: @@ -174,7 +180,12 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session): "/api/v1/ai/copilot/execute", json={ "conversation_id": conv_id_a, - "action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9}, + "action": { + "method": "GET", + "path": "/api/v1/companies", + "description": "List", + "confidence": 0.9, + }, }, headers=ORIGIN_HEADER, ) @@ -254,9 +265,11 @@ async def test_copilot_unauthenticated(client: AsyncClient, db_session): # ─── ActionMapper Unit Tests ─── + def test_action_mapper_create_company(): """ActionMapper: 'create company named X' → POST /api/v1/companies.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Create a company named Acme Corp") assert len(actions) == 1 assert actions[0]["method"] == "POST" @@ -268,6 +281,7 @@ def test_action_mapper_create_company(): def test_action_mapper_create_company_no_name(): """ActionMapper: 'create company' without name → default 'New Company'.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Add a new company") assert len(actions) == 1 assert actions[0]["body"]["name"] == "New Company" @@ -276,6 +290,7 @@ def test_action_mapper_create_company_no_name(): def test_action_mapper_delete_company_with_context(): """ActionMapper: 'delete company' with entity_id in context → DELETE with specific ID.""" from app.ai.action_mapper import map_query_to_actions + test_id = "12345678-1234-1234-1234-123456789abc" actions = map_query_to_actions("Delete company", context={"entity_id": test_id}) assert len(actions) == 1 @@ -287,6 +302,7 @@ def test_action_mapper_delete_company_with_context(): def test_action_mapper_delete_company_no_context(): """ActionMapper: 'delete company' without context → DELETE with {id} placeholder.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Remove company") assert len(actions) == 1 assert actions[0]["method"] == "DELETE" @@ -297,6 +313,7 @@ def test_action_mapper_delete_company_no_context(): def test_action_mapper_update_company(): """ActionMapper: 'update company' → PATCH with extracted fields.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Update company industry to Tech, name to FooBar") assert len(actions) == 1 assert actions[0]["method"] == "PATCH" @@ -307,6 +324,7 @@ def test_action_mapper_update_company(): def test_action_mapper_update_company_with_context(): """ActionMapper: 'update company' with entity_id → PATCH with specific path.""" from app.ai.action_mapper import map_query_to_actions + test_id = "12345678-1234-1234-1234-123456789abc" actions = map_query_to_actions("Edit company", context={"company_id": test_id}) assert len(actions) == 1 @@ -316,6 +334,7 @@ def test_action_mapper_update_company_with_context(): def test_action_mapper_list_companies(): """ActionMapper: 'list companies' → GET /api/v1/companies.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Show all compan") assert len(actions) == 1 assert actions[0]["method"] == "GET" @@ -325,6 +344,7 @@ def test_action_mapper_list_companies(): def test_action_mapper_list_companies_with_search(): """ActionMapper: 'find companies named X' → GET with search description.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Find compan named Acme") assert len(actions) == 1 assert "Acme" in actions[0]["description"] @@ -333,6 +353,7 @@ def test_action_mapper_list_companies_with_search(): def test_action_mapper_create_contact(): """ActionMapper: 'create contact named X' → POST /api/v1/contacts.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Create a contact named John Doe") assert len(actions) == 1 assert actions[0]["method"] == "POST" @@ -343,6 +364,7 @@ def test_action_mapper_create_contact(): def test_action_mapper_list_contacts(): """ActionMapper: 'list contacts' → GET /api/v1/contacts.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Show all contact") assert len(actions) == 1 assert actions[0]["method"] == "GET" @@ -352,6 +374,7 @@ def test_action_mapper_list_contacts(): def test_action_mapper_list_workflows(): """ActionMapper: 'list workflows' → GET /api/v1/workflows.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Show all workflow") assert len(actions) == 1 assert actions[0]["method"] == "GET" @@ -361,6 +384,7 @@ def test_action_mapper_list_workflows(): def test_action_mapper_create_workflow(): """ActionMapper: 'create workflow named X' → POST /api/v1/workflows.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Create a new workflow named Approval Process") assert len(actions) == 1 assert actions[0]["method"] == "POST" @@ -371,6 +395,7 @@ def test_action_mapper_create_workflow(): def test_action_mapper_help_intent(): """ActionMapper: 'help' query returns demo action with low confidence.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("What can you do? Help me please") assert len(actions) == 1 assert actions[0]["confidence"] == 0.3 @@ -379,6 +404,7 @@ def test_action_mapper_help_intent(): def test_action_mapper_unknown_query(): """ActionMapper: unrecognized query returns empty actions list.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("xyzzy flonk") assert actions == [] @@ -386,6 +412,7 @@ def test_action_mapper_unknown_query(): def test_action_mapper_update_company_phone_email(): """ActionMapper: update company with phone and email fields.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Update company phone to 123456, email to test@examplecom") assert len(actions) == 1 assert actions[0]["body"]["phone"] == "123456" @@ -395,6 +422,7 @@ def test_action_mapper_update_company_phone_email(): def test_action_mapper_update_company_no_fields(): """ActionMapper: update company without explicit fields → default name.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Modify company details") assert len(actions) == 1 assert "name" in actions[0]["body"] @@ -403,6 +431,7 @@ def test_action_mapper_update_company_no_fields(): def test_action_mapper_company_list_all_pattern(): """ActionMapper: 'companies all' matches list_company2 pattern.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("Show me companies all") assert len(actions) == 1 assert actions[0]["method"] == "GET" @@ -411,16 +440,19 @@ def test_action_mapper_company_list_all_pattern(): def test_action_mapper_empty_query(): """ActionMapper: empty query returns no actions.""" from app.ai.action_mapper import map_query_to_actions + actions = map_query_to_actions("") assert actions == [] # ─── LLMClient Unit Tests ─── + @pytest.mark.asyncio async def test_llm_client_mock_mode_generate(): """LLMClient: mock mode generates actions from keyword matching.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) assert client.is_mock is True @@ -434,6 +466,7 @@ async def test_llm_client_mock_mode_generate(): async def test_llm_client_mock_mode_no_actions(): """LLMClient: mock mode with unrecognized query returns empty actions.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) response = await client.generate("xyzzy flonk") @@ -445,6 +478,7 @@ async def test_llm_client_mock_mode_no_actions(): async def test_llm_client_mock_mode_with_context(): """LLMClient: mock mode passes context to action mapper.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) response = await client.generate("Delete company", context={"entity_id": "abc123"}) @@ -455,6 +489,7 @@ async def test_llm_client_mock_mode_with_context(): def test_llm_client_api_mode_init(): """LLMClient: with model and api_key set, is_mock is False.""" from app.ai.llm_client import LLMClient + client = LLMClient(model="gpt-4", api_key="test-key") assert client.is_mock is False assert client.model == "gpt-4" @@ -464,6 +499,7 @@ def test_llm_client_api_mode_init(): def test_llm_client_api_base_default(): """LLMClient: default api_base is OpenAI URL.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) assert client.api_base == "https://api.openai.com/v1" @@ -471,6 +507,7 @@ def test_llm_client_api_base_default(): def test_llm_client_to_dict(): """LLMResponse: to_dict returns structured response.""" from app.ai.llm_client import LLMResponse + resp = LLMResponse(message="Hello", proposed_actions=[{"method": "GET"}], confidence=0.9) d = resp.to_dict() assert d["message"] == "Hello" @@ -480,14 +517,18 @@ def test_llm_client_to_dict(): def test_llm_client_parse_valid_json(): """LLMClient: _parse_llm_response with valid JSON returns structured response.""" - from app.ai.llm_client import LLMClient import json + + from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) - content = json.dumps({ - "message": "Here are actions", - "proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}], - "confidence": 0.95, - }) + content = json.dumps( + { + "message": "Here are actions", + "proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}], + "confidence": 0.95, + } + ) response = client._parse_llm_response(content) assert response.message == "Here are actions" assert len(response.proposed_actions) == 1 @@ -497,6 +538,7 @@ def test_llm_client_parse_valid_json(): def test_llm_client_parse_invalid_json(): """LLMClient: _parse_llm_response with invalid JSON returns fallback response.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) response = client._parse_llm_response("not valid json at all") assert response.proposed_actions == [] @@ -506,6 +548,7 @@ def test_llm_client_parse_invalid_json(): def test_llm_client_build_system_prompt(): """LLMClient: _build_system_prompt contains API endpoints and context.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) prompt = client._build_system_prompt({"page": "companies"}) assert "LeoCRM" in prompt @@ -516,6 +559,7 @@ def test_llm_client_build_system_prompt(): def test_llm_client_build_system_prompt_empty_context(): """LLMClient: _build_system_prompt with no context uses empty dict.""" from app.ai.llm_client import LLMClient + client = LLMClient(model=None, api_key=None) prompt = client._build_system_prompt({}) assert "LeoCRM" in prompt @@ -523,7 +567,8 @@ def test_llm_client_build_system_prompt_empty_context(): def test_llm_client_get_and_reset(): """LLMClient: get_llm_client returns singleton, reset clears it.""" - from app.ai.llm_client import get_llm_client, reset_llm_client, LLMClient + from app.ai.llm_client import get_llm_client, reset_llm_client + reset_llm_client() client1 = get_llm_client() client2 = get_llm_client() @@ -535,10 +580,12 @@ def test_llm_client_get_and_reset(): # ─── AI Copilot Service Unit Tests ─── + @pytest.mark.asyncio async def test_service_process_query_new_conversation(db_session): """Service: process_query creates new conversation and returns proposed actions.""" from app.services.ai_copilot_service import process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -553,6 +600,7 @@ async def test_service_process_query_new_conversation(db_session): async def test_service_process_query_existing_conversation(db_session): """Service: process_query with existing conversation_id appends to conversation.""" from app.services.ai_copilot_service import process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -563,7 +611,9 @@ async def test_service_process_query_existing_conversation(db_session): # Second query with same conversation_id result2 = await process_query( - db_session, tenant_id, admin_id, + db_session, + tenant_id, + admin_id, "Create a company named FooBar", conversation_id=conv_id, ) @@ -574,12 +624,16 @@ async def test_service_process_query_existing_conversation(db_session): async def test_service_process_query_invalid_conversation(db_session): """Service: process_query with invalid conversation_id returns 404 error.""" from app.services.ai_copilot_service import process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await process_query( - db_session, tenant_id, admin_id, "List companies", + db_session, + tenant_id, + admin_id, + "List companies", conversation_id="00000000-0000-0000-0000-000000000000", ) assert result["error"] == "Conversation not found" @@ -590,6 +644,7 @@ async def test_service_process_query_invalid_conversation(db_session): async def test_service_process_query_empty_query(db_session): """Service: process_query with empty query creates conversation with 'Untitled' title.""" from app.services.ai_copilot_service import process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -602,17 +657,23 @@ async def test_service_process_query_empty_query(db_session): async def test_service_execute_action_companies_get(db_session): """Service: execute_action with GET /api/v1/companies returns list.""" from app.services.ai_copilot_service import execute_action + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # First create a conversation from app.services.ai_copilot_service import process_query + query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "GET", "path": "/api/v1/companies", "body": None}, ) assert result["success"] is True @@ -624,6 +685,7 @@ async def test_service_execute_action_companies_get(db_session): async def test_service_execute_action_companies_post(db_session): """Service: execute_action with POST /api/v1/companies creates company.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -632,8 +694,16 @@ async def test_service_execute_action_companies_post(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, - {"method": "POST", "path": "/api/v1/companies", "body": {"name": "NewCo", "industry": "Tech"}}, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, + { + "method": "POST", + "path": "/api/v1/companies", + "body": {"name": "NewCo", "industry": "Tech"}, + }, ) assert result["success"] is True assert result["status_code"] == 201 @@ -644,6 +714,7 @@ async def test_service_execute_action_companies_post(db_session): async def test_service_execute_action_companies_patch(db_session): """Service: execute_action with PATCH /api/v1/companies/{id} updates company.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -653,15 +724,27 @@ async def test_service_execute_action_companies_patch(db_session): conv_id = query_result["conversation_id"] create_result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "POST", "path": "/api/v1/companies", "body": {"name": "PatchCo"}}, ) company_id = create_result["data"]["id"] # Now patch it patch_result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, - {"method": "PATCH", "path": f"/api/v1/companies/{company_id}", "body": {"name": "PatchedCo"}}, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, + { + "method": "PATCH", + "path": f"/api/v1/companies/{company_id}", + "body": {"name": "PatchedCo"}, + }, ) assert patch_result["success"] is True assert patch_result["data"]["name"] == "PatchedCo" @@ -671,6 +754,7 @@ async def test_service_execute_action_companies_patch(db_session): async def test_service_execute_action_companies_patch_not_found(db_session): """Service: execute_action with PATCH non-existent company returns 404.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -679,8 +763,16 @@ async def test_service_execute_action_companies_patch_not_found(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, - {"method": "PATCH", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": {"name": "X"}}, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, + { + "method": "PATCH", + "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", + "body": {"name": "X"}, + }, ) assert result["success"] is False assert result["status_code"] == 404 @@ -690,6 +782,7 @@ async def test_service_execute_action_companies_patch_not_found(db_session): async def test_service_execute_action_companies_patch_no_id(db_session): """Service: execute_action with PATCH /api/v1/companies/{id} returns 400.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -698,7 +791,11 @@ async def test_service_execute_action_companies_patch_no_id(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "PATCH", "path": "/api/v1/companies/{id}", "body": {"name": "X"}}, ) assert result["success"] is False @@ -709,6 +806,7 @@ async def test_service_execute_action_companies_patch_no_id(db_session): async def test_service_execute_action_companies_delete(db_session): """Service: execute_action with DELETE /api/v1/companies/{id} soft-deletes company.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -717,13 +815,21 @@ async def test_service_execute_action_companies_delete(db_session): conv_id = query_result["conversation_id"] create_result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "POST", "path": "/api/v1/companies", "body": {"name": "DeleteMe"}}, ) company_id = create_result["data"]["id"] del_result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "DELETE", "path": f"/api/v1/companies/{company_id}", "body": None}, ) assert del_result["success"] is True @@ -734,6 +840,7 @@ async def test_service_execute_action_companies_delete(db_session): async def test_service_execute_action_companies_delete_not_found(db_session): """Service: execute_action with DELETE non-existent company returns 404.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -742,8 +849,16 @@ async def test_service_execute_action_companies_delete_not_found(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, - {"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None}, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, + { + "method": "DELETE", + "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", + "body": None, + }, ) assert result["success"] is False assert result["status_code"] == 404 @@ -753,6 +868,7 @@ async def test_service_execute_action_companies_delete_not_found(db_session): async def test_service_execute_action_companies_delete_no_id(db_session): """Service: execute_action with DELETE without ID returns 400.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -761,7 +877,11 @@ async def test_service_execute_action_companies_delete_no_id(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "DELETE", "path": "/api/v1/companies/{id}", "body": None}, ) assert result["success"] is False @@ -772,6 +892,7 @@ async def test_service_execute_action_companies_delete_no_id(db_session): async def test_service_execute_action_contacts_get(db_session): """Service: execute_action with GET /api/v1/contacts returns list.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -780,7 +901,11 @@ async def test_service_execute_action_contacts_get(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "GET", "path": "/api/v1/contacts", "body": None}, ) assert result["success"] is True @@ -792,6 +917,7 @@ async def test_service_execute_action_contacts_post(db_session): """Service: execute_action with POST /api/v1/contacts — Contact model uses first_name/last_name, service passes 'name' which causes error, verify error handling works.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -800,8 +926,16 @@ async def test_service_execute_action_contacts_post(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, - {"method": "POST", "path": "/api/v1/contacts", "body": {"name": "John Doe", "email": "john@example.com"}}, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, + { + "method": "POST", + "path": "/api/v1/contacts", + "body": {"name": "John Doe", "email": "john@example.com"}, + }, ) # Contact model has first_name/last_name, not name — service code attempts to set 'name' # which raises TypeError, caught by execute_action's try/except @@ -813,6 +947,7 @@ async def test_service_execute_action_contacts_post(db_session): async def test_service_execute_action_contacts_unsupported_method(db_session): """Service: execute_action with DELETE /api/v1/contacts returns 400 (unsupported).""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -821,7 +956,11 @@ async def test_service_execute_action_contacts_unsupported_method(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "DELETE", "path": "/api/v1/contacts/123", "body": None}, ) assert result["success"] is False @@ -832,6 +971,7 @@ async def test_service_execute_action_contacts_unsupported_method(db_session): async def test_service_execute_action_workflows_get(db_session): """Service: execute_action with GET /api/v1/workflows returns list.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -840,7 +980,11 @@ async def test_service_execute_action_workflows_get(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "GET", "path": "/api/v1/workflows", "body": None}, ) assert result["success"] is True @@ -851,6 +995,7 @@ async def test_service_execute_action_workflows_get(db_session): async def test_service_execute_action_workflows_unsupported_method(db_session): """Service: execute_action with POST /api/v1/workflows returns 400 (unsupported).""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -859,7 +1004,11 @@ async def test_service_execute_action_workflows_unsupported_method(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "POST", "path": "/api/v1/workflows", "body": {"name": "test"}}, ) assert result["success"] is False @@ -870,6 +1019,7 @@ async def test_service_execute_action_workflows_unsupported_method(db_session): async def test_service_execute_action_unsupported_entity(db_session): """Service: execute_action with unknown entity returns 400.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -878,7 +1028,11 @@ async def test_service_execute_action_unsupported_entity(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "GET", "path": "/api/v1/unknown", "body": None}, ) assert result["success"] is False @@ -890,6 +1044,7 @@ async def test_service_execute_action_unsupported_entity(db_session): async def test_service_execute_action_companies_unsupported_method(db_session): """Service: execute_action with PUT /api/v1/companies returns 400 (unsupported).""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -898,7 +1053,11 @@ async def test_service_execute_action_companies_unsupported_method(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "admin", conv_id, + db_session, + tenant_id, + admin_id, + "admin", + conv_id, {"method": "PUT", "path": "/api/v1/companies", "body": {}}, ) assert result["success"] is False @@ -909,12 +1068,16 @@ async def test_service_execute_action_companies_unsupported_method(db_session): async def test_service_execute_action_invalid_conversation(db_session): """Service: execute_action with invalid conversation_id returns 404.""" from app.services.ai_copilot_service import execute_action + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await execute_action( - db_session, tenant_id, admin_id, "admin", + db_session, + tenant_id, + admin_id, + "admin", "00000000-0000-0000-0000-000000000000", {"method": "GET", "path": "/api/v1/companies", "body": None}, ) @@ -926,6 +1089,7 @@ async def test_service_execute_action_invalid_conversation(db_session): async def test_service_execute_action_rbac_blocked(db_session): """Service: execute_action as viewer with DELETE returns 403.""" from app.services.ai_copilot_service import execute_action, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -934,8 +1098,16 @@ async def test_service_execute_action_rbac_blocked(db_session): conv_id = query_result["conversation_id"] result = await execute_action( - db_session, tenant_id, admin_id, "viewer", conv_id, - {"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None}, + db_session, + tenant_id, + admin_id, + "viewer", + conv_id, + { + "method": "DELETE", + "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", + "body": None, + }, ) assert result["status_code"] == 403 assert result["success"] is False @@ -945,6 +1117,7 @@ async def test_service_execute_action_rbac_blocked(db_session): async def test_service_get_history_pagination(db_session): """Service: get_history returns paginated results.""" from app.services.ai_copilot_service import get_history, process_query + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -963,6 +1136,7 @@ async def test_service_get_history_pagination(db_session): async def test_service_get_history_empty(db_session): """Service: get_history returns empty when no conversations exist.""" from app.services.ai_copilot_service import get_history + seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id @@ -975,6 +1149,7 @@ async def test_service_get_history_empty(db_session): def test_service_derive_rbac_from_path_companies_get(): """Service: _derive_rbac_from_path for GET /api/v1/companies → (companies, read).""" from app.services.ai_copilot_service import _derive_rbac_from_path + module, action = _derive_rbac_from_path("GET", "/api/v1/companies") assert module == "companies" assert action == "read" @@ -983,6 +1158,7 @@ def test_service_derive_rbac_from_path_companies_get(): def test_service_derive_rbac_from_path_contacts_post(): """Service: _derive_rbac_from_path for POST /api/v1/contacts → (contacts, create).""" from app.services.ai_copilot_service import _derive_rbac_from_path + module, action = _derive_rbac_from_path("POST", "/api/v1/contacts") assert module == "contacts" assert action == "create" @@ -991,6 +1167,7 @@ def test_service_derive_rbac_from_path_contacts_post(): def test_service_derive_rbac_from_path_workflows_patch(): """Service: _derive_rbac_from_path for PATCH /api/v1/workflows → (workflows, update).""" from app.services.ai_copilot_service import _derive_rbac_from_path + module, action = _derive_rbac_from_path("PATCH", "/api/v1/workflows") assert module == "workflows" assert action == "update" @@ -999,6 +1176,7 @@ def test_service_derive_rbac_from_path_workflows_patch(): def test_service_derive_rbac_from_path_unknown_entity(): """Service: _derive_rbac_from_path for unknown entity returns entity as module.""" from app.services.ai_copilot_service import _derive_rbac_from_path + module, action = _derive_rbac_from_path("DELETE", "/api/v1/foobar") assert module == "foobar" assert action == "delete" @@ -1007,6 +1185,7 @@ def test_service_derive_rbac_from_path_unknown_entity(): def test_service_derive_rbac_from_path_ai_entity(): """Service: _derive_rbac_from_path for /api/v1/ai → (ai_copilot, read).""" from app.services.ai_copilot_service import _derive_rbac_from_path + module, action = _derive_rbac_from_path("GET", "/api/v1/ai/copilot/query") assert module == "ai_copilot" assert action == "read" @@ -1015,14 +1194,17 @@ def test_service_derive_rbac_from_path_ai_entity(): def test_service_safe_iso_none(): """Service: _safe_iso with None returns None.""" from app.services.ai_copilot_service import _safe_iso + assert _safe_iso(None) is None def test_service_safe_iso_datetime(): """Service: _safe_iso with datetime returns ISO string.""" + from datetime import datetime + from app.services.ai_copilot_service import _safe_iso - from datetime import datetime, timezone - dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) result = _safe_iso(dt) assert result is not None assert "2026-01-01" in result @@ -1031,15 +1213,18 @@ def test_service_safe_iso_datetime(): def test_service_safe_iso_exception(): """Service: _safe_iso with object that raises on isoformat returns None.""" from app.services.ai_copilot_service import _safe_iso + class Bad: def isoformat(self): raise ValueError("bad") + assert _safe_iso(Bad()) is None def test_service_get_attr_missing(): """Service: _get_attr with missing attribute returns default.""" from app.services.ai_copilot_service import _get_attr + obj = type("Obj", (), {"x": 1})() assert _get_attr(obj, "x") == 1 assert _get_attr(obj, "y", "default") == "default" @@ -1048,12 +1233,14 @@ def test_service_get_attr_missing(): def test_service_get_attr_none(): """Service: _get_attr with None value returns default.""" from app.services.ai_copilot_service import _get_attr + obj = type("Obj", (), {"x": None})() assert _get_attr(obj, "x", "fallback") == "fallback" # ─── Route Error Path Tests for AI Copilot ─── + @pytest.mark.asyncio async def test_route_copilot_execute_not_found(client: AsyncClient, db_session): """Route: POST /execute with invalid conversation_id returns 404.""" @@ -1064,7 +1251,12 @@ async def test_route_copilot_execute_not_found(client: AsyncClient, db_session): "/api/v1/ai/copilot/execute", json={ "conversation_id": "00000000-0000-0000-0000-000000000000", - "action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9}, + "action": { + "method": "GET", + "path": "/api/v1/companies", + "description": "List", + "confidence": 0.9, + }, }, headers=ORIGIN_HEADER, ) @@ -1080,7 +1272,10 @@ async def test_route_copilot_execute_rbac_blocked(client: AsyncClient, db_sessio # First create a conversation as viewer query_resp = await client.post( "/api/v1/ai/copilot/query", - json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}}, + json={ + "query": "Delete company", + "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}, + }, headers=ORIGIN_HEADER, ) conv_id = query_resp.json()["conversation_id"] @@ -1108,7 +1303,12 @@ async def test_route_copilot_execute_unauthenticated(client: AsyncClient, db_ses "/api/v1/ai/copilot/execute", json={ "conversation_id": "00000000-0000-0000-0000-000000000000", - "action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9}, + "action": { + "method": "GET", + "path": "/api/v1/companies", + "description": "List", + "confidence": 0.9, + }, }, headers=ORIGIN_HEADER, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 7ea38b4..df1d761 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,7 +5,7 @@ from __future__ import annotations import pytest from httpx import AsyncClient -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio @@ -21,8 +21,9 @@ class TestAuthLogin: headers=ORIGIN_HEADER, ) assert resp.status_code == 200 - assert "leocrm_session" in resp.headers.get("set-cookie", "").lower() or \ - "leocrm_session" in str(resp.cookies) + assert "leocrm_session" in resp.headers.get( + "set-cookie", "" + ).lower() or "leocrm_session" in str(resp.cookies) data = resp.json() assert data["email"] == "admin@tenanta.com" assert data["role"] == "admin" @@ -64,7 +65,9 @@ class TestAuthMe: class TestAuthLogout: """AC 5: logout.""" - async def test_logout_returns_200_and_invalidates_session(self, client: AsyncClient, db_session): + async def test_logout_returns_200_and_invalidates_session( + self, client: AsyncClient, db_session + ): """AC 5: POST /api/v1/auth/logout -> 200, session invalidated.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") @@ -104,6 +107,7 @@ class TestPasswordReset: async def test_password_reset_confirm_valid_token(self, client: AsyncClient, db_session): """AC 7: POST /api/v1/auth/password-reset/confirm valid token -> 200.""" from app.services.auth_service import auth_service + await seed_tenant_and_users(db_session) raw_token = await auth_service.get_password_reset_token_raw(db_session, "admin@tenanta.com") await db_session.commit() @@ -119,6 +123,7 @@ class TestPasswordReset: async def test_password_reset_confirm_expired_token(self, client: AsyncClient, db_session): """AC 8: POST /api/v1/auth/password-reset/confirm expired token -> 400.""" from app.services.auth_service import auth_service + await seed_tenant_and_users(db_session) raw_token = await auth_service.create_expired_reset_token(db_session, "admin@tenanta.com") await db_session.commit() @@ -147,8 +152,10 @@ class TestSwitchTenant: initial_tenant = resp.json()["tenant_id"] # Get tenant B ID - from app.models.tenant import Tenant from sqlalchemy import select + + from app.models.tenant import Tenant + q = select(Tenant).where(Tenant.slug == "tenant-b") result = await db_session.execute(q) tenant_b = result.scalar_one() diff --git a/tests/test_companies.py b/tests/test_companies.py index 7c6f34a..da0b690 100644 --- a/tests/test_companies.py +++ b/tests/test_companies.py @@ -5,7 +5,7 @@ from __future__ import annotations import pytest from httpx import AsyncClient -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio @@ -186,7 +186,7 @@ class TestCompanyDelete: json={"first_name": "John", "last_name": "Doe", "company_ids": [company_id]}, headers=ORIGIN_HEADER, ) - contact_id = cont_resp.json()["id"] + cont_resp.json()["id"] # Verify link exists detail_resp = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) assert len(detail_resp.json()["contacts"]) == 1 @@ -318,7 +318,9 @@ class TestCompanyAuditAndSoftDelete: async def test_audit_log_on_company_create(self, client: AsyncClient, db_session): """AC 23: Audit log entry on every company/contact mutation.""" from sqlalchemy import select + from app.models.audit import AuditLog + await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") await client.post( diff --git a/tests/test_contacts.py b/tests/test_contacts.py index 2312fdc..02e26dc 100644 --- a/tests/test_contacts.py +++ b/tests/test_contacts.py @@ -5,7 +5,7 @@ from __future__ import annotations import pytest from httpx import AsyncClient -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio @@ -29,7 +29,9 @@ class TestContactList: class TestContactCreate: """AC 15: Create contact with company_ids array -> N:M links.""" - async def test_create_contact_with_company_ids_returns_201(self, client: AsyncClient, db_session): + async def test_create_contact_with_company_ids_returns_201( + self, client: AsyncClient, db_session + ): """AC 15: POST /api/v1/contacts mit company_ids array -> 201 + N:M links.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") @@ -132,12 +134,17 @@ class TestContactDelete: names = [f"{item['first_name']} {item['last_name']}" for item in list_resp.json()["items"]] assert "Delete Me" not in names - async def test_delete_contact_gdpr_hard_delete_returns_204(self, client: AsyncClient, db_session): + async def test_delete_contact_gdpr_hard_delete_returns_204( + self, client: AsyncClient, db_session + ): """AC 19: DELETE /api/v1/contacts/{id}?gdpr=true -> 204, hard-delete + deletion_log.""" + import uuid as uuid_mod + from sqlalchemy import select + from app.models.audit import DeletionLog from app.models.contact import Contact - import uuid as uuid_mod + await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( diff --git a/tests/test_entity_links.py b/tests/test_entity_links.py index 4b44861..ac7d0c3 100644 --- a/tests/test_entity_links.py +++ b/tests/test_entity_links.py @@ -7,16 +7,16 @@ import uuid import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from app.core.db import reset_engine_for_testing, close_engine +from app.core.db import close_engine, reset_engine_for_testing from app.core.event_bus import get_event_bus from app.core.service_container import get_container from app.main import create_app -from app.plugins.registry import reset_registry_for_testing from app.plugins.builtins.entity_links import EntityLinksPlugin +from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing -from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest_asyncio.fixture @@ -240,14 +240,18 @@ async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient): # Publish company.deleted event event_bus = get_event_bus() - await event_bus.publish("company.deleted", { - "entity_id": str(company_id), - "company_id": str(company_id), - "tenant_id": str(tenant_id), - }) + await event_bus.publish( + "company.deleted", + { + "entity_id": str(company_id), + "company_id": str(company_id), + "tenant_id": str(tenant_id), + }, + ) # Allow async event handler to complete import asyncio + await asyncio.sleep(0.1) # Verify link is cleaned up @@ -279,14 +283,18 @@ async def test_event_cleanup_on_contact_deleted(authed_client: AsyncClient): # Publish contact.deleted event event_bus = get_event_bus() - await event_bus.publish("contact.deleted", { - "entity_id": str(contact_id), - "contact_id": str(contact_id), - "tenant_id": str(tenant_id), - }) + await event_bus.publish( + "contact.deleted", + { + "entity_id": str(contact_id), + "contact_id": str(contact_id), + "tenant_id": str(tenant_id), + }, + ) # Allow async event handler to complete import asyncio + await asyncio.sleep(0.1) # Verify link is cleaned up diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 966133d..0f2733c 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -2,12 +2,10 @@ from __future__ import annotations -import io import pytest from httpx import AsyncClient -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client - +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users CSV_COMPANIES = """name,industry,phone,email,website,description ImportCorp,IT,123456,import@example.com,https://import.example,Imported company diff --git a/tests/test_notifications.py b/tests/test_notifications.py index a2f3846..8a5ebfd 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -2,16 +2,14 @@ from __future__ import annotations -import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest from httpx import AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client from app.core.notifications import create_notification from app.models.notification import Notification +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio @@ -23,22 +21,31 @@ class TestNotifications: seed = await seed_tenant_and_users(db_session) # Create some notifications for admin_a await create_notification( - db_session, seed["tenant_a"].id, seed["admin_a"].id, - "info", "Read Notif", "Already read", + db_session, + seed["tenant_a"].id, + seed["admin_a"].id, + "info", + "Read Notif", + "Already read", ) await db_session.flush() # Mark the first one as read - from sqlalchemy import select, update + from sqlalchemy import select + q = select(Notification).where(Notification.title == "Read Notif") result = await db_session.execute(q) first_notif = result.scalar_one() - first_notif.read_at = datetime.now(timezone.utc) + first_notif.read_at = datetime.now(UTC) await db_session.flush() # Create an unread one await create_notification( - db_session, seed["tenant_a"].id, seed["admin_a"].id, - "info", "Unread Notif", "Not read yet", + db_session, + seed["tenant_a"].id, + seed["admin_a"].id, + "info", + "Unread Notif", + "Not read yet", ) await db_session.commit() @@ -64,8 +71,12 @@ class TestNotifications: """AC 25: PATCH /api/v1/notifications/{id}/read -> 200.""" seed = await seed_tenant_and_users(db_session) notif = await create_notification( - db_session, seed["tenant_a"].id, seed["admin_a"].id, - "info", "Test Notif", "Test body", + db_session, + seed["tenant_a"].id, + seed["admin_a"].id, + "info", + "Test Notif", + "Test body", ) await db_session.commit() @@ -81,12 +92,20 @@ class TestNotifications: """AC 26: GET /api/v1/notifications/unread-count -> 200 + integer count.""" seed = await seed_tenant_and_users(db_session) await create_notification( - db_session, seed["tenant_a"].id, seed["admin_a"].id, - "info", "Unread 1", "Body 1", + db_session, + seed["tenant_a"].id, + seed["admin_a"].id, + "info", + "Unread 1", + "Body 1", ) await create_notification( - db_session, seed["tenant_a"].id, seed["admin_a"].id, - "info", "Unread 2", "Body 2", + db_session, + seed["tenant_a"].id, + seed["admin_a"].id, + "info", + "Unread 2", + "Body 2", ) await db_session.commit() diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 38ea3e1..112bf4f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -3,20 +3,20 @@ from __future__ import annotations import uuid -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from app.core.db import reset_engine_for_testing, close_engine +from app.core.db import close_engine, reset_engine_for_testing from app.core.service_container import get_container from app.main import create_app -from app.plugins.registry import reset_registry_for_testing from app.plugins.builtins.permissions import PermissionsPlugin +from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing -from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest_asyncio.fixture @@ -182,7 +182,7 @@ async def test_expired_share_link(authed_client: AsyncClient): file_id = str(uuid.uuid4()) # Create link with expiry in the past - past = datetime.now(timezone.utc) - timedelta(hours=1) + past = datetime.now(UTC) - timedelta(hours=1) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"expires_at": past.isoformat(), "access_level": "download"}, @@ -235,21 +235,23 @@ async def test_revoke_share_link(authed_client: AsyncClient): @pytest.mark.asyncio -async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, db_session: AsyncSession): +async def test_permission_403_for_unauthorized_user( + plugin_client: AsyncClient, db_session: AsyncSession +): """AC14: Folder permissions enforced: user without read → 403. The permissions plugin does not intercept file access (no DMS file system yet). We test the permission check helper directly: a user with no permission record gets denied (False), which translates to 403 at the route layer. """ - from app.plugins.builtins.permissions.routes import check_user_file_permission from app.core.db import get_session_factory + from app.plugins.builtins.permissions.routes import check_user_file_permission seed = await seed_tenant_and_users(db_session) await login_client(plugin_client, "admin@tenanta.com") # Install + activate permissions plugin - registry = reset_registry_for_testing() + reset_registry_for_testing() # We need to set up the registry properly # Since plugin_client fixture wasn't used, manually install resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER) @@ -270,6 +272,7 @@ async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, # Grant read to admin from app.plugins.builtins.permissions.models import Permission + perm = Permission( tenant_id=tenant_id, file_id=file_id, @@ -422,7 +425,7 @@ async def test_public_access_post_expired(authed_client: AsyncClient): """POST /api/public/share/{token} with expired link → 410.""" client, seed = authed_client file_id = str(uuid.uuid4()) - past = datetime.now(timezone.utc) - timedelta(hours=1) + past = datetime.now(UTC) - timedelta(hours=1) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"expires_at": past.isoformat(), "access_level": "download"}, diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 994af36..35e9581 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -3,31 +3,31 @@ from __future__ import annotations import asyncio -import uuid from typing import Any import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient -from sqlalchemy import text, select, inspect -from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession +from sqlalchemy import inspect, select, text +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker -from app.core.db import Base, reset_engine_for_testing, close_engine, get_engine +from app.core.db import close_engine, reset_engine_for_testing from app.core.event_bus import get_event_bus from app.core.service_container import get_container from app.main import create_app -from app.models.plugin import Plugin as PluginModel, PluginMigration +from app.models.plugin import Plugin as PluginModel +from app.models.plugin import PluginMigration from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest -from app.plugins.registry import get_registry, reset_registry_for_testing -from app.plugins.migration_runner import MigrationRunner, MigrationValidationError from app.plugins.builtins.test_sample import TestSamplePlugin +from app.plugins.manifest import PluginManifest +from app.plugins.migration_runner import MigrationRunner, MigrationValidationError +from app.plugins.registry import get_registry, reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing -from tests.conftest import TEST_DB_URL, seed_tenant_and_users, login_client, ORIGIN_HEADER - +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users # ─── Bad Plugin for AC11 (migration without tenant_id) ─── + class BadMigrationPlugin(BasePlugin): """Plugin with a migration that creates a table WITHOUT tenant_id.""" @@ -46,6 +46,7 @@ class BadMigrationPlugin(BasePlugin): # ─── Fixtures ─── + @pytest_asyncio.fixture async def plugin_app(engine: AsyncEngine, redis_client): """FastAPI app with plugin registry initialized for testing.""" @@ -82,7 +83,7 @@ async def plugin_client(plugin_app) -> AsyncClient: @pytest_asyncio.fixture async def authed_plugin_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient: """Authenticated admin client for plugin tests.""" - seed = await seed_tenant_and_users(db_session) + await seed_tenant_and_users(db_session) await login_client(plugin_client, "admin@tenanta.com") return plugin_client @@ -98,6 +99,7 @@ async def db_session_for_plugins(engine: AsyncEngine) -> AsyncSession: # ─── AC1: GET /api/v1/plugins → 200 + list of plugins with status ─── + @pytest.mark.asyncio async def test_ac01_list_plugins(authed_plugin_client: AsyncClient): """AC1: GET /api/v1/plugins returns 200 with plugin list and status.""" @@ -119,10 +121,15 @@ async def test_ac01_list_plugins(authed_plugin_client: AsyncClient): # ─── AC2: POST /api/v1/plugins/{name}/install → 200, status=installed, migrations run ─── + @pytest.mark.asyncio -async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession): +async def test_ac02_install_plugin( + authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession +): """AC2: Install plugin runs migrations and sets status=installed.""" - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "test_sample" @@ -132,22 +139,29 @@ async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session # Verify migration table was created (tenant_id column exists) result = await db_session_for_plugins.execute( - text("SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'") + text( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'" + ) ) assert result.fetchone() is not None, "plugin_test_data table should have tenant_id column" # ─── AC3: POST /api/v1/plugins/{name}/activate → 200, status=active, routes registered ─── + @pytest.mark.asyncio async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient): """AC3: Activate plugin sets status=active and registers event listeners.""" # Install first - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 # Activate - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "test_sample" @@ -157,6 +171,7 @@ async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient): # ─── AC4: POST /api/v1/plugins/{name}/deactivate → 200, status=inactive, routes unregistered ─── + @pytest.mark.asyncio async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient): """AC4: Deactivate plugin sets status=inactive and unregisters event listeners.""" @@ -165,7 +180,9 @@ async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient): await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) # Deactivate - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "test_sample" @@ -175,8 +192,11 @@ async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient): # ─── AC5: DELETE /api/v1/plugins/{name} → 200, plugin removed ─── + @pytest.mark.asyncio -async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession): +async def test_ac05_uninstall_plugin( + authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession +): """AC5: Uninstall plugin removes DB record.""" # Install first await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) @@ -197,6 +217,7 @@ async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_sessi # ─── AC6: DELETE /api/v1/plugins/{name}?remove_data=true → 200, plugin tables dropped ─── + @pytest.mark.asyncio async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, engine: AsyncEngine): """AC6: Uninstall with remove_data=true drops plugin tables.""" @@ -207,6 +228,7 @@ async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, eng def _check_table(sync_conn): insp = inspect(sync_conn) return "plugin_test_data" in insp.get_table_names() + async with engine.connect() as conn: table_exists_before = await conn.run_sync(_check_table) assert table_exists_before, "plugin_test_data table should exist after install" @@ -223,11 +245,14 @@ async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, eng # Verify table is gone async with engine.connect() as conn: table_exists_after = await conn.run_sync(_check_table) - assert not table_exists_after, "plugin_test_data table should be dropped after uninstall with remove_data=true" + assert ( + not table_exists_after + ), "plugin_test_data table should be dropped after uninstall with remove_data=true" # ─── AC7: GET /api/v1/plugins/manifest → 200 + manifest schema documentation ─── + @pytest.mark.asyncio async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient): """AC7: GET /api/v1/plugins/manifest returns schema documentation.""" @@ -250,6 +275,7 @@ async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient): # ─── AC8: Plugin activation registers event listeners on event bus ─── + @pytest.mark.asyncio async def test_ac08_activation_registers_event_listeners( authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession @@ -281,6 +307,7 @@ async def test_ac08_activation_registers_event_listeners( # ─── AC9: Plugin deactivation unregisters event listeners ─── + @pytest.mark.asyncio async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_client: AsyncClient): """AC9: Deactivating a plugin unregisters its event listeners from the event bus.""" @@ -297,11 +324,15 @@ async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_clien # Publish an event — handler should NOT be called after deactivation initial_log_count = len(plugin.event_log) - await event_bus.publish("company.created", {"company_id": "test-456", "name": "After Deactivate"}) + await event_bus.publish( + "company.created", {"company_id": "test-456", "name": "After Deactivate"} + ) await asyncio.sleep(0.1) # Event log should not have grown - assert len(plugin.event_log) == initial_log_count, "Event handler should not be called after deactivation" + assert ( + len(plugin.event_log) == initial_log_count + ), "Event handler should not be called after deactivation" # Check event bus no longer has the plugin's handlers handlers = event_bus._handlers.get("company.created", []) @@ -312,11 +343,16 @@ async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_clien # ─── AC10: Plugin migration creates tables with tenant_id column ─── + @pytest.mark.asyncio -async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClient, engine: AsyncEngine): +async def test_ac10_migration_creates_tenant_id( + authed_plugin_client: AsyncClient, engine: AsyncEngine +): """AC10: Plugin migration creates tables that have tenant_id column.""" # Install the test_sample plugin which has a migration creating plugin_test_data - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 # Verify the table has tenant_id column @@ -335,6 +371,7 @@ async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClien # ─── AC11: Plugin migration validator rejects tables without tenant_id ─── + @pytest.mark.asyncio async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncClient): """AC11: Migration validator rejects tables created without tenant_id column.""" @@ -357,11 +394,16 @@ async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncCl # ─── AC12: Plugin DB migrations tracked in plugin_migrations table ─── + @pytest.mark.asyncio -async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession): +async def test_ac12_migrations_tracked( + authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession +): """AC12: Plugin migrations are tracked in plugin_migrations table.""" # Install the test_sample plugin - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 # Check plugin_migrations table has a record @@ -376,17 +418,22 @@ async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_ses # ─── AC13: Activating already-active plugin → idempotent (200, no error) ─── + @pytest.mark.asyncio async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient): """AC13: Activating an already-active plugin is idempotent (returns 200, no error).""" # Install and activate await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 assert resp.json()["status"] == "active" # Activate again — should be idempotent - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "active" @@ -396,6 +443,7 @@ async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient): # ─── AC14: Deactivating inactive plugin → idempotent (200) ─── + @pytest.mark.asyncio async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClient): """AC14: Deactivating an already-inactive plugin is idempotent (returns 200, no error).""" @@ -404,12 +452,16 @@ async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClien await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) # Deactivate - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 assert resp.json()["status"] == "inactive" # Deactivate again — should be idempotent - resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) + resp = await authed_plugin_client.post( + "/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER + ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "inactive" @@ -419,6 +471,7 @@ async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClien # ─── Additional Tests: Direct MigrationRunner Unit Tests ─── + @pytest.mark.asyncio async def test_registry_discover_builtins(engine: AsyncEngine): """Test that registry can discover built-in plugins.""" @@ -431,7 +484,9 @@ async def test_registry_discover_builtins(engine: AsyncEngine): @pytest.mark.asyncio -async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_list_plugins_mixed_states( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test listing plugins in various states (discovered, installed, active, inactive).""" registry = reset_registry_for_testing() registry.initialize(engine, None) @@ -464,7 +519,9 @@ async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_sessio @pytest.mark.asyncio -async def test_registry_install_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_install_idempotent( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test that installing an already-installed plugin is idempotent.""" registry = reset_registry_for_testing() registry.initialize(engine, None) @@ -500,7 +557,9 @@ async def test_registry_not_found_errors(engine: AsyncEngine, db_session_for_plu @pytest.mark.asyncio -async def test_registry_activate_without_install(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_activate_without_install( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test that activating without install raises error.""" registry = reset_registry_for_testing() registry.initialize(engine, None) @@ -511,7 +570,9 @@ async def test_registry_activate_without_install(engine: AsyncEngine, db_session @pytest.mark.asyncio -async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_migration_runner_drop_tables( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test MigrationRunner.drop_plugin_tables removes tables and migration records.""" runner = MigrationRunner(engine) @@ -545,7 +606,9 @@ async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_ @pytest.mark.asyncio -async def test_migration_runner_file_not_found(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_migration_runner_file_not_found( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test MigrationRunner raises FileNotFoundError for missing migration file.""" runner = MigrationRunner(engine) with pytest.raises(FileNotFoundError): @@ -592,7 +655,7 @@ def test_plugin_manifest_validation(): assert m2.name == "myplugin" # Invalid name with special chars - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 PluginManifest(name="my-plugin!", version="1.0.0", display_name="Test") @@ -606,6 +669,7 @@ def test_base_plugin_repr(): def test_base_plugin_no_manifest_error(): """Test that BasePlugin without manifest raises ValueError.""" + class NoManifestPlugin(BasePlugin): pass @@ -623,10 +687,14 @@ def test_base_plugin_name_version_properties(): @pytest.mark.asyncio async def test_base_plugin_on_install_default(): """Test default on_install returns None (no-op).""" + class MinimalPlugin(BasePlugin): manifest = PluginManifest( - name="minimal", version="1.0.0", display_name="Minimal", + name="minimal", + version="1.0.0", + display_name="Minimal", ) + plugin = MinimalPlugin() result = await plugin.on_install(None, None) assert result is None @@ -635,10 +703,14 @@ async def test_base_plugin_on_install_default(): @pytest.mark.asyncio async def test_base_plugin_on_uninstall_default(): """Test default on_uninstall returns None (no-op).""" + class MinimalPlugin(BasePlugin): manifest = PluginManifest( - name="minimal2", version="1.0.0", display_name="Minimal", + name="minimal2", + version="1.0.0", + display_name="Minimal", ) + plugin = MinimalPlugin() result = await plugin.on_uninstall(None, None) assert result is None @@ -653,11 +725,15 @@ def test_base_plugin_get_routes_empty(): def test_base_plugin_make_event_handler_noop(): """Test that _make_event_handler creates noop for unknown events.""" + class MinimalPlugin(BasePlugin): manifest = PluginManifest( - name="minimal3", version="1.0.0", display_name="Minimal", + name="minimal3", + version="1.0.0", + display_name="Minimal", events=["unknown.event"], ) + plugin = MinimalPlugin() handler = plugin._make_event_handler("unknown.event") assert handler is not None @@ -669,6 +745,7 @@ def test_base_plugin_make_event_handler_noop(): async def test_service_manifest_schema(): """Test plugin service get_manifest_schema returns dict.""" from app.services.plugin_service import PluginService + service = PluginService() schema = service.get_manifest_schema() assert isinstance(schema, dict) @@ -694,10 +771,13 @@ async def test_uninstall_inactive_plugin(engine: AsyncEngine, db_session_for_plu @pytest.mark.asyncio -async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_activate_with_app_routes( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test that activating a plugin with a FastAPI app registers routes.""" - from fastapi import FastAPI, APIRouter - from app.plugins.manifest import PluginManifest, PluginRouteDef + from fastapi import APIRouter, FastAPI + + from app.plugins.manifest import PluginManifest # Create a plugin with a route class RoutePlugin(BasePlugin): @@ -707,11 +787,14 @@ async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session display_name="Route Plugin", events=["test.event"], ) + def get_routes(self) -> list: test_router = APIRouter() + @test_router.get("/api/v1/route-plugin/test") async def test_endpoint(): return {"status": "ok"} + return [test_router] app = FastAPI() @@ -737,7 +820,9 @@ async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session @pytest.mark.asyncio -async def test_registry_uninstall_with_remove_data(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_uninstall_with_remove_data( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test registry uninstall with remove_data drops tables.""" registry = reset_registry_for_testing() registry.initialize(engine, None) @@ -763,7 +848,9 @@ async def test_registry_not_initialized_errors(): @pytest.mark.asyncio -async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_deactivate_already_inactive_direct( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test registry.deactivate is idempotent when already inactive.""" registry = reset_registry_for_testing() registry.initialize(engine, None) @@ -785,9 +872,12 @@ async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine, @pytest.mark.asyncio -async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_plugin_service_install_error_handling( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test plugin service raises ValueError for unknown plugin.""" from app.services.plugin_service import PluginService + registry = reset_registry_for_testing() registry.initialize(engine, None) service = PluginService(registry=registry) @@ -797,9 +887,12 @@ async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_ses @pytest.mark.asyncio -async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_plugin_service_activate_error_handling( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test plugin service raises ValueError when activating uninstalled plugin.""" from app.services.plugin_service import PluginService + registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) @@ -810,9 +903,12 @@ async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_se @pytest.mark.asyncio -async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_plugin_service_deactivate_error_handling( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test plugin service raises ValueError for deactivating unknown plugin.""" from app.services.plugin_service import PluginService + registry = reset_registry_for_testing() registry.initialize(engine, None) service = PluginService(registry=registry) @@ -822,9 +918,12 @@ async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_ @pytest.mark.asyncio -async def test_plugin_service_uninstall_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_plugin_service_uninstall_error_handling( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test plugin service raises ValueError for uninstalling unknown plugin.""" from app.services.plugin_service import PluginService + registry = reset_registry_for_testing() registry.initialize(engine, None) service = PluginService(registry=registry) @@ -842,7 +941,9 @@ async def test_migration_runner_split_sql_dollar_quotes(): @pytest.mark.asyncio -async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_registry_list_plugins_db_only_record( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test listing plugins includes DB-only records (installed but not discovered).""" registry = reset_registry_for_testing() registry.initialize(engine, None) @@ -869,11 +970,15 @@ async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_sess @pytest.mark.asyncio async def test_base_plugin_on_event_fallback(): """Test that on_event fallback handler works.""" + class FallbackPlugin(BasePlugin): manifest = PluginManifest( - name="fallback", version="1.0.0", display_name="Fallback", + name="fallback", + version="1.0.0", + display_name="Fallback", events=["custom.event"], ) + async def on_event(self, payload: dict[str, Any]) -> None: self.event_received = payload @@ -884,7 +989,9 @@ async def test_base_plugin_on_event_fallback(): @pytest.mark.asyncio -async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_migration_runner_valid_migration( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test MigrationRunner directly with a valid migration (has tenant_id).""" runner = MigrationRunner(engine) record = await runner.run_migration( @@ -899,7 +1006,9 @@ async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_ @pytest.mark.asyncio -async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_migration_runner_invalid_migration( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test MigrationRunner directly rejects migration without tenant_id.""" runner = MigrationRunner(engine) with pytest.raises(MigrationValidationError) as exc_info: @@ -912,7 +1021,9 @@ async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_sessio @pytest.mark.asyncio -async def test_migration_runner_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession): +async def test_migration_runner_idempotent( + engine: AsyncEngine, db_session_for_plugins: AsyncSession +): """Test MigrationRunner skips already-applied migrations.""" runner = MigrationRunner(engine) # Run migration diff --git a/tests/test_tags.py b/tests/test_tags.py index 5f32408..5aaab9c 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -7,16 +7,15 @@ import uuid import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from app.core.db import Base, reset_engine_for_testing, close_engine +from app.core.db import close_engine, reset_engine_for_testing from app.core.service_container import get_container from app.main import create_app -from app.plugins.registry import get_registry, reset_registry_for_testing from app.plugins.builtins.tags import TagsPlugin +from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing -from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest_asyncio.fixture @@ -48,7 +47,7 @@ async def plugin_client(plugin_app) -> AsyncClient: @pytest_asyncio.fixture async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient: """Authenticated admin client with seeded data.""" - seed = await seed_tenant_and_users(db_session) + await seed_tenant_and_users(db_session) await login_client(plugin_client, "admin@tenanta.com") # Install + activate the tags plugin resp = await plugin_client.post("/api/v1/plugins/tags/install", headers=ORIGIN_HEADER) @@ -91,7 +90,7 @@ async def test_list_tags_with_counts(authed_client: AsyncClient): headers=ORIGIN_HEADER, ) assert resp.status_code == 201 - tag_id = resp.json()["id"] + resp.json()["id"] # List tags — should show entity_count=0 resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER) @@ -302,11 +301,15 @@ async def test_update_tag_invalid_id(authed_client: AsyncClient): async def test_update_tag_duplicate_name(authed_client: AsyncClient): """PATCH /api/v1/tags/{id} with existing name → 409.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "TagA", "color": "#AAAAAA"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "TagA", "color": "#AAAAAA"}, + headers=ORIGIN_HEADER, ) assert resp.status_code == 201 resp = await authed_client.post( - "/api/v1/tags", json={"name": "TagB", "color": "#BBBBBB"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "TagB", "color": "#BBBBBB"}, + headers=ORIGIN_HEADER, ) assert resp.status_code == 201 tag_b_id = resp.json()["id"] @@ -322,7 +325,9 @@ async def test_update_tag_duplicate_name(authed_client: AsyncClient): async def test_update_tag_color_only(authed_client: AsyncClient): """PATCH /api/v1/tags/{id} with only color → 200.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "ColorTag", "color": "#CCCCCC"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "ColorTag", "color": "#CCCCCC"}, + headers=ORIGIN_HEADER, ) tag_id = resp.json()["id"] resp = await authed_client.patch( @@ -352,7 +357,9 @@ async def test_delete_tag_invalid_id(authed_client: AsyncClient): async def test_assign_tag_invalid_entity_type(authed_client: AsyncClient): """POST /api/v1/tags/assign with invalid entity_type → 400.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "ETTag", "color": "#EEEEEE"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "ETTag", "color": "#EEEEEE"}, + headers=ORIGIN_HEADER, ) tag_id = resp.json()["id"] resp = await authed_client.post( @@ -368,7 +375,11 @@ async def test_assign_tag_not_found(authed_client: AsyncClient): """POST /api/v1/tags/assign with nonexistent tag → 404.""" resp = await authed_client.post( "/api/v1/tags/assign", - json={"tag_id": str(uuid.uuid4()), "entity_type": "company", "entity_id": str(uuid.uuid4())}, + json={ + "tag_id": str(uuid.uuid4()), + "entity_type": "company", + "entity_id": str(uuid.uuid4()), + }, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @@ -378,7 +389,9 @@ async def test_assign_tag_not_found(authed_client: AsyncClient): async def test_assign_tag_already_assigned(authed_client: AsyncClient): """POST /api/v1/tags/assign twice → already_assigned=True.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "DupAssign", "color": "#FFFFFF"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "DupAssign", "color": "#FFFFFF"}, + headers=ORIGIN_HEADER, ) tag_id = resp.json()["id"] entity_id = str(uuid.uuid4()) @@ -413,7 +426,9 @@ async def test_assign_tag_invalid_ids(authed_client: AsyncClient): async def test_unassign_tag_not_found(authed_client: AsyncClient): """DELETE /api/v1/tags/assign with nonexistent assignment → 404.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "Unassign404", "color": "#ABABAB"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "Unassign404", "color": "#ABABAB"}, + headers=ORIGIN_HEADER, ) tag_id = resp.json()["id"] resp = await authed_client.request( @@ -429,7 +444,9 @@ async def test_unassign_tag_not_found(authed_client: AsyncClient): async def test_bulk_assign_invalid_entity_type(authed_client: AsyncClient): """POST /api/v1/tags/bulk-assign with invalid entity_type → 400.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "BulkBad", "color": "#000001"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "BulkBad", "color": "#000001"}, + headers=ORIGIN_HEADER, ) tag_id = resp.json()["id"] resp = await authed_client.post( @@ -445,7 +462,11 @@ async def test_bulk_assign_tag_not_found(authed_client: AsyncClient): """POST /api/v1/tags/bulk-assign with nonexistent tag → 404.""" resp = await authed_client.post( "/api/v1/tags/bulk-assign", - json={"tag_ids": [str(uuid.uuid4())], "entity_type": "file", "entity_id": str(uuid.uuid4())}, + json={ + "tag_ids": [str(uuid.uuid4())], + "entity_type": "file", + "entity_id": str(uuid.uuid4()), + }, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @@ -455,7 +476,9 @@ async def test_bulk_assign_tag_not_found(authed_client: AsyncClient): async def test_bulk_assign_already_assigned(authed_client: AsyncClient): """POST /api/v1/tags/bulk-assign twice → already_assigned populated.""" resp = await authed_client.post( - "/api/v1/tags", json={"name": "BulkDup1", "color": "#001100"}, headers=ORIGIN_HEADER, + "/api/v1/tags", + json={"name": "BulkDup1", "color": "#001100"}, + headers=ORIGIN_HEADER, ) tag_id = resp.json()["id"] entity_id = str(uuid.uuid4()) diff --git a/tests/test_tenant.py b/tests/test_tenant.py index 95c83c0..6fa241c 100644 --- a/tests/test_tenant.py +++ b/tests/test_tenant.py @@ -5,12 +5,10 @@ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client from app.models.audit import AuditLog from app.models.notification import Notification -from app.models.company import Company +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio @@ -60,6 +58,7 @@ class TestUserManagement: await login_client(client, "admin@tenanta.com") # Get viewer user ID from app.models.user import User + q = select(User).where(User.email == "viewer@tenanta.com") result = await db_session.execute(q) viewer = result.scalar_one() @@ -76,6 +75,7 @@ class TestUserManagement: await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") from app.models.user import User + q = select(User).where(User.email == "viewer@tenanta.com") result = await db_session.execute(q) viewer = result.scalar_one() @@ -102,7 +102,9 @@ class TestRoleManagement: assert "permissions" in data["items"][0] assert "field_permissions" in data["items"][0] - async def test_create_role_with_custom_permissions_returns_201(self, client: AsyncClient, db_session): + async def test_create_role_with_custom_permissions_returns_201( + self, client: AsyncClient, db_session + ): """AC 16: POST /api/v1/roles with custom permissions -> 201.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") @@ -110,7 +112,9 @@ class TestRoleManagement: "/api/v1/roles", json={ "name": "manager", - "permissions": {"companies": {"read": True, "create": True, "update": True, "delete": True}}, + "permissions": { + "companies": {"read": True, "create": True, "update": True, "delete": True} + }, "field_permissions": {"annual_revenue": "read"}, }, headers=ORIGIN_HEADER, @@ -172,6 +176,7 @@ class TestFieldPermissions: # Create a user with sales_rep role from app.core.auth import hash_password from app.models.user import User, UserTenant + sales_user = User( tenant_id=seed["tenant_a"].id, email="sales@tenanta.com", @@ -213,6 +218,7 @@ class TestAuditLog: # Check audit_log table from sqlalchemy import select as sel + q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "create") result = await db_session.execute(q) entries = result.scalars().all() @@ -230,6 +236,7 @@ class TestAuditLog: assert resp.status_code == 200 from sqlalchemy import select as sel + q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "update") result = await db_session.execute(q) entries = result.scalars().all() @@ -246,6 +253,7 @@ class TestAuditLog: assert resp.status_code == 204 from sqlalchemy import select as sel + q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "delete") result = await db_session.execute(q) entries = result.scalars().all() @@ -275,6 +283,7 @@ class TestNotificationOnAssign: # Check notification was created for the new user from sqlalchemy import select as sel + q = sel(Notification).where(Notification.user_id == new_user_id) result = await db_session.execute(q) notifs = result.scalars().all() diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 84a8e88..e65aabd 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3,24 +3,28 @@ from __future__ import annotations import uuid -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta import pytest -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - from httpx import AsyncClient +from sqlalchemy import select -from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client -from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory -from app.models.audit import AuditLog from app.models.notification import Notification +from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory from app.services.workflow_service import ( - create_workflow, list_workflows, get_workflow, update_workflow, delete_workflow, - create_instance, list_instances, get_instance, advance_instance, cancel_instance, - check_timeout, auto_reject_timeout, find_workflows_for_event, start_instance_for_event, + advance_instance, + auto_reject_timeout, + check_timeout, + create_instance, + create_workflow, + delete_workflow, + find_workflows_for_event, + get_instance, + list_workflows, + start_instance_for_event, + update_workflow, ) - +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users # ─── Workflow CRUD (ACs 8-12) ─── @@ -144,6 +148,7 @@ async def test_ac12_delete_workflow(client: AsyncClient, db_session): # ─── Instance Lifecycle (ACs 13-18) ─── + @pytest.mark.asyncio async def test_ac13_create_instance(client: AsyncClient, db_session): """AC13: POST /api/v1/workflows/{id}/instances returns 201, instance created with status=pending.""" @@ -328,6 +333,7 @@ async def test_ac18_cancel_instance(client: AsyncClient, db_session): # ─── Event-Triggered & Code-Engine (ACs 19-22) ─── + @pytest.mark.asyncio async def test_ac19_event_triggered_workflow(db_session): """AC19: Event-triggered workflow — publish event → workflow instance auto-starts.""" @@ -336,8 +342,10 @@ async def test_ac19_event_triggered_workflow(db_session): admin_id = seed["admin_a"].id # Create a workflow with trigger_event - wf = await create_workflow( - db_session, tenant_id, admin_id, + await create_workflow( + db_session, + tenant_id, + admin_id, { "name": "Event Triggered WF", "trigger_event": "company.created", @@ -347,7 +355,9 @@ async def test_ac19_event_triggered_workflow(db_session): # Simulate event by calling start_instance_for_event instances = await start_instance_for_event( - db_session, tenant_id, admin_id, + db_session, + tenant_id, + admin_id, "company.created", context={"company_id": "test"}, ) @@ -363,7 +373,9 @@ async def test_ac20_step_history_created(db_session): admin_id = seed["admin_a"].id wf = await create_workflow( - db_session, tenant_id, admin_id, + db_session, + tenant_id, + admin_id, {"name": "History WF", "steps": VALID_STEPS}, ) wf_id = wf["id"] @@ -376,9 +388,7 @@ async def test_ac20_step_history_created(db_session): # Check step history result = await db_session.execute( - select(WorkflowStepHistory).where( - WorkflowStepHistory.instance_id == uuid.UUID(inst_id) - ) + select(WorkflowStepHistory).where(WorkflowStepHistory.instance_id == uuid.UUID(inst_id)) ) history = result.scalars().all() assert len(history) >= 3 # entered + approved + entered (next step) @@ -387,7 +397,7 @@ async def test_ac20_step_history_created(db_session): @pytest.mark.asyncio async def test_ac21_onboarding_workflow_on_user_creation(db_session): """AC21: Code-engine workflow — onboarding workflow runs on user creation.""" - from app.workflows.code.onboarding import trigger_onboarding, get_onboarding_workflow_definition + from app.workflows.code.onboarding import trigger_onboarding seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id @@ -419,7 +429,9 @@ async def test_ac22_approval_timeout_auto_reject(db_session): # Create workflow with approval step wf = await create_workflow( - db_session, tenant_id, admin_id, + db_session, + tenant_id, + admin_id, { "name": "Timeout WF", "steps": [ @@ -430,7 +442,9 @@ async def test_ac22_approval_timeout_auto_reject(db_session): # Create instance with timeout_hours=1 inst = await create_instance( - db_session, tenant_id, admin_id, + db_session, + tenant_id, + admin_id, wf["id"], timeout_hours=1, ) @@ -441,7 +455,7 @@ async def test_ac22_approval_timeout_auto_reject(db_session): select(WorkflowInstance).where(WorkflowInstance.id == inst_id) ) instance = result.scalar_one() - instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1) + instance.timeout_at = datetime.now(UTC) - timedelta(hours=1) await db_session.flush() # Check timeout detection @@ -461,6 +475,7 @@ async def test_ac22_approval_timeout_auto_reject(db_session): # ─── Edge Cases ─── + @pytest.mark.asyncio async def test_workflow_not_found(client: AsyncClient, db_session): """Edge case: Get non-existent workflow returns 404.""" @@ -489,7 +504,10 @@ async def test_cancel_completed_instance_fails(client: AsyncClient, db_session): create_resp = await client.post( "/api/v1/workflows", - json={"name": "Complete WF", "steps": [{"name": "Only step", "type": "action", "config": {"action_type": "noop"}}]}, + json={ + "name": "Complete WF", + "steps": [{"name": "Only step", "type": "action", "config": {"action_type": "noop"}}], + }, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] @@ -530,8 +548,11 @@ async def test_workflow_tenant_isolation(client: AsyncClient, db_session): wf_id = create_resp.json()["id"] # Login as tenant B - from httpx import ASGITransport, AsyncClient as AC + from httpx import ASGITransport + from httpx import AsyncClient as AC # noqa: N817 + import app.main + app_instance = app.main.app async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b: @@ -557,6 +578,7 @@ async def test_workflow_create_rbac_viewer_blocked(client: AsyncClient, db_sessi # ─── WorkflowEngine Unit Tests (engine.py direct coverage) ─── + @pytest.mark.asyncio async def test_engine_process_action_step_noop(db_session): """Engine: action step with noop config advances to next step.""" @@ -566,13 +588,18 @@ async def test_engine_process_action_step_noop(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Engine Noop WF", - "steps": [ - {"name": "Action", "type": "action", "config": {"action_type": "noop"}}, - {"name": "Approval", "type": "approval", "config": {}}, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Engine Noop WF", + "steps": [ + {"name": "Action", "type": "action", "config": {"action_type": "noop"}}, + {"name": "Approval", "type": "approval", "config": {}}, + ], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) # Fetch the instance object @@ -596,22 +623,27 @@ async def test_engine_process_action_step_create_notification(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Engine Notif WF", - "steps": [ - { - "name": "Create Notif", - "type": "action", - "config": { - "action_type": "create_notification", - "user_id": str(admin_id), - "title": "Test Notif", - "body": "Hello from engine", - "notification_type": "workflow_action", + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Engine Notif WF", + "steps": [ + { + "name": "Create Notif", + "type": "action", + "config": { + "action_type": "create_notification", + "user_id": str(admin_id), + "title": "Test Notif", + "body": "Hello from engine", + "notification_type": "workflow_action", + }, }, - }, - ], - }) + ], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -638,10 +670,15 @@ async def test_engine_process_action_completes_last_step(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Single Step WF", - "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Single Step WF", + "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -664,10 +701,15 @@ async def test_engine_process_approval_step(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Approval WF", - "steps": [{"name": "Approval", "type": "approval", "config": {}}], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Approval WF", + "steps": [{"name": "Approval", "type": "approval", "config": {}}], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -690,21 +732,26 @@ async def test_engine_process_notification_step(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Notif Step WF", - "steps": [ - { - "name": "Notify", - "type": "notification", - "config": { - "user_id": str(admin_id), - "title": "Step Notif", - "body": "You have a notification", - "notification_type": "wf_notif", + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Notif Step WF", + "steps": [ + { + "name": "Notify", + "type": "notification", + "config": { + "user_id": str(admin_id), + "title": "Step Notif", + "body": "You have a notification", + "notification_type": "wf_notif", + }, }, - }, - ], - }) + ], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -730,26 +777,34 @@ async def test_engine_process_condition_eq_true(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond Eq WF", - "steps": [ - { - "name": "Check Status", - "type": "condition", - "config": { - "field": "status", - "operator": "eq", - "value": "active", - "on_true_step": 2, - "on_false_step": 1, + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond Eq WF", + "steps": [ + { + "name": "Check Status", + "type": "condition", + "config": { + "field": "status", + "operator": "eq", + "value": "active", + "on_true_step": 2, + "on_false_step": 1, + }, }, - }, - {"name": "False Step", "type": "action", "config": {"action_type": "noop"}}, - {"name": "True Step", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + {"name": "False Step", "type": "action", "config": {"action_type": "noop"}}, + {"name": "True Step", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"status": "active"}, ) @@ -772,26 +827,34 @@ async def test_engine_process_condition_eq_false(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond Eq False WF", - "steps": [ - { - "name": "Check Status", - "type": "condition", - "config": { - "field": "status", - "operator": "eq", - "value": "active", - "on_true_step": 2, - "on_false_step": 1, + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond Eq False WF", + "steps": [ + { + "name": "Check Status", + "type": "condition", + "config": { + "field": "status", + "operator": "eq", + "value": "active", + "on_true_step": 2, + "on_false_step": 1, + }, }, - }, - {"name": "False Step", "type": "action", "config": {"action_type": "noop"}}, - {"name": "True Step", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + {"name": "False Step", "type": "action", "config": {"action_type": "noop"}}, + {"name": "True Step", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"status": "inactive"}, ) @@ -814,23 +877,31 @@ async def test_engine_process_condition_ne(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond NE WF", - "steps": [ - { - "name": "Check", - "type": "condition", - "config": { - "field": "level", - "operator": "ne", - "value": "low", + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond NE WF", + "steps": [ + { + "name": "Check", + "type": "condition", + "config": { + "field": "level", + "operator": "ne", + "value": "low", + }, }, - }, - {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"level": "high"}, ) @@ -853,19 +924,27 @@ async def test_engine_process_condition_gt(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond GT WF", - "steps": [ - { - "name": "Check Count", - "type": "condition", - "config": {"field": "count", "operator": "gt", "value": 5}, - }, - {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond GT WF", + "steps": [ + { + "name": "Check Count", + "type": "condition", + "config": {"field": "count", "operator": "gt", "value": 5}, + }, + {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"count": 10}, ) @@ -888,19 +967,27 @@ async def test_engine_process_condition_lt(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond LT WF", - "steps": [ - { - "name": "Check Count", - "type": "condition", - "config": {"field": "count", "operator": "lt", "value": 100}, - }, - {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond LT WF", + "steps": [ + { + "name": "Check Count", + "type": "condition", + "config": {"field": "count", "operator": "lt", "value": 100}, + }, + {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"count": 50}, ) @@ -923,19 +1010,27 @@ async def test_engine_process_condition_contains(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond Contains WF", - "steps": [ - { - "name": "Check Tags", - "type": "condition", - "config": {"field": "tags", "operator": "contains", "value": "vip"}, - }, - {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond Contains WF", + "steps": [ + { + "name": "Check Tags", + "type": "condition", + "config": {"field": "tags", "operator": "contains", "value": "vip"}, + }, + {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"tags": "vip,premium"}, ) @@ -958,19 +1053,27 @@ async def test_engine_process_condition_contains_list(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond List WF", - "steps": [ - { - "name": "Check List", - "type": "condition", - "config": {"field": "roles", "operator": "contains", "value": "admin"}, - }, - {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond List WF", + "steps": [ + { + "name": "Check List", + "type": "condition", + "config": {"field": "roles", "operator": "contains", "value": "admin"}, + }, + {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"roles": ["admin", "editor"]}, ) @@ -993,19 +1096,27 @@ async def test_engine_process_condition_no_match_advances(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond No Branch WF", - "steps": [ - { - "name": "Check", - "type": "condition", - "config": {"field": "status", "operator": "eq", "value": "active"}, - }, - {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond No Branch WF", + "steps": [ + { + "name": "Check", + "type": "condition", + "config": {"field": "status", "operator": "eq", "value": "active"}, + }, + {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"status": "inactive"}, ) @@ -1028,18 +1139,26 @@ async def test_engine_process_condition_completes_last_step(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Cond Last WF", - "steps": [ - { - "name": "Check", - "type": "condition", - "config": {"field": "status", "operator": "eq", "value": "active"}, - }, - ], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Cond Last WF", + "steps": [ + { + "name": "Check", + "type": "condition", + "config": {"field": "status", "operator": "eq", "value": "active"}, + }, + ], + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], + db_session, + tenant_id, + admin_id, + wf["id"], context={"status": "active"}, ) @@ -1062,10 +1181,15 @@ async def test_engine_unknown_step_type(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Unknown Type WF", - "steps": [{"name": "Bad", "type": "unknown_type", "config": {}}], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Unknown Type WF", + "steps": [{"name": "Bad", "type": "unknown_type", "config": {}}], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -1082,19 +1206,25 @@ async def test_engine_unknown_step_type(db_session): @pytest.mark.asyncio async def test_engine_workflow_not_found(db_session): """Engine: process_step with non-existent workflow returns 404 error.""" - from unittest.mock import AsyncMock, MagicMock, patch - from app.workflows.engine import WorkflowEngine + from unittest.mock import MagicMock, patch + from app.models.workflow import WorkflowInstance + from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create a real workflow and instance - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Temp WF for Not Found Test", - "steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Temp WF for Not Found Test", + "steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -1130,10 +1260,15 @@ async def test_engine_step_index_beyond_steps_completes(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Beyond Steps WF", - "steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}], - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Beyond Steps WF", + "steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}], + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -1157,14 +1292,21 @@ async def test_engine_handle_event(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Event Handler WF", - "trigger_event": "company.created", - "steps": VALID_STEPS, - }) + await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Event Handler WF", + "trigger_event": "company.created", + "steps": VALID_STEPS, + }, + ) instances = await handle_event( - db_session, tenant_id, "company.created", + db_session, + tenant_id, + "company.created", {"user_id": str(admin_id), "company_id": "test"}, ) assert len(instances) >= 1 @@ -1180,7 +1322,9 @@ async def test_engine_handle_event_no_match(db_session): tenant_id = seed["tenant_a"].id instances = await handle_event( - db_session, tenant_id, "nonexistent.event", + db_session, + tenant_id, + "nonexistent.event", {"user_id": str(seed["admin_a"].id)}, ) assert instances == [] @@ -1189,8 +1333,9 @@ async def test_engine_handle_event_no_match(db_session): def test_engine_register_workflow_event_handlers(): """Engine: register_workflow_event_handlers subscribes to event bus.""" from collections import defaultdict - from app.workflows.engine import register_workflow_event_handlers + from app.core.event_bus import get_event_bus + from app.workflows.engine import register_workflow_event_handlers event_bus = get_event_bus() # Reset handlers to a fresh defaultdict for test isolation @@ -1206,6 +1351,7 @@ def test_engine_register_workflow_event_handlers(): # ─── Route Error Path Tests ─── + @pytest.mark.asyncio async def test_route_update_workflow_not_found(client: AsyncClient, db_session): """Route: PATCH non-existent workflow returns 404.""" @@ -1234,11 +1380,16 @@ async def test_route_update_workflow_rbac_blocked(client: AsyncClient, db_sessio wf_id = create_resp.json()["id"] # Login as viewer - from httpx import ASGITransport, AsyncClient as AC + from httpx import ASGITransport + from httpx import AsyncClient as AC # noqa: N817 + import app.main + app_instance = app.main.app - async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as viewer_client: + async with AC( + transport=ASGITransport(app=app_instance), base_url="http://test" + ) as viewer_client: await login_client(viewer_client, "viewer@tenanta.com") resp = await viewer_client.patch( f"/api/v1/workflows/{wf_id}", @@ -1271,11 +1422,16 @@ async def test_route_delete_workflow_rbac_blocked(client: AsyncClient, db_sessio ) wf_id = create_resp.json()["id"] - from httpx import ASGITransport, AsyncClient as AC + from httpx import ASGITransport + from httpx import AsyncClient as AC # noqa: N817 + import app.main + app_instance = app.main.app - async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as viewer_client: + async with AC( + transport=ASGITransport(app=app_instance), base_url="http://test" + ) as viewer_client: await login_client(viewer_client, "viewer@tenanta.com") resp = await viewer_client.delete(f"/api/v1/workflows/{wf_id}", headers=ORIGIN_HEADER) assert resp.status_code == 403 @@ -1330,7 +1486,10 @@ async def test_route_advance_completed_instance_returns_400(client: AsyncClient, create_resp = await client.post( "/api/v1/workflows", - json={"name": "Single Step", "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}]}, + json={ + "name": "Single Step", + "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}], + }, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] @@ -1395,6 +1554,7 @@ async def test_route_cancel_rejected_instance_returns_400(client: AsyncClient, d # ─── Service Edge Case Tests ─── + @pytest.mark.asyncio async def test_service_check_timeout_no_timeout_at(db_session): """Service: check_timeout returns False when timeout_at is None.""" @@ -1402,9 +1562,15 @@ async def test_service_check_timeout_no_timeout_at(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "No Timeout WF", "steps": VALID_STEPS, - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "No Timeout WF", + "steps": VALID_STEPS, + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( @@ -1423,11 +1589,21 @@ async def test_service_check_timeout_completed_status(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Completed Timeout WF", "steps": VALID_STEPS, - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Completed Timeout WF", + "steps": VALID_STEPS, + }, + ) inst = await create_instance( - db_session, tenant_id, admin_id, wf["id"], timeout_hours=1, + db_session, + tenant_id, + admin_id, + wf["id"], + timeout_hours=1, ) result = await db_session.execute( @@ -1435,7 +1611,7 @@ async def test_service_check_timeout_completed_status(db_session): ) instance = result.scalar_one() instance.status = "completed" - instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1) + instance.timeout_at = datetime.now(UTC) - timedelta(hours=1) await db_session.flush() is_timed_out = await check_timeout(instance) @@ -1449,9 +1625,15 @@ async def test_service_auto_reject_no_initiated_by(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "No Initiator WF", "steps": VALID_STEPS, - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "No Initiator WF", + "steps": VALID_STEPS, + }, + ) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"], timeout_hours=1) result = await db_session.execute( @@ -1459,7 +1641,7 @@ async def test_service_auto_reject_no_initiated_by(db_session): ) instance = result.scalar_one() instance.initiated_by = None - instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1) + instance.timeout_at = datetime.now(UTC) - timedelta(hours=1) await db_session.flush() reject_result = await auto_reject_timeout(db_session, tenant_id, instance) @@ -1484,7 +1666,9 @@ async def test_service_start_instance_no_match(db_session): admin_id = seed["admin_a"].id instances = await start_instance_for_event( - db_session, tenant_id, admin_id, + db_session, + tenant_id, + admin_id, "nonexistent.event", context={}, ) @@ -1499,13 +1683,27 @@ async def test_service_list_workflows_with_active_filter(db_session): admin_id = seed["admin_a"].id # Create active workflow - await create_workflow(db_session, tenant_id, admin_id, { - "name": "Active WF", "steps": VALID_STEPS, "is_active": True, - }) + await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Active WF", + "steps": VALID_STEPS, + "is_active": True, + }, + ) # Create inactive workflow - await create_workflow(db_session, tenant_id, admin_id, { - "name": "Inactive WF", "steps": VALID_STEPS, "is_active": False, - }) + await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Inactive WF", + "steps": VALID_STEPS, + "is_active": False, + }, + ) result = await list_workflows(db_session, tenant_id, is_active=True) assert result["total"] == 1 @@ -1519,15 +1717,27 @@ async def test_service_update_workflow_with_steps(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - wf = await create_workflow(db_session, tenant_id, admin_id, { - "name": "Steps Update WF", "steps": VALID_STEPS, - }) + wf = await create_workflow( + db_session, + tenant_id, + admin_id, + { + "name": "Steps Update WF", + "steps": VALID_STEPS, + }, + ) new_steps = [{"name": "New Step", "type": "action", "config": {"action_type": "noop"}}] - result = await update_workflow(db_session, tenant_id, admin_id, wf["id"], { - "steps": new_steps, - "is_active": False, - }) + result = await update_workflow( + db_session, + tenant_id, + admin_id, + wf["id"], + { + "steps": new_steps, + "is_active": False, + }, + ) assert result is not None assert len(result["steps"]) == 1 assert result["steps"][0]["name"] == "New Step" @@ -1541,7 +1751,9 @@ async def test_service_update_workflow_not_found(db_session): tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id - result = await update_workflow(db_session, tenant_id, admin_id, str(uuid.uuid4()), {"name": "X"}) + result = await update_workflow( + db_session, tenant_id, admin_id, str(uuid.uuid4()), {"name": "X"} + ) assert result is None @@ -1559,7 +1771,6 @@ async def test_service_delete_workflow_not_found(db_session): @pytest.mark.asyncio async def test_service_get_instance_not_found(db_session): """Service: get_instance returns None for non-existent instance.""" - from app.services.workflow_service import get_instance seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id