fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
This commit is contained in:
+21
-18
@@ -21,8 +21,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import litellm
|
||||
|
||||
@@ -100,7 +100,7 @@ _PERMANENT_KEYWORDS = frozenset(
|
||||
|
||||
def _get_cost_key(tenant_id: uuid.UUID | str) -> str:
|
||||
"""Build the Redis cost-tracking key for the current month."""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
month_str = now.strftime("%Y-%m")
|
||||
return f"cost:tenant:{tenant_id}:month:{month_str}"
|
||||
|
||||
@@ -204,7 +204,7 @@ async def _check_cost_alerts(
|
||||
return
|
||||
|
||||
thresholds = [0.50, 0.80, 1.00]
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
month_str = now.strftime("%Y-%m")
|
||||
|
||||
try:
|
||||
@@ -226,11 +226,11 @@ async def _check_cost_alerts(
|
||||
# Best-effort system notification
|
||||
if db is not None:
|
||||
try:
|
||||
from app.core.notifications import post_system_message
|
||||
# Need a user_id — try to find an admin for this tenant
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.notifications import post_system_message
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.role import Role
|
||||
|
||||
async with db.begin_nested() if db.in_transaction() else _NoopCtx():
|
||||
result = await db.execute(
|
||||
@@ -289,11 +289,12 @@ async def get_api_credentials(
|
||||
# Fallback to DB provider
|
||||
if db and tenant_id:
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
|
||||
|
||||
provider = await get_default_provider(db, tenant_id)
|
||||
if provider and provider.api_key:
|
||||
return provider.api_key, provider.base_url, provider.provider_type
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
ai_contract = get_contract("ai_assistant")
|
||||
if ai_contract is not None:
|
||||
provider = await ai_contract.get_default_provider(db, tenant_id)
|
||||
if provider and provider.api_key:
|
||||
return provider.api_key, provider.base_url, provider.provider_type
|
||||
except Exception:
|
||||
logger.debug("Failed to get provider from DB, falling back to env")
|
||||
|
||||
@@ -316,9 +317,11 @@ async def get_provider_compliance(
|
||||
if not (db and tenant_id):
|
||||
return None
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
|
||||
|
||||
provider = await get_default_provider(db, tenant_id)
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
ai_contract = get_contract("ai_assistant")
|
||||
if ai_contract is None:
|
||||
return None
|
||||
provider = await ai_contract.get_default_provider(db, tenant_id)
|
||||
if provider is None:
|
||||
return None
|
||||
return {
|
||||
@@ -449,7 +452,7 @@ def _extract_usage(response: Any) -> dict[str, int]:
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def llm_complete(
|
||||
async def llm_complete( # noqa: ASYNC109
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
@@ -459,7 +462,7 @@ async def llm_complete(
|
||||
api_base: str | None = None,
|
||||
provider: str | None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
timeout: int = DEFAULT_TIMEOUT, # noqa: ASYNC109
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
trace_id: str | None = None,
|
||||
tenant_id: uuid.UUID | str | None = None,
|
||||
@@ -578,7 +581,7 @@ async def llm_complete(
|
||||
raise last_exc
|
||||
|
||||
|
||||
async def llm_embed(
|
||||
async def llm_embed( # noqa: ASYNC109
|
||||
texts: str | list[str],
|
||||
model: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
@@ -587,7 +590,7 @@ async def llm_embed(
|
||||
api_base: str | None = None,
|
||||
provider: str | None = None,
|
||||
dimensions: int | None = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
timeout: int = DEFAULT_TIMEOUT, # noqa: ASYNC109
|
||||
trace_id: str | None = None,
|
||||
) -> list[list[float]]:
|
||||
"""Generic text embedding via LiteLLM.
|
||||
|
||||
@@ -9,28 +9,17 @@ Commands encapsulate business operations with:
|
||||
|
||||
Commands do NOT commit or rollback — the calling layer (FastAPI dependency
|
||||
``get_db``) manages the transaction boundary.
|
||||
|
||||
Note: Plugin-specific commands (mail, calendar, dms) have been moved to their
|
||||
respective plugins. Import them directly from the plugin package.
|
||||
"""
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.commands.contact_commands import (
|
||||
CreateContactCommand,
|
||||
UpdateContactCommand,
|
||||
DeleteContactCommand,
|
||||
MergeContactsCommand,
|
||||
)
|
||||
from app.commands.dms_commands import (
|
||||
UploadFileCommand,
|
||||
DeleteFileCommand,
|
||||
)
|
||||
from app.commands.mail_commands import (
|
||||
SendMailCommand,
|
||||
MarkMailReadCommand,
|
||||
DeleteMailCommand,
|
||||
)
|
||||
from app.commands.calendar_commands import (
|
||||
CreateCalendarEntryCommand,
|
||||
UpdateCalendarEntryCommand,
|
||||
DeleteCalendarEntryCommand,
|
||||
UpdateContactCommand,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -40,12 +29,4 @@ __all__ = [
|
||||
"UpdateContactCommand",
|
||||
"DeleteContactCommand",
|
||||
"MergeContactsCommand",
|
||||
"UploadFileCommand",
|
||||
"DeleteFileCommand",
|
||||
"SendMailCommand",
|
||||
"MarkMailReadCommand",
|
||||
"DeleteMailCommand",
|
||||
"CreateCalendarEntryCommand",
|
||||
"UpdateCalendarEntryCommand",
|
||||
"DeleteCalendarEntryCommand",
|
||||
]
|
||||
|
||||
@@ -15,9 +15,8 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
@@ -41,12 +40,12 @@ class CommandResult:
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> "CommandResult":
|
||||
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> CommandResult:
|
||||
"""Create a successful result."""
|
||||
return cls(success=True, data=data, events=events or [])
|
||||
|
||||
@classmethod
|
||||
def fail(cls, error: str) -> "CommandResult":
|
||||
def fail(cls, error: str) -> CommandResult:
|
||||
"""Create a failed result."""
|
||||
return cls(success=False, error=error)
|
||||
|
||||
|
||||
@@ -14,20 +14,17 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
from app.core.state_machine import contact_state_machine, StateMachineError
|
||||
from app.core.state_machine import StateMachineError, contact_state_machine
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.services import contact_service, dedup_service
|
||||
from app.services.entity_history_service import record_history
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+2
-2
@@ -122,8 +122,8 @@ def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
s = Settings()
|
||||
# Safety checks — always validate critical settings
|
||||
_DEFAULT_KEY = "change-me-in-production-use-a-secure-random-string"
|
||||
if s.secret_key == _DEFAULT_KEY:
|
||||
_default_key = "change-me-in-production-use-a-secure-random-string"
|
||||
if s.secret_key == _default_key:
|
||||
raise RuntimeError("SECRET_KEY must be changed from default value")
|
||||
if len(s.secret_key) < 32:
|
||||
raise RuntimeError("SECRET_KEY must be at least 32 characters long")
|
||||
|
||||
@@ -10,10 +10,10 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.auth import ApiToken
|
||||
|
||||
+7
-5
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -211,10 +210,12 @@ async def get_session_data(redis: aioredis.Redis, session_id: str) -> dict[str,
|
||||
|
||||
# DB fallback: query sessions table
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_auth_session_factory
|
||||
from app.models.session import Session as SessionModel
|
||||
from sqlalchemy import select
|
||||
from datetime import UTC, datetime
|
||||
|
||||
factory = get_auth_session_factory()
|
||||
async with factory() as db:
|
||||
@@ -254,9 +255,10 @@ async def invalidate_session(redis: aioredis.Redis, session_id: str) -> None:
|
||||
await redis.delete(f"session:{session_id}")
|
||||
# Also invalidate in PostgreSQL fallback
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models.session import SessionModel
|
||||
from sqlalchemy import delete
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
await db.execute(
|
||||
|
||||
@@ -224,7 +224,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
Used for normal API requests with tenant context set via RLS.
|
||||
Includes retry logic for transient connection errors.
|
||||
"""
|
||||
from app.core.resilience import get_circuit, retry_db, _is_transient_db_error
|
||||
from app.core.resilience import _is_transient_db_error, get_circuit, retry_db
|
||||
|
||||
async def _get_session():
|
||||
factory = get_session_factory()
|
||||
|
||||
@@ -18,7 +18,6 @@ from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
DELEGATION_AUDIENCE = "internal-ai-delegation"
|
||||
MAX_TOKEN_LIFETIME = 60 # seconds
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ Every API error response follows the schema:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCategory(str, enum.Enum):
|
||||
class ErrorCategory(StrEnum):
|
||||
"""Error classification for retry decisions."""
|
||||
|
||||
TRANSIENT = "transient" # retryable: timeout, rate-limit, connection
|
||||
|
||||
+15
-50
@@ -31,9 +31,14 @@ def register_history_hooks(
|
||||
after_create_hook: str,
|
||||
after_update_hook: str,
|
||||
after_delete_hook: str,
|
||||
owner_tag: str | None = None,
|
||||
) -> None:
|
||||
"""Register standard history-recording hooks for an entity type.
|
||||
|
||||
Args:
|
||||
owner_tag: Plugin name that owns these hooks. Used for targeted
|
||||
deregistration in on_deactivate() via unregister_actions_by_owner().
|
||||
|
||||
Each hook receives kwargs: db, tenant_id, user_id, and either:
|
||||
- after_create: snapshot_after (the created entity dict)
|
||||
- after_update: snapshot_before, snapshot_after, changes
|
||||
@@ -99,9 +104,9 @@ def register_history_hooks(
|
||||
action="delete", snapshot_before=snapshot_before,
|
||||
)
|
||||
|
||||
reg.register_action(after_create_hook, _on_create, priority=90)
|
||||
reg.register_action(after_update_hook, _on_update, priority=90)
|
||||
reg.register_action(after_delete_hook, _on_delete, priority=90)
|
||||
reg.register_action(after_create_hook, _on_create, priority=90, owner_tag=owner_tag)
|
||||
reg.register_action(after_update_hook, _on_update, priority=90, owner_tag=owner_tag)
|
||||
reg.register_action(after_delete_hook, _on_delete, priority=90, owner_tag=owner_tag)
|
||||
logger.debug("History hooks registered for: %s", entity_type)
|
||||
|
||||
|
||||
@@ -121,56 +126,16 @@ def _extract_entity_id(snapshot: dict[str, Any] | None) -> uuid.UUID | None:
|
||||
|
||||
|
||||
def register_default_history_hooks() -> None:
|
||||
"""Register history hooks for all built-in entity types.
|
||||
"""Register history hooks for Core entity types only.
|
||||
|
||||
Called during app startup after the hook registry is initialized.
|
||||
Plugin entities should register their own hooks in on_activate().
|
||||
Plugin entities (task, calendar_entry, dms_file, mail) register
|
||||
their own hooks in on_activate(). See P0-8 fix.
|
||||
"""
|
||||
reg = get_hook_registry()
|
||||
|
||||
# Contact (already has manual record_history calls in contact_service.py,
|
||||
# but registering hooks ensures consistency for any code path that fires
|
||||
# the hooks without calling record_history directly)
|
||||
register_history_hooks(
|
||||
reg, "contact",
|
||||
"contact.after_create",
|
||||
"contact.after_update",
|
||||
"contact.after_delete",
|
||||
)
|
||||
|
||||
# Task plugin
|
||||
register_history_hooks(
|
||||
reg, "task",
|
||||
"task.after_create",
|
||||
"task.after_update",
|
||||
"task.after_delete",
|
||||
)
|
||||
|
||||
# Calendar plugin — CalendarEntry
|
||||
register_history_hooks(
|
||||
reg, "calendar_entry",
|
||||
"calendar_entry.after_create",
|
||||
"calendar_entry.after_update",
|
||||
"calendar_entry.after_delete",
|
||||
)
|
||||
|
||||
# DMS plugin — File metadata
|
||||
register_history_hooks(
|
||||
reg, "dms_file",
|
||||
"dms_file.after_create",
|
||||
"dms_file.after_update",
|
||||
"dms_file.after_delete",
|
||||
)
|
||||
|
||||
# Mail plugin
|
||||
register_history_hooks(
|
||||
reg, "mail",
|
||||
"mail.after_create",
|
||||
"mail.after_update",
|
||||
"mail.after_delete",
|
||||
)
|
||||
|
||||
logger.info("Default history hooks registered for: contact, task, calendar_entry, dms_file, mail")
|
||||
# Contact hooks are registered by ContactsPlugin.on_activate() with
|
||||
# owner_tag="contacts" — do not register them here to avoid double
|
||||
# registration. This function remains for future Core entities that
|
||||
# have no plugin.
|
||||
|
||||
|
||||
def reset_history_hooks_for_testing() -> None:
|
||||
|
||||
+45
-15
@@ -30,7 +30,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,27 +56,32 @@ class HookRegistry:
|
||||
|
||||
# ─── Registration ───
|
||||
|
||||
def register_action(self, hook_name: str, callback: Callable, priority: int = 10) -> None:
|
||||
"""Register an action callback for *hook_name*."""
|
||||
self._actions[hook_name].append((priority, callback))
|
||||
self._actions[hook_name].sort(key=lambda x: x[0])
|
||||
logger.debug("Action registered: %s (priority=%d)", hook_name, priority)
|
||||
def register_action(self, hook_name: str, callback: Callable, priority: int = 10, owner_tag: str | None = None) -> None:
|
||||
"""Register an action callback for *hook_name*.
|
||||
|
||||
def register_filter(self, hook_name: str, callback: Callable, priority: int = 10) -> None:
|
||||
Args:
|
||||
owner_tag: Optional tag identifying the owning plugin. Used by
|
||||
unregister_actions_by_owner() to remove only this plugin's hooks.
|
||||
"""
|
||||
self._actions[hook_name].append((priority, callback, owner_tag))
|
||||
self._actions[hook_name].sort(key=lambda x: x[0])
|
||||
logger.debug("Action registered: %s (priority=%d, owner=%s)", hook_name, priority, owner_tag)
|
||||
|
||||
def register_filter(self, hook_name: str, callback: Callable, priority: int = 10, owner_tag: str | None = None) -> None:
|
||||
"""Register a filter callback for *hook_name*."""
|
||||
self._filters[hook_name].append((priority, callback))
|
||||
self._filters[hook_name].append((priority, callback, owner_tag))
|
||||
self._filters[hook_name].sort(key=lambda x: x[0])
|
||||
logger.debug("Filter registered: %s (priority=%d)", hook_name, priority)
|
||||
logger.debug("Filter registered: %s (priority=%d, owner=%s)", hook_name, priority, owner_tag)
|
||||
|
||||
# ─── Unregistration ───
|
||||
|
||||
def unregister(self, hook_name: str, callback: Callable) -> None:
|
||||
"""Remove a specific callback from both actions and filters."""
|
||||
self._actions[hook_name] = [
|
||||
(p, c) for p, c in self._actions.get(hook_name, []) if c != callback
|
||||
(p, c, o) for p, c, o in self._actions.get(hook_name, []) if c != callback
|
||||
]
|
||||
self._filters[hook_name] = [
|
||||
(p, c) for p, c in self._filters.get(hook_name, []) if c != callback
|
||||
(p, c, o) for p, c, o in self._filters.get(hook_name, []) if c != callback
|
||||
]
|
||||
if not self._actions[hook_name]:
|
||||
self._actions.pop(hook_name, None)
|
||||
@@ -92,23 +98,47 @@ class HookRegistry:
|
||||
for hook_dict in (self._actions, self._filters):
|
||||
for hook_name in list(hook_dict.keys()):
|
||||
kept: list[tuple[int, Callable]] = []
|
||||
for priority, callback in hook_dict[hook_name]:
|
||||
for priority, callback, _owner in hook_dict[hook_name]:
|
||||
owner = getattr(callback, "__self__", None)
|
||||
plugin_manifest_name = getattr(getattr(owner, "manifest", None), "name", None)
|
||||
if plugin_manifest_name == plugin_name:
|
||||
logger.debug("Unregistered hook %s for plugin %s", hook_name, plugin_name)
|
||||
continue
|
||||
kept.append((priority, callback))
|
||||
kept.append((priority, callback, _owner))
|
||||
if kept:
|
||||
hook_dict[hook_name] = kept
|
||||
else:
|
||||
hook_dict.pop(hook_name, None)
|
||||
|
||||
def clear_actions(self, hook_name: str) -> None:
|
||||
"""Remove all action callbacks for a given hook name.
|
||||
|
||||
Used by plugins to unregister hooks that were registered via
|
||||
register_history_hooks() (which creates free functions, not bound methods).
|
||||
"""
|
||||
self._actions.pop(hook_name, None)
|
||||
logger.debug("Cleared all actions for hook: %s", hook_name)
|
||||
|
||||
def unregister_actions_by_owner(self, hook_name: str, owner_tag: str) -> None:
|
||||
"""Remove only the action callbacks for *hook_name* that were registered
|
||||
with the given *owner_tag*.
|
||||
|
||||
This prevents a plugin from accidentally removing another plugin's
|
||||
handlers for the same event.
|
||||
"""
|
||||
callbacks = self._actions.get(hook_name, [])
|
||||
kept = [(p, c, o) for p, c, o in callbacks if o != owner_tag]
|
||||
if kept:
|
||||
self._actions[hook_name] = kept
|
||||
else:
|
||||
self._actions.pop(hook_name, None)
|
||||
logger.debug("Unregistered %d actions for hook %s owner=%s", len(callbacks) - len(kept), hook_name, owner_tag)
|
||||
|
||||
# ─── Execution ───
|
||||
|
||||
async def do_action(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Execute all action callbacks for *hook_name* in priority order."""
|
||||
for _, callback in self._actions.get(hook_name, []):
|
||||
for _, callback, _owner in self._actions.get(hook_name, []):
|
||||
try:
|
||||
result = callback(*args, **kwargs)
|
||||
if hasattr(result, "__await__"):
|
||||
@@ -118,7 +148,7 @@ class HookRegistry:
|
||||
|
||||
async def apply_filters(self, hook_name: str, value: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Pass *value* through all filter callbacks for *hook_name* in priority order."""
|
||||
for _, callback in self._filters.get(hook_name, []):
|
||||
for _, callback, _owner in self._filters.get(hook_name, []):
|
||||
try:
|
||||
result = callback(value, *args, **kwargs)
|
||||
if hasattr(result, "__await__"):
|
||||
|
||||
@@ -9,7 +9,8 @@ importing from plugin modules directly.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+4
-3
@@ -6,7 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from arq import create_pool
|
||||
from arq.connections import RedisSettings, ArqRedis
|
||||
from arq.connections import ArqRedis, RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -97,9 +97,10 @@ async def send_password_reset_email(
|
||||
This is an ARQ worker function. It is registered with the job registry
|
||||
so the worker can execute it when the auth service enqueues it.
|
||||
"""
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import Request, status
|
||||
|
||||
@@ -12,13 +12,11 @@ import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import (
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
NotificationType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,11 +35,15 @@ async def post_system_message(
|
||||
):
|
||||
"""Post a typed system message to the tenant system channel.
|
||||
|
||||
Delegates to kommunikation.services.post_system_message.
|
||||
Returns the created CommMessage, or None if the user has muted this type.
|
||||
Delegates to kommunikation plugin via contract registry. Returns None
|
||||
if the kommunikation plugin is not active (graceful degradation).
|
||||
"""
|
||||
from app.plugins.builtins.kommunikation.services import post_system_message as _post
|
||||
return await _post(
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
komm_contract = get_contract("kommunikation")
|
||||
if komm_contract is None:
|
||||
logger.warning("kommunikation plugin not available — system message not posted")
|
||||
return None
|
||||
return await komm_contract.post_system_message(
|
||||
db, tenant_id, user_id, message_type, title, body,
|
||||
entity_type, entity_id, severity,
|
||||
)
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -318,7 +318,7 @@ async def _process_single_outbox_event(
|
||||
already_succeeded = {row[0] for row in succeeded_q}
|
||||
|
||||
# Filter out handlers that already succeeded (per-handler idempotency)
|
||||
pending_handlers = [(h, name) for h, name in zip(handlers, handler_names) if name not in already_succeeded]
|
||||
pending_handlers = [(h, name) for h, name in zip(handlers, handler_names, strict=False) if name not in already_succeeded]
|
||||
pending_names = [name for _, name in pending_handlers]
|
||||
pending_callables = [h for h, _ in pending_handlers]
|
||||
|
||||
@@ -345,7 +345,7 @@ async def _process_single_outbox_event(
|
||||
"status": "delivered",
|
||||
"attempt_count": current_attempt,
|
||||
"last_error": None,
|
||||
"processed_at": datetime.now(timezone.utc),
|
||||
"processed_at": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
# Per-handler consumer_inbox for idempotency
|
||||
@@ -408,7 +408,7 @@ async def _process_single_outbox_event(
|
||||
)
|
||||
else:
|
||||
backoff = timedelta(seconds=(2 ** new_attempts) * 10)
|
||||
next_retry = datetime.now(timezone.utc) + backoff
|
||||
next_retry = datetime.now(UTC) + backoff
|
||||
await db.execute(
|
||||
_RETRY_SQL,
|
||||
{
|
||||
|
||||
@@ -7,8 +7,9 @@ Provides:
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, TypeVar, Sequence
|
||||
from sqlalchemy import select, func, text
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import Select
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Core system permissions ──
|
||||
CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "core", "module": "contacts"},
|
||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "core", "module": "contacts"},
|
||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "core", "module": "contacts"},
|
||||
{"key": "users:read", "label": "Users: Read", "category": "core", "module": "users"},
|
||||
{"key": "users:write", "label": "Users: Write", "category": "core", "module": "users"},
|
||||
{"key": "users:delete", "label": "Users: Delete", "category": "core", "module": "users"},
|
||||
@@ -66,66 +63,10 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
|
||||
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
||||
# ── Plugin permissions (registered at startup, but also listed here for completeness) ──
|
||||
{"key": "ai:read", "label": "AI: Read", "category": "core", "module": "ai"},
|
||||
{"key": "ai:write", "label": "AI: Write", "category": "core", "module": "ai"},
|
||||
{"key": "ai:agents", "label": "AI: Agents", "category": "core", "module": "ai"},
|
||||
{"key": "ai:config", "label": "AI: Config", "category": "core", "module": "ai"},
|
||||
{"key": "ai_proactive:read", "label": "AI Proactive: Read", "category": "core", "module": "ai_proactive"},
|
||||
{"key": "ai_proactive:write", "label": "AI Proactive: Write", "category": "core", "module": "ai_proactive"},
|
||||
{"key": "ai_proactive:config", "label": "AI Proactive: Config", "category": "core", "module": "ai_proactive"},
|
||||
{"key": "agents:read", "label": "Agents: Read", "category": "core", "module": "agents"},
|
||||
{"key": "agents:write", "label": "Agents: Write", "category": "core", "module": "agents"},
|
||||
{"key": "agents:delete", "label": "Agents: Delete", "category": "core", "module": "agents"},
|
||||
{"key": "agents:execute", "label": "Agents: Execute", "category": "core", "module": "agents"},
|
||||
{"key": "automation:read", "label": "Automation: Read", "category": "core", "module": "automation"},
|
||||
{"key": "automation:write", "label": "Automation: Write", "category": "core", "module": "automation"},
|
||||
{"key": "automation:delete", "label": "Automation: Delete", "category": "core", "module": "automation"},
|
||||
{"key": "automation:execute", "label": "Automation: Execute", "category": "core", "module": "automation"},
|
||||
{"key": "automation:admin", "label": "Automation: Admin", "category": "core", "module": "automation"},
|
||||
{"key": "automation:configure", "label": "Automation: Configure", "category": "core", "module": "automation"},
|
||||
{"key": "calendar:read", "label": "Calendar: Read", "category": "core", "module": "calendar"},
|
||||
{"key": "calendar:write", "label": "Calendar: Write", "category": "core", "module": "calendar"},
|
||||
{"key": "calendar:delete", "label": "Calendar: Delete", "category": "core", "module": "calendar"},
|
||||
{"key": "calendar:share", "label": "Calendar: Share", "category": "core", "module": "calendar"},
|
||||
{"key": "comm:read", "label": "Comm: Read", "category": "core", "module": "comm"},
|
||||
{"key": "comm:write", "label": "Comm: Write", "category": "core", "module": "comm"},
|
||||
{"key": "comm:delete", "label": "Comm: Delete", "category": "core", "module": "comm"},
|
||||
{"key": "comm:manage", "label": "Comm: Manage", "category": "core", "module": "comm"},
|
||||
{"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"},
|
||||
{"key": "dms:read", "label": "DMS: Read", "category": "core", "module": "dms"},
|
||||
{"key": "dms:write", "label": "DMS: Write", "category": "core", "module": "dms"},
|
||||
{"key": "dms:delete", "label": "DMS: Delete", "category": "core", "module": "dms"},
|
||||
{"key": "dms:share", "label": "DMS: Share", "category": "core", "module": "dms"},
|
||||
{"key": "entity_links:read", "label": "Entity Links: Read", "category": "core", "module": "entity_links"},
|
||||
{"key": "entity_links:write", "label": "Entity Links: Write", "category": "core", "module": "entity_links"},
|
||||
{"key": "entity_links:delete", "label": "Entity Links: Delete", "category": "core", "module": "entity_links"},
|
||||
{"key": "mail:read", "label": "Mail: Read", "category": "core", "module": "mail"},
|
||||
{"key": "mail:write", "label": "Mail: Write", "category": "core", "module": "mail"},
|
||||
{"key": "mail:delete", "label": "Mail: Delete", "category": "core", "module": "mail"},
|
||||
{"key": "mail:send", "label": "Mail: Send", "category": "core", "module": "mail"},
|
||||
{"key": "mail:share", "label": "Mail: Share", "category": "core", "module": "mail"},
|
||||
{"key": "mail:config", "label": "Mail: Config", "category": "core", "module": "mail"},
|
||||
{"key": "mcp:read", "label": "MCP: Read", "category": "core", "module": "mcp"},
|
||||
{"key": "mcp:write", "label": "MCP: Write", "category": "core", "module": "mcp"},
|
||||
{"key": "permissions:admin", "label": "Permissions: Admin", "category": "core", "module": "permissions"},
|
||||
{"key": "permissions:delegations:read", "label": "Permissions: Delegations: Read", "category": "core", "module": "permissions"},
|
||||
{"key": "permissions:delegations:write", "label": "Permissions: Delegations: Write", "category": "core", "module": "permissions"},
|
||||
{"key": "permissions:policies:read", "label": "Permissions: Policies: Read", "category": "core", "module": "permissions"},
|
||||
{"key": "permissions:policies:write", "label": "Permissions: Policies: Write", "category": "core", "module": "permissions"},
|
||||
{"key": "permissions:templates:read", "label": "Permissions: Templates: Read", "category": "core", "module": "permissions"},
|
||||
{"key": "permissions:templates:write", "label": "Permissions: Templates: Write", "category": "core", "module": "permissions"},
|
||||
{"key": "reports:read", "label": "Reports: Read", "category": "core", "module": "reports"},
|
||||
{"key": "reports:generate", "label": "Reports: Generate", "category": "core", "module": "reports"},
|
||||
{"key": "reports:manage_templates", "label": "Reports: Manage Templates", "category": "core", "module": "reports"},
|
||||
{"key": "search:read", "label": "Search: Read", "category": "core", "module": "search"},
|
||||
{"key": "search:admin", "label": "Search: Admin", "category": "core", "module": "search"},
|
||||
{"key": "tags:read", "label": "Tags: Read", "category": "core", "module": "tags"},
|
||||
{"key": "tags:write", "label": "Tags: Write", "category": "core", "module": "tags"},
|
||||
{"key": "tags:delete", "label": "Tags: Delete", "category": "core", "module": "tags"},
|
||||
{"key": "tasks:read", "label": "Tasks: Read", "category": "core", "module": "tasks"},
|
||||
{"key": "tasks:write", "label": "Tasks: Write", "category": "core", "module": "tasks"},
|
||||
{"key": "tasks:delete", "label": "Tasks: Delete", "category": "core", "module": "tasks"},
|
||||
# NOTE: Plugin permissions (calendar, dms, mail, tasks, comm, automation, ai,
|
||||
# tags, entity_links, reports, search, mcp, permissions, agents, dashboard)
|
||||
# are registered dynamically via register_plugin_permissions() from plugin
|
||||
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -16,11 +16,9 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.role import Role
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Plugin error isolation wrapper."""
|
||||
import logging
|
||||
import functools
|
||||
import inspect
|
||||
from fastapi import UploadFile # noqa: F401 — needed for ForwardRef resolution
|
||||
from fastapi import WebSocket # noqa: F401 — needed for ForwardRef resolution
|
||||
import logging
|
||||
|
||||
from fastapi import (
|
||||
UploadFile, # noqa: F401 — needed for ForwardRef resolution
|
||||
WebSocket, # noqa: F401 — needed for ForwardRef resolution
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,7 +137,7 @@ def _is_transient_db_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, _DB_RETRYABLE_EXC):
|
||||
return True
|
||||
try:
|
||||
from sqlalchemy.exc import OperationalError, DBAPIError
|
||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||
if isinstance(exc, OperationalError):
|
||||
return True
|
||||
if isinstance(exc, DBAPIError):
|
||||
@@ -162,7 +162,6 @@ async def retry_db(
|
||||
base_delay: float = 0.1,
|
||||
**kwargs: Any,
|
||||
) -> T:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
@@ -170,7 +169,6 @@ async def retry_db(
|
||||
logger.info("DB operation succeeded on retry %d", attempt + 1)
|
||||
return result
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if not _is_transient_db_error(exc):
|
||||
raise
|
||||
if attempt < max_retries - 1:
|
||||
|
||||
+15
-170
@@ -13,8 +13,9 @@ entity types can be restored, and only through their declared configuration.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -72,6 +73,12 @@ class RestoreRegistry:
|
||||
self._configs[config.entity_type] = config
|
||||
logger.debug("Registered restore config for: %s", config.entity_type)
|
||||
|
||||
def unregister(self, entity_type: str) -> None:
|
||||
"""Remove a RestoreConfig (e.g. when plugin is deactivated)."""
|
||||
if entity_type in self._configs:
|
||||
del self._configs[entity_type]
|
||||
logger.debug("Unregistered restore config for: %s", entity_type)
|
||||
|
||||
def get(self, entity_type: str) -> RestoreConfig | None:
|
||||
"""Get RestoreConfig for entity_type, or None if not registered."""
|
||||
return self._configs.get(entity_type)
|
||||
@@ -105,176 +112,14 @@ def reset_restore_registry_for_testing() -> RestoreRegistry:
|
||||
|
||||
|
||||
def register_default_entities() -> None:
|
||||
"""Register all built-in entity types for restore.
|
||||
"""Register Core entity types for restore.
|
||||
|
||||
Called during app startup. Plugin entities should register themselves
|
||||
Called during app startup. Plugin entities register themselves
|
||||
in their on_activate() lifecycle hook.
|
||||
"""
|
||||
from app.models.contact import Contact
|
||||
# Contact is registered by ContactsPlugin.on_activate() — do not register
|
||||
# it here to avoid double registration. This function remains for future
|
||||
# Core entities that have no plugin.
|
||||
|
||||
reg = get_restore_registry()
|
||||
|
||||
# Contact (covers both 'person' and 'company' types — same model)
|
||||
reg.register(RestoreConfig(
|
||||
entity_type="contact",
|
||||
model_class=Contact,
|
||||
restore_permission="contacts:write",
|
||||
excluded_fields=frozenset({
|
||||
"search_tsv",
|
||||
"embedding",
|
||||
"default_person_id",
|
||||
"admin_contactperson_id",
|
||||
}),
|
||||
))
|
||||
|
||||
# Task plugin
|
||||
try:
|
||||
from app.plugins.builtins.tasks.models import Task
|
||||
reg.register(RestoreConfig(
|
||||
entity_type="task",
|
||||
model_class=Task,
|
||||
restore_permission="tasks:write",
|
||||
excluded_fields=frozenset({
|
||||
"created_by",
|
||||
"assigned_to",
|
||||
"contact_id",
|
||||
}),
|
||||
))
|
||||
except ImportError:
|
||||
logger.debug("Tasks plugin model not available for restore registration")
|
||||
|
||||
# Calendar plugin — CalendarEntry
|
||||
try:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
reg.register(RestoreConfig(
|
||||
entity_type="calendar_entry",
|
||||
model_class=CalendarEntry,
|
||||
restore_permission="calendar:write",
|
||||
excluded_fields=frozenset({
|
||||
"calendar_id",
|
||||
"created_by",
|
||||
"assigned_to",
|
||||
"source_mail_id",
|
||||
}),
|
||||
))
|
||||
except ImportError:
|
||||
logger.debug("Calendar plugin model not available for restore registration")
|
||||
|
||||
# DMS plugin — File metadata
|
||||
try:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
reg.register(RestoreConfig(
|
||||
entity_type="dms_file",
|
||||
model_class=DmsFile,
|
||||
restore_permission="dms:write",
|
||||
excluded_fields=frozenset({
|
||||
"storage_path",
|
||||
"content_hash",
|
||||
"size_bytes",
|
||||
"uploaded_by",
|
||||
"folder_id",
|
||||
}),
|
||||
))
|
||||
except ImportError:
|
||||
logger.debug("DMS plugin model not available for restore registration")
|
||||
|
||||
# Mail plugin — special handler for IMAP semantics
|
||||
try:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
reg.register(RestoreConfig(
|
||||
entity_type="mail",
|
||||
model_class=Mail,
|
||||
restore_permission="mail:write",
|
||||
excluded_fields=frozenset({
|
||||
"message_id",
|
||||
"rfc822_size",
|
||||
"raw_path",
|
||||
"account_id",
|
||||
"folder_id",
|
||||
}),
|
||||
special_handler=_mail_restore_handler,
|
||||
))
|
||||
except ImportError:
|
||||
logger.debug("Mail plugin model not available for restore registration")
|
||||
|
||||
|
||||
async def _mail_restore_handler(
|
||||
db: AsyncSession,
|
||||
entity: Any,
|
||||
action: str,
|
||||
snapshot: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Special restore handler for Mail entities.
|
||||
|
||||
Mail restore has IMAP semantics:
|
||||
- delete: move back from trash to original folder (if folder still exists)
|
||||
- update: revert metadata fields
|
||||
- create: soft-delete (undo send only works for drafts)
|
||||
|
||||
Server errors must not produce false local status.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
user_id = context.get("user_id")
|
||||
tenant_id = context.get("tenant_id")
|
||||
|
||||
if action == "delete":
|
||||
# Un-delete: clear deleted_at, restore original folder_id if available
|
||||
if entity is None:
|
||||
raise ValueError("Mail entity not found for restore")
|
||||
entity.deleted_at = None
|
||||
if user_id:
|
||||
entity.updated_by = user_id if hasattr(entity, "updated_by") else None
|
||||
# Restore original folder from snapshot if available
|
||||
original_folder_id = snapshot.get("folder_id")
|
||||
if original_folder_id and hasattr(entity, "folder_id"):
|
||||
try:
|
||||
folder_uuid = uuid.UUID(str(original_folder_id))
|
||||
# Verify folder still exists and is not deleted
|
||||
from app.plugins.builtins.mail.models import MailFolder
|
||||
folder_q = select(MailFolder).where(
|
||||
MailFolder.id == folder_uuid,
|
||||
MailFolder.tenant_id == tenant_id,
|
||||
MailFolder.deleted_at.is_(None),
|
||||
)
|
||||
folder_result = await db.execute(folder_q)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if folder:
|
||||
entity.folder_id = folder_uuid
|
||||
else:
|
||||
logger.warning(
|
||||
"Original mail folder %s no longer exists, "
|
||||
"restoring mail without folder assignment",
|
||||
original_folder_id,
|
||||
)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.warning("Failed to restore mail folder: %s", e)
|
||||
|
||||
await db.flush()
|
||||
return {"id": str(entity.id), "restored": True, "entity_type": "mail"}
|
||||
|
||||
elif action == "update":
|
||||
if entity is None:
|
||||
raise ValueError("Mail entity not found for restore")
|
||||
# Revert metadata fields from snapshot_before
|
||||
excluded = _DEFAULT_EXCLUDED | {
|
||||
"message_id", "rfc822_size", "raw_path", "account_id", "folder_id",
|
||||
}
|
||||
for key, value in snapshot.items():
|
||||
if hasattr(entity, key) and key not in excluded:
|
||||
setattr(entity, key, value)
|
||||
await db.flush()
|
||||
return {"id": str(entity.id), "restored": True, "entity_type": "mail"}
|
||||
|
||||
elif action == "create":
|
||||
# Undo creation: soft-delete (only meaningful for drafts)
|
||||
if entity is None:
|
||||
raise ValueError("Mail entity not found for restore")
|
||||
entity.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return {"id": str(entity.id), "restored": True, "entity_type": "mail", "note": "soft-deleted (undo create)"}
|
||||
|
||||
raise ValueError(f"Unsupported action for mail restore: {action}")
|
||||
# Plugin entities (task, calendar_entry, dms_file, mail) are registered
|
||||
# by their respective plugins in on_activate(). See P0-7 fix.
|
||||
|
||||
+8
-7
@@ -22,7 +22,8 @@ import mimetypes
|
||||
import os
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncIterator
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
|
||||
@@ -145,13 +146,13 @@ class LocalStorage(StorageBackend):
|
||||
|
||||
async def delete(self, path: str) -> bool:
|
||||
full_path = self._full_path(path)
|
||||
if os.path.exists(full_path):
|
||||
if os.path.exists(full_path): # noqa: ASYNC240
|
||||
os.remove(full_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def exists(self, path: str) -> bool:
|
||||
return os.path.exists(self._full_path(path))
|
||||
return os.path.exists(self._full_path(path)) # noqa: ASYNC240
|
||||
|
||||
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||||
"""Return a relative URL path for the file (not the filesystem path)."""
|
||||
@@ -160,12 +161,12 @@ class LocalStorage(StorageBackend):
|
||||
|
||||
async def list_files(self, prefix: str) -> list[str]:
|
||||
full_prefix = self._full_path(prefix)
|
||||
if not os.path.isdir(full_prefix):
|
||||
if not os.path.isdir(full_prefix): # noqa: ASYNC240
|
||||
return []
|
||||
result: list[str] = []
|
||||
for root, _dirs, files in os.walk(full_prefix):
|
||||
for root, _dirs, files in os.walk(full_prefix): # noqa: ASYNC240
|
||||
for fname in files:
|
||||
rel = os.path.relpath(os.path.join(root, fname), self.base_path)
|
||||
rel = os.path.relpath(os.path.join(root, fname), self.base_path) # noqa: ASYNC240
|
||||
result.append(rel)
|
||||
return result
|
||||
|
||||
@@ -295,7 +296,7 @@ class S3Storage(StorageBackend):
|
||||
logger.debug("S3Storage: streamed %s (%d bytes)", path, total)
|
||||
return total
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
if os.path.exists(tmp_path): # noqa: ASYNC240
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
|
||||
@@ -113,7 +113,12 @@ class TriggerDispatcher:
|
||||
) -> None:
|
||||
"""Query DB for active automations matching *event_name* and dispatch."""
|
||||
from app.core.db import get_session_factory
|
||||
from app.plugins.builtins.automation.models import AutomationDefinition
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
automation_contract = get_contract("automation")
|
||||
AutomationDefinition = automation_contract.Automation # noqa: N806
|
||||
if automation_contract is None:
|
||||
logger.debug("Automation plugin not available — trigger skipped")
|
||||
return
|
||||
|
||||
factory = get_session_factory()
|
||||
tenant_id = payload.get("tenant_id")
|
||||
@@ -171,15 +176,16 @@ class TriggerDispatcher:
|
||||
For production workloads with back-pressure, the caller may
|
||||
alternatively enqueue via ``enqueue_job``.
|
||||
"""
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||
|
||||
try:
|
||||
await run_automation(
|
||||
ctx={},
|
||||
automation_id=automation_id,
|
||||
trigger_type=trigger_type,
|
||||
trigger_data=trigger_data,
|
||||
)
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
automation_contract = get_contract("automation")
|
||||
if automation_contract is not None:
|
||||
await automation_contract.run_automation(
|
||||
ctx={},
|
||||
automation_id=automation_id,
|
||||
trigger_type=trigger_type,
|
||||
trigger_data=trigger_data,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"TriggerDispatcher: run_automation failed for automation_id=%s",
|
||||
|
||||
@@ -6,7 +6,7 @@ Defense-in-Depth layer.
|
||||
|
||||
Usage:
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
|
||||
|
||||
@router.get("/contacts")
|
||||
async def list_contacts(db, current_user):
|
||||
query = select(Contact).where(Contact.tenant_id == tenant_id)
|
||||
@@ -29,17 +29,17 @@ import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, exists, not_, or_, select, text
|
||||
from sqlalchemy import and_, not_, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.group import UserGroup
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.user import UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK # noqa: E402
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
|
||||
@@ -8,9 +8,8 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.core.db import get_engine, get_session_factory
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.event_bus import EventBus, get_event_bus
|
||||
from app.models.webhook import Webhook
|
||||
from app.services.webhook_service import send_webhook
|
||||
|
||||
+54
-34
@@ -6,8 +6,8 @@ import logging
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from arq.connections import RedisSettings
|
||||
from arq import cron
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.job_registry import get_all_jobs, get_job, register_job
|
||||
@@ -97,12 +97,12 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
await container.initialize()
|
||||
|
||||
# Initialize plugin registry and discover built-in plugins
|
||||
from app.plugins.registry import get_registry
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.webhook_dispatcher import register_webhook_event_handlers
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from app.plugins.registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
from app.core.db import get_migration_engine
|
||||
@@ -120,7 +120,6 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
# are registered by the API container's startup. The worker only
|
||||
# needs event handlers and job processing.
|
||||
from app.models.tenant import Tenant as TenantModel
|
||||
from app.core.db import set_tenant_context
|
||||
|
||||
async with async_session() as db:
|
||||
# Load all tenant IDs for per-tenant event handler registration
|
||||
@@ -134,7 +133,7 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
async with async_session() as db:
|
||||
# Global plugins that are marked active
|
||||
result = await db.execute(
|
||||
sa_select(PluginModel.name).where(PluginModel.active == True)
|
||||
sa_select(PluginModel.name).where(PluginModel.active.is_(True))
|
||||
)
|
||||
active_plugin_names = {row[0] for row in result}
|
||||
logger.info(f"Worker: {len(active_plugin_names)} active plugins: {active_plugin_names}")
|
||||
@@ -166,11 +165,20 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
|
||||
# Register search providers (normally done by app startup)
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
|
||||
factory = async_session
|
||||
async with factory() as db:
|
||||
await auto_register_providers(db)
|
||||
logger.info("Search providers registered for worker")
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
search_contract = get_contract("unified_search")
|
||||
if search_contract is not None:
|
||||
factory = async_session
|
||||
async with factory() as db:
|
||||
# auto_register_providers is not exposed via contract yet;
|
||||
# use the contract's get_search_registry to access providers
|
||||
from app.plugins.builtins.unified_search.provider_registry import (
|
||||
auto_register_providers,
|
||||
)
|
||||
await auto_register_providers(db)
|
||||
logger.info("Search providers registered for worker")
|
||||
else:
|
||||
logger.debug("Unified search plugin not available — skipping provider registration")
|
||||
except Exception:
|
||||
logger.warning("Failed to register search providers in worker", exc_info=True)
|
||||
|
||||
@@ -185,8 +193,9 @@ async def on_shutdown(ctx: dict[str, Any]) -> None:
|
||||
|
||||
# Pause running workflow instances so they can be resumed after restart
|
||||
try:
|
||||
from app.core.db import get_worker_session_factory
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.workflow import WorkflowInstance
|
||||
|
||||
session_factory = get_worker_session_factory()
|
||||
@@ -215,19 +224,26 @@ async def on_shutdown(ctx: dict[str, Any]) -> None:
|
||||
# from plugin internals.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _lazy_register_plugin_jobs() -> None:
|
||||
"""Import each plugin job module so its register_job() call fires."""
|
||||
plugin_job_modules = [
|
||||
"app.core.jobs",
|
||||
"app.plugins.builtins.unified_search.jobs",
|
||||
"app.plugins.builtins.ai_proactive.jobs",
|
||||
"app.plugins.builtins.automation.scheduler",
|
||||
"app.plugins.builtins.automation.workflow_timeout",
|
||||
"app.plugins.builtins.automation.agent_runner",
|
||||
"app.plugins.builtins.automation.execution_engine",
|
||||
"app.plugins.builtins.tasks.jobs",
|
||||
"app.services.import_export_jobs",
|
||||
]
|
||||
for mod_name in plugin_job_modules:
|
||||
"""Import each plugin job module so its register_job() call fires.
|
||||
|
||||
Dynamically discovers job modules from all registered plugins via
|
||||
get_job_modules() — no hardcoded plugin list (P0-5 fix).
|
||||
"""
|
||||
from app.plugins.registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# Ensure builtins are discovered
|
||||
if not registry.list_discovered():
|
||||
registry.discover_builtins()
|
||||
|
||||
job_modules: list[str] = ["app.core.jobs", "app.services.import_export_jobs"]
|
||||
for plugin_name in registry.list_discovered():
|
||||
plugin = registry.get_plugin(plugin_name)
|
||||
if plugin is None:
|
||||
continue
|
||||
job_modules.extend(plugin.get_job_modules())
|
||||
|
||||
for mod_name in job_modules:
|
||||
try:
|
||||
import importlib
|
||||
importlib.import_module(mod_name)
|
||||
@@ -251,9 +267,10 @@ async def process_outbox_job(ctx: dict[str, Any]) -> None:
|
||||
|
||||
Processes events per-tenant by setting tenant context for RLS.
|
||||
"""
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.core.outbox import process_outbox_batch
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
@@ -268,14 +285,16 @@ async def process_outbox_job(ctx: dict[str, Any]) -> None:
|
||||
except Exception as exc:
|
||||
logger.error("Outbox processing failed", exc_info=True)
|
||||
await db.rollback()
|
||||
# Report to Forgejo
|
||||
# Report to Forgejo via contract (avoid Core→Plugin direct import)
|
||||
try:
|
||||
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
|
||||
await report_error_to_forgejo({
|
||||
"message": f"[Worker] Outbox processing failed: {exc}",
|
||||
"stack": traceback.format_exc(),
|
||||
"context": {"source": "worker_outbox_job"},
|
||||
})
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
reporter_contract = get_contract("forgejo_error_reporter")
|
||||
if reporter_contract is not None:
|
||||
await reporter_contract.report_error_to_forgejo({
|
||||
"message": f"[Worker] Outbox processing failed: {exc}",
|
||||
"stack": traceback.format_exc(),
|
||||
"context": {"source": "worker_outbox_job"},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -292,9 +311,10 @@ async def cleanup_outbox_job(ctx: dict[str, Any]) -> None:
|
||||
Runs hourly to prevent the outbox table from growing indefinitely.
|
||||
Iterates per-tenant for RLS compliance.
|
||||
"""
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.core.outbox import cleanup_published_events
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
|
||||
@@ -6,14 +6,15 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from app.core.auth import get_redis, get_session_data, verify_ws_origin
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, get_session_data, verify_ws_origin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -167,7 +168,7 @@ async def drain_all_connections(grace_period_seconds: float = 5.0) -> None:
|
||||
"""
|
||||
total_connections = 0
|
||||
for registry in _global_ws_registries:
|
||||
for user_id, conns in list(registry.items()):
|
||||
for _user_id, conns in list(registry.items()):
|
||||
for ws in list(conns):
|
||||
try:
|
||||
await ws.send_text(json.dumps({
|
||||
@@ -186,7 +187,7 @@ async def drain_all_connections(grace_period_seconds: float = 5.0) -> None:
|
||||
|
||||
# Close all connections
|
||||
for registry in _global_ws_registries:
|
||||
for user_id, conns in list(registry.items()):
|
||||
for _user_id, conns in list(registry.items()):
|
||||
for ws in list(conns):
|
||||
try:
|
||||
await ws.close(code=1001, reason="Server shutting down")
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from app.core.auth import get_redis
|
||||
|
||||
|
||||
+7
-6
@@ -14,13 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
|
||||
from app.core.db import get_db, set_tenant_context, set_user_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Known write-permission modules — used by require_write() to check
|
||||
# specific permissions instead of broad wildcards like *:write
|
||||
_WRITE_PERMISSIONS = [
|
||||
"contacts:write",
|
||||
"contacts:create",
|
||||
"users:write",
|
||||
"roles:write",
|
||||
"audit:write",
|
||||
@@ -401,10 +400,12 @@ def require_active_plugin(plugin_name: str):
|
||||
return
|
||||
|
||||
# Per-tenant activation check with Redis cache
|
||||
from app.core.redis import get_redis
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.redis import get_redis
|
||||
|
||||
redis = get_redis()
|
||||
if redis is not None:
|
||||
cache_key = f"plugin-activation:{tenant_id}:{plugin_name}"
|
||||
@@ -454,7 +455,7 @@ def require_active_plugin(plugin_name: str):
|
||||
logger.error("Plugin activation check failed for '%s': %s", plugin_name, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"detail": f"Plugin activation check failed", "code": "plugin_check_error"},
|
||||
)
|
||||
detail={"detail": "Plugin activation check failed", "code": "plugin_check_error"},
|
||||
) from exc
|
||||
|
||||
return _check
|
||||
|
||||
+104
-122
@@ -3,80 +3,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import uuid as _uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, HTTPException, Request, Depends, APIRouter
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import WebSocketRoute
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.db import close_engine, get_engine
|
||||
from app.core.error_codes import ApiError, ErrorCategory, classify_exception, build_error_response
|
||||
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware
|
||||
from app.core.rate_limit import GeneralRateLimitMiddleware
|
||||
from app.core.resilience import CircuitBreakerMiddleware
|
||||
from app.core.monitoring import record_error, record_request
|
||||
from app.core.plugin_error_handler import wrap_plugin_route
|
||||
from app.core.service_container import get_container
|
||||
from app.plugins.registry import get_registry
|
||||
from app.routes import (
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.core.db import close_engine, get_engine # noqa: E402
|
||||
from app.core.error_codes import ApiError, build_error_response # noqa: E402
|
||||
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402
|
||||
from app.core.monitoring import record_error, record_request # noqa: E402
|
||||
from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402
|
||||
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
|
||||
from app.core.service_container import get_container # noqa: E402
|
||||
from app.plugins.registry import get_registry # noqa: E402
|
||||
from app.routes import ( # noqa: E402
|
||||
addresses,
|
||||
bank_accounts,
|
||||
ai_copilot,
|
||||
api_tokens,
|
||||
attachments,
|
||||
audit,
|
||||
auth,
|
||||
errors,
|
||||
contact_folders,
|
||||
backups,
|
||||
bank_accounts,
|
||||
contact_folder_permissions,
|
||||
entity_permissions,
|
||||
contact_folders,
|
||||
contacts,
|
||||
currencies,
|
||||
custom_field_definitions,
|
||||
custom_fields,
|
||||
dashboard,
|
||||
entity_history,
|
||||
entity_permissions,
|
||||
errors,
|
||||
groups,
|
||||
guests,
|
||||
health,
|
||||
import_export,
|
||||
metrics,
|
||||
notifications,
|
||||
plugins,
|
||||
roles,
|
||||
tenants,
|
||||
users,
|
||||
user_preferences,
|
||||
workflows,
|
||||
currencies,
|
||||
taxes,
|
||||
sequences,
|
||||
system_settings,
|
||||
attachments,
|
||||
custom_field_definitions,
|
||||
custom_fields,
|
||||
saved_filters,
|
||||
workspaces,
|
||||
saved_views,
|
||||
webhooks,
|
||||
backups,
|
||||
outbox,
|
||||
owner_transfer,
|
||||
permission_templates,
|
||||
plugins,
|
||||
# delegations, # ⏸ Parked — not integrated into resolve_permissions()
|
||||
policies,
|
||||
guests,
|
||||
outbox,
|
||||
api_tokens,
|
||||
roles,
|
||||
saved_filters,
|
||||
saved_views,
|
||||
sequences,
|
||||
system_settings,
|
||||
taxes,
|
||||
tenants,
|
||||
user_preferences,
|
||||
users,
|
||||
webhooks,
|
||||
workflows,
|
||||
workspaces,
|
||||
)
|
||||
|
||||
|
||||
# ── Graceful shutdown signal ─────────────────────────────────────────────────
|
||||
# Set during lifespan shutdown so middleware and handlers can stop accepting work.
|
||||
_shutdown_event = asyncio.Event()
|
||||
@@ -148,13 +145,15 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
# Report to Forgejo error reporter
|
||||
try:
|
||||
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
|
||||
await report_error_to_forgejo({
|
||||
"message": f"[Backend] {method} {path}: {exc}",
|
||||
"stack": tb_str,
|
||||
"url": str(request.url),
|
||||
"context": {"method": method, "path": path, "source": "backend_middleware", "trace_id": trace_id},
|
||||
})
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
reporter_contract = get_contract("forgejo_error_reporter")
|
||||
if reporter_contract is not None:
|
||||
await reporter_contract.report_error_to_forgejo({
|
||||
"message": f"[Backend] {method} {path}: {exc}",
|
||||
"stack": tb_str,
|
||||
"url": str(request.url),
|
||||
"context": {"method": method, "path": path, "source": "backend_middleware", "trace_id": trace_id},
|
||||
})
|
||||
except Exception:
|
||||
pass # Never let error reporting break the request
|
||||
raise
|
||||
@@ -168,12 +167,14 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
# Report 4xx and 5xx errors to Forgejo (except 401/403 which are expected)
|
||||
if status_code >= 400 and status_code not in (401, 403):
|
||||
try:
|
||||
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
|
||||
await report_error_to_forgejo({
|
||||
"message": f"[Backend] {method} {path} → {status_code}",
|
||||
"url": str(request.url),
|
||||
"context": {"method": method, "path": path, "status": status_code, "source": "backend_response", "trace_id": trace_id},
|
||||
})
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
reporter_contract = get_contract("forgejo_error_reporter")
|
||||
if reporter_contract is not None:
|
||||
await reporter_contract.report_error_to_forgejo({
|
||||
"message": f"[Backend] {method} {path} → {status_code}",
|
||||
"url": str(request.url),
|
||||
"context": {"method": method, "path": path, "status": status_code, "source": "backend_response", "trace_id": trace_id},
|
||||
})
|
||||
except Exception:
|
||||
pass # Never let error reporting break the response
|
||||
|
||||
@@ -197,8 +198,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown."""
|
||||
# Initialize global Redis client (singleton)
|
||||
from app.core.auth import init_redis, close_redis
|
||||
from app.core.jobs import init_job_pool, close_job_pool
|
||||
from app.core.auth import close_redis, init_redis
|
||||
from app.core.jobs import close_job_pool, init_job_pool
|
||||
|
||||
await init_redis()
|
||||
await init_job_pool()
|
||||
@@ -216,15 +217,16 @@ async def lifespan(app: FastAPI):
|
||||
# Install discovered builtin plugins and activate only those marked active in DB
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
|
||||
event_bus = get_event_bus()
|
||||
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||
|
||||
# Load all tenant IDs for per-tenant plugin activation (RLS fail-closed requires tenant context)
|
||||
from app.models.tenant import Tenant as TenantModel
|
||||
from app.core.db import set_tenant_context
|
||||
from app.models.tenant import Tenant as TenantModel
|
||||
|
||||
async with async_session() as db:
|
||||
tenant_result = await db.execute(sa_select(TenantModel.id))
|
||||
@@ -333,15 +335,17 @@ async def lifespan(app: FastAPI):
|
||||
register_trigger_dispatcher(event_bus)
|
||||
logger.info("Trigger dispatcher registered")
|
||||
|
||||
# Register entity restore configurations (Phase D — Undo/Restore)
|
||||
from app.core.restore_registry import register_default_entities
|
||||
register_default_entities()
|
||||
logger.info("Entity restore registry initialized")
|
||||
# Entity restore + history hooks are registered by plugins in on_activate(),
|
||||
# including Contacts (via ContactsPlugin). No Core special case here.
|
||||
|
||||
# Register hook-based history recording (Phase D — Undo/Restore)
|
||||
from app.core.history_hooks import register_default_history_hooks
|
||||
register_default_history_hooks()
|
||||
logger.info("History hooks registered")
|
||||
# Register entity models from active plugins (P0-3 fix)
|
||||
from app.services.entity_permission_service import register_entity_model
|
||||
for name in active_plugin_names:
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin:
|
||||
for entity_type, model_class in plugin.get_entity_models().items():
|
||||
register_entity_model(entity_type, model_class)
|
||||
logger.info("Entity models registered for %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Register field definitions from active plugins only
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
@@ -356,8 +360,8 @@ async def lifespan(app: FastAPI):
|
||||
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
|
||||
# ⚠️ Use migration engine (crm_migration, BYPASSRLS) — RLS on currencies/taxes
|
||||
# blocks inserts from crm_api role without tenant context.
|
||||
from app.core.seeds import seed_default_data
|
||||
from app.core.db import get_migration_session_factory
|
||||
from app.core.seeds import seed_default_data
|
||||
|
||||
mig_session_factory = get_migration_session_factory()
|
||||
async with mig_session_factory() as db:
|
||||
@@ -386,7 +390,7 @@ async def lifespan(app: FastAPI):
|
||||
# Give in-flight requests time to complete (max 30s)
|
||||
try:
|
||||
await asyncio.wait_for(_drain_inflight(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.warning("Graceful shutdown: 30s timeout reached, forcing shutdown")
|
||||
|
||||
# Close global Redis and ARQ pool
|
||||
@@ -575,63 +579,41 @@ def create_app() -> FastAPI:
|
||||
app.include_router(outbox.router)
|
||||
app.include_router(api_tokens.router)
|
||||
|
||||
# ── Register plugin routes for all built-in plugins ──
|
||||
# ── Register plugin routes for all discovered plugins ──
|
||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||
# Activation status is enforced per-request via require_active_plugin().
|
||||
import importlib
|
||||
# Plugin modules are discovered dynamically via the registry — no hardcoded list.
|
||||
from app.deps import require_active_plugin
|
||||
# Discover all built-in plugin modules and register their routes
|
||||
plugin_modules = [
|
||||
"app.plugins.builtins.tags",
|
||||
"app.plugins.builtins.permissions",
|
||||
"app.plugins.builtins.entity_links",
|
||||
"app.plugins.builtins.dms",
|
||||
"app.plugins.builtins.calendar",
|
||||
"app.plugins.builtins.mail",
|
||||
"app.plugins.builtins.report_generator",
|
||||
"app.plugins.builtins.kommunikation",
|
||||
"app.plugins.builtins.tasks",
|
||||
"app.plugins.builtins.automation",
|
||||
"app.plugins.builtins.ai_assistant",
|
||||
"app.plugins.builtins.ai_proactive",
|
||||
"app.plugins.builtins.ai_ui_control",
|
||||
"app.plugins.builtins.mcp_client",
|
||||
"app.plugins.builtins.mcp_server",
|
||||
"app.plugins.builtins.system_notif",
|
||||
"app.plugins.builtins.unified_search",
|
||||
"app.plugins.builtins.forgejo_error_reporter",
|
||||
"app.plugins.builtins.agent_memory",
|
||||
"app.plugins.builtins.graph_rag",
|
||||
"app.plugins.builtins.marketplace",
|
||||
]
|
||||
for mod_name in plugin_modules:
|
||||
from app.plugins.registry import get_registry
|
||||
_route_registry = get_registry()
|
||||
# Ensure builtins are discovered before registering routes.
|
||||
# discover_builtins() is idempotent — safe to call even if lifespan hasn't run yet.
|
||||
if not _route_registry.list_discovered():
|
||||
_route_registry.discover_builtins()
|
||||
for plugin_name in _route_registry.list_discovered():
|
||||
plugin = _route_registry.get_plugin(plugin_name)
|
||||
if plugin is None or not plugin.manifest.routes:
|
||||
continue
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
# Find the plugin class and get its manifest routes
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name)
|
||||
if isinstance(attr, type) and hasattr(attr, "manifest") and hasattr(attr.manifest, "routes"):
|
||||
plugin_name = getattr(attr.manifest, "name", mod_name.split(".")[-1])
|
||||
for route_def in attr.manifest.routes:
|
||||
try:
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
# Check if this route definition is public (no auth required)
|
||||
is_public = getattr(route_def, "is_public", False)
|
||||
if is_public:
|
||||
# Public routes: no auth dependency, no plugin check
|
||||
app.include_router(router)
|
||||
logger.info(f"Registered PUBLIC routes for {plugin_name}: {route_def.module}")
|
||||
continue
|
||||
plugin_dep = Depends(require_active_plugin(plugin_name))
|
||||
# Use include_router with dependencies to avoid mutating
|
||||
# the shared module-level router object (which tests reuse)
|
||||
app.include_router(router, dependencies=[plugin_dep])
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
|
||||
break
|
||||
for route_def in plugin.manifest.routes:
|
||||
try:
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
# Check if this route definition is public (no auth required)
|
||||
is_public = getattr(route_def, "is_public", False)
|
||||
if is_public:
|
||||
# Public routes: no auth dependency, no plugin check
|
||||
app.include_router(router)
|
||||
logger.info(f"Registered PUBLIC routes for {plugin_name}: {route_def.module}")
|
||||
continue
|
||||
plugin_dep = Depends(require_active_plugin(plugin_name))
|
||||
# Use include_router with dependencies to avoid mutating
|
||||
# the shared module-level router object (which tests reuse)
|
||||
app.include_router(router, dependencies=[plugin_dep])
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register plugin routes for {mod_name}: {exc}")
|
||||
logger.error(f"Failed to register plugin routes for {plugin_name}: {exc}")
|
||||
|
||||
# ── Serve frontend static files (SPA) ──────────────────────────────
|
||||
# Mount built frontend assets (JS, CSS, images)
|
||||
@@ -654,7 +636,7 @@ def create_app() -> FastAPI:
|
||||
if full_path.startswith(blocked_prefixes) or ".." in full_path:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
index_path = os.path.join(frontend_dist, "index.html")
|
||||
if os.path.isfile(index_path):
|
||||
if os.path.isfile(index_path): # noqa: ASYNC240
|
||||
return FileResponse(index_path)
|
||||
raise HTTPException(status_code=404, detail="Frontend not built")
|
||||
|
||||
|
||||
+17
-13
@@ -1,38 +1,37 @@
|
||||
"""SQLAlchemy models for LeoCRM."""
|
||||
|
||||
from app.models.address import Address
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.backup import Backup
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.consumer_inbox import ConsumerInbox
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.consumer_inbox import ConsumerInbox
|
||||
from app.models.outbox_delivery import OutboxDelivery
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.currency import Currency
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
from app.models.plugin import Plugin, PluginMigration
|
||||
from app.models.role import Role
|
||||
from app.models.saved_view import SavedView
|
||||
from app.models.sequence import Sequence
|
||||
from app.models.session import Session
|
||||
from app.models.system_settings import SystemSettings
|
||||
from app.models.tax import TaxRate
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.backup import Backup
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.webhook import Webhook
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||
from app.models.saved_view import SavedView
|
||||
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
@@ -79,4 +78,9 @@ __all__ = [
|
||||
"SavedView",
|
||||
]
|
||||
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
||||
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget # noqa: F401
|
||||
from app.models.workspace import ( # noqa: F401
|
||||
Workspace,
|
||||
WorkspaceModule,
|
||||
WorkspaceUser,
|
||||
WorkspaceWidget,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class AIConversation(Base, TenantMixin):
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.core.db import Base, TenantMixin
|
||||
|
||||
# Re-export EntityHistory as DeletionLog for backward compatibility.
|
||||
# Tests import DeletionLog from app.models.audit and use entity_snapshot attribute.
|
||||
from app.models.entity_history import EntityHistory as DeletionLog
|
||||
|
||||
|
||||
class AuditLog(Base, TenantMixin):
|
||||
|
||||
@@ -13,16 +13,15 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Computed,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
Float,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ for manual ordering via drag & drop.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy import JSON, Boolean, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
class EntityAttachment(Base, TenantMixin, OwnedMixin):
|
||||
"""Links a DMS file to any entity (contact, address, invoice, etc.).
|
||||
|
||||
|
||||
The actual file is stored in the DMS (files table).
|
||||
This table only holds the reference + category + display_name.
|
||||
"""
|
||||
@@ -42,10 +42,10 @@ class EntityAttachment(Base, TenantMixin, OwnedMixin):
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=__import__('sqlalchemy').func.now()
|
||||
DateTime(timezone=True), nullable=False, server_default=text('now()')
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=__import__('sqlalchemy').func.now(),
|
||||
onupdate=__import__('sqlalchemy').func.now(),
|
||||
DateTime(timezone=True), nullable=False, server_default=text('now()'),
|
||||
onupdate=text('now()'),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -23,7 +23,6 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
|
||||
@@ -30,10 +30,10 @@ from sqlalchemy import (
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -19,7 +19,6 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Notification(Base, TenantMixin):
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Index
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
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
|
||||
|
||||
@@ -15,7 +15,8 @@ from sqlalchemy import (
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
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
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy import Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
+1
-2
@@ -3,10 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, Numeric, String
|
||||
from sqlalchemy import Boolean, Index, Numeric, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Integer, JSON, String
|
||||
from sqlalchemy import JSON, Boolean, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -97,6 +97,29 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return []
|
||||
|
||||
# ─── Job Modules ───
|
||||
|
||||
def get_job_modules(self) -> list[str]:
|
||||
"""Return list of ARQ job module paths to import for background workers.
|
||||
|
||||
Override in subclasses that register background jobs. The worker
|
||||
imports each module so that register_job() calls fire.
|
||||
Default: no job modules.
|
||||
"""
|
||||
return []
|
||||
|
||||
# ─── Entity Models ───
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
"""Return entity_type → SQLAlchemy model class mapping for permission system.
|
||||
|
||||
Override in subclasses that own entities with OwnedMixin.
|
||||
These models are registered in ENTITY_MODELS at activation time
|
||||
so the permission system can resolve entity types dynamically.
|
||||
Default: no entity models.
|
||||
"""
|
||||
return {}
|
||||
|
||||
# ─── Field Definitions ───
|
||||
|
||||
def get_field_definitions(self) -> list[dict[str, str]]:
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
"""Built-in plugins directory.
|
||||
|
||||
Each module in this package that exports a BasePlugin subclass will be
|
||||
discovered automatically by the plugin registry on application startup.
|
||||
discovered automatically by the plugin registry on application startup
|
||||
via pkgutil.iter_modules — no manual imports needed here.
|
||||
|
||||
Subdirectory plugins (tags, permissions, entity_links, mail) export their plugin
|
||||
class via __init__.py so the registry can discover them as packages.
|
||||
Subdirectory plugins (tags, permissions, entity_links, mail, etc.) export
|
||||
their plugin class via __init__.py so the registry can discover them as packages.
|
||||
"""
|
||||
|
||||
from app.plugins.builtins.agent_memory import AgentMemoryPlugin
|
||||
from app.plugins.builtins.calendar import CalendarPlugin
|
||||
from app.plugins.builtins.dms import DmsPlugin
|
||||
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
||||
from app.plugins.builtins.graph_rag import GraphRAGPlugin
|
||||
from app.plugins.builtins.mail import MailPlugin
|
||||
from app.plugins.builtins.report_generator import ReportGeneratorPlugin
|
||||
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||
from app.plugins.builtins.tags import TagsPlugin
|
||||
from app.plugins.builtins.marketplace import MarketplacePlugin
|
||||
|
||||
__all__ = ["AgentMemoryPlugin", "TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin", "DmsPlugin", "CalendarPlugin", "MailPlugin", "ReportGeneratorPlugin", "GraphRAGPlugin", "MarketplacePlugin"]
|
||||
|
||||
@@ -34,6 +34,10 @@ class AgentMemoryPlugin(BasePlugin):
|
||||
contract_version="1.0.0",
|
||||
)
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.plugins.builtins.agent_memory.models import AgentMemory
|
||||
return {"agent_memory": AgentMemory}
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Deactivate plugin: unregister contract and event listeners."""
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.agent_memory.models import AgentMemory
|
||||
from app.plugins.builtins.agent_memory.schemas import (
|
||||
AgentMemoryCreate,
|
||||
AgentMemoryRead,
|
||||
AgentMemoryUpdate,
|
||||
)
|
||||
from app.plugins.builtins.agent_memory.services import (
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text as sql_text
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.agent_memory.models import AgentMemory
|
||||
|
||||
@@ -9,14 +9,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -61,7 +61,7 @@ async def run_agent_external(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
# Find the agent
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent
|
||||
@@ -79,8 +79,8 @@ async def run_agent_external(
|
||||
raise HTTPException(status_code=400, detail="Agent is not active")
|
||||
|
||||
# Create or find a session for this external interaction
|
||||
from app.plugins.builtins.ai_assistant.models import AIChatSession, AIChatMessage
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.plugins.builtins.ai_assistant.models import AIChatMessage, AIChatSession
|
||||
|
||||
session = AIChatSession(
|
||||
user_id=uuid.UUID(current_user["user_id"]),
|
||||
@@ -168,7 +168,7 @@ async def get_agent_status_external(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent
|
||||
|
||||
@@ -183,9 +183,10 @@ async def get_agent_status_external(
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
# Get recent run stats
|
||||
from app.plugins.builtins.automation.contracts import AutomationContract
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.plugins.builtins.automation.contracts import AutomationContract
|
||||
|
||||
recent_runs = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(AutomationContract.AgentRun)
|
||||
@@ -232,7 +233,7 @@ async def stream_agent_external(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
@@ -15,13 +13,13 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
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
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
# --- Providers ---
|
||||
|
||||
class AIProvider(Base, TenantMixin):
|
||||
|
||||
@@ -6,7 +6,13 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute, FrontendSettingsPage
|
||||
from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
FrontendSettingsPage,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,6 +77,10 @@ class AIAssistantPlugin(BasePlugin):
|
||||
|
||||
await seed_defaults(db)
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent, AIChatSession
|
||||
return {"ai_agent": AIAgent, "ai_chat_session": AIChatSession}
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Activate plugin: register CRM API tool and participant handler."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
@@ -33,10 +33,8 @@ from app.plugins.builtins.ai_assistant.schemas import (
|
||||
AIPresetUpdate,
|
||||
AIProviderCreate,
|
||||
AIProviderUpdate,
|
||||
ChatAttachmentResponse,
|
||||
ChatFolderCreate,
|
||||
ChatFolderUpdate,
|
||||
ChatFolderResponse,
|
||||
ChatSendRequest,
|
||||
ChatSessionCreate,
|
||||
ChatSessionUpdate,
|
||||
@@ -92,7 +90,7 @@ async def create_provider(
|
||||
existing = await db.execute(
|
||||
select(AIProvider)
|
||||
.where(AIProvider.tenant_id == tenant_id)
|
||||
.where(AIProvider.is_default == True)
|
||||
.where(AIProvider.is_default.is_(True))
|
||||
)
|
||||
for p in existing.scalars().all():
|
||||
p.is_default = False
|
||||
@@ -129,7 +127,7 @@ async def update_provider(
|
||||
existing = await db.execute(
|
||||
select(AIProvider)
|
||||
.where(AIProvider.tenant_id == tenant_id)
|
||||
.where(AIProvider.is_default == True)
|
||||
.where(AIProvider.is_default.is_(True))
|
||||
.where(AIProvider.id != provider.id)
|
||||
)
|
||||
for p in existing.scalars().all():
|
||||
@@ -717,8 +715,8 @@ async def delete_folder(
|
||||
|
||||
# ─── Attachments ───
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import os # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
ATTACHMENT_DIR = Path(os.environ.get("STORAGE_PATH", "/data/storage")) / "ai_attachments"
|
||||
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ─── Providers ───
|
||||
|
||||
class AIProviderCreate(BaseModel):
|
||||
|
||||
@@ -10,16 +10,15 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
|
||||
import litellm
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.llm_client import llm_complete
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
from app.plugins.builtins.ai_assistant.models import (
|
||||
AIAgent,
|
||||
@@ -169,7 +168,7 @@ async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProv
|
||||
result = await db.execute(
|
||||
select(AIProvider)
|
||||
.where(AIProvider.tenant_id == tenant_id)
|
||||
.where(AIProvider.is_default == True)
|
||||
.where(AIProvider.is_default.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -209,7 +208,7 @@ async def get_default_agent(db: AsyncSession, tenant_id: uuid.UUID) -> AIAgent |
|
||||
result = await db.execute(
|
||||
select(AIAgent)
|
||||
.where(AIAgent.tenant_id == tenant_id)
|
||||
.where(AIAgent.is_default == True)
|
||||
.where(AIAgent.is_default.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -394,8 +393,9 @@ async def _extract_attachment_content(
|
||||
text_content = content.decode("utf-8", errors="replace")
|
||||
elif mime == "application/pdf" or att.filename.endswith(".pdf"):
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
from io import BytesIO
|
||||
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(BytesIO(content))
|
||||
text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except ImportError:
|
||||
|
||||
@@ -8,8 +8,8 @@ and an async handler. Tools can optionally require specific RBAC permissions.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Protocol
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -12,12 +12,10 @@ import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.plugins.builtins.mail.contracts import Mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -101,7 +99,9 @@ async def search_related_handler(arguments: dict[str, Any], context: dict[str, A
|
||||
entity_id = uuid.UUID(arguments["entity_id"])
|
||||
limit = arguments.get("limit", 5)
|
||||
|
||||
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
|
||||
from app.plugins.builtins.unified_search.contracts import (
|
||||
get_contract as get_search_contract,
|
||||
)
|
||||
_search = get_search_contract()
|
||||
find_similar_all_types = _search.find_similar_all_types
|
||||
|
||||
@@ -165,8 +165,8 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
|
||||
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
CalendarEntryLink = _cal.CalendarEntryLink
|
||||
calendar_entry = _cal.calendar_entry
|
||||
calendar_entry_link = _cal.calendar_entry_link
|
||||
|
||||
db, tenant_id, _ = await _get_db_and_tenant(context)
|
||||
entity_type = arguments["entity_type"]
|
||||
@@ -174,14 +174,14 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
|
||||
now = datetime.now(UTC)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry)
|
||||
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
|
||||
.where(CalendarEntryLink.entity_type == entity_type)
|
||||
.where(CalendarEntryLink.entity_id == entity_id)
|
||||
.where(CalendarEntry.tenant_id == tenant_id)
|
||||
.where(CalendarEntry.start_at > now)
|
||||
.where(CalendarEntry.status == "open")
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
select(calendar_entry)
|
||||
.join(calendar_entry_link, calendar_entry_link.entry_id == calendar_entry.id)
|
||||
.where(calendar_entry_link.entity_type == entity_type)
|
||||
.where(calendar_entry_link.entity_id == entity_id)
|
||||
.where(calendar_entry.tenant_id == tenant_id)
|
||||
.where(calendar_entry.start_at > now)
|
||||
.where(calendar_entry.status == "open")
|
||||
.order_by(calendar_entry.start_at.asc())
|
||||
.limit(20)
|
||||
)
|
||||
tasks = [_serialize(e) for e in result.scalars().all()]
|
||||
@@ -194,7 +194,9 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
|
||||
async def hybrid_search_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
"""Perform hybrid search via unified_search search_engine."""
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
|
||||
from app.plugins.builtins.unified_search.contracts import (
|
||||
get_contract as get_search_contract,
|
||||
)
|
||||
_search = get_search_contract()
|
||||
hybrid_search = _search.hybrid_search
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ Exposes models, services, and job functions that other builtins plugins may need
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||
register_context_tools,
|
||||
)
|
||||
from app.plugins.builtins.ai_proactive.jobs import (
|
||||
deep_analysis,
|
||||
heartbeat,
|
||||
)
|
||||
from app.plugins.builtins.ai_proactive.models import (
|
||||
ContextLog,
|
||||
ProactiveSettings,
|
||||
@@ -20,13 +26,7 @@ from app.plugins.builtins.ai_proactive.services import (
|
||||
mark_dismissed,
|
||||
push_suggestion,
|
||||
)
|
||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||
register_context_tools,
|
||||
)
|
||||
from app.plugins.builtins.ai_proactive.jobs import (
|
||||
deep_analysis,
|
||||
heartbeat,
|
||||
)
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class AiProactiveContract:
|
||||
|
||||
@@ -10,23 +10,23 @@ Deep analysis job runs after context-change for deeper analysis:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.ai.llm_client import llm_complete
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai.llm_client import llm_complete
|
||||
from app.core.db import create_db_session
|
||||
from app.core.notifications import create_notification
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion, ProactiveSettings
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion
|
||||
from app.plugins.builtins.ai_proactive.services import (
|
||||
_serialize_row,
|
||||
generate_suggestion,
|
||||
get_user_settings,
|
||||
push_suggestion,
|
||||
)
|
||||
@@ -175,9 +175,10 @@ async def deep_analysis(
|
||||
|
||||
# Similar entities via unified_search
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
|
||||
from app.plugins.builtins.unified_search.contracts import (
|
||||
get_contract as get_search_contract,
|
||||
)
|
||||
_search = get_search_contract()
|
||||
hybrid_search = _search.hybrid_search
|
||||
find_similar_all_types = _search.hybrid_search # alias
|
||||
|
||||
extended_context["similar"] = await find_similar_all_types(
|
||||
@@ -339,9 +340,10 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
|
||||
async with create_db_session(tid) as db:
|
||||
# Check if heartbeat is enabled and get configuration
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
|
||||
|
||||
settings_result = await db.execute(
|
||||
sa_select(ProactiveSettings)
|
||||
.where(ProactiveSettings.tenant_id == tid)
|
||||
@@ -390,9 +392,9 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
contact_count = contact_count_result.scalar() or 0
|
||||
|
||||
# Build status message
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
now_str = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
now_str = datetime.now(UTC).strftime("%H:%M:%S")
|
||||
status_content = (
|
||||
f"**System aktiv** — überwacht {contact_count} Kontakte\n"
|
||||
f"_Letztes Update: {now_str}_"
|
||||
@@ -416,6 +418,6 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
|
||||
|
||||
# Register all job functions with the job registry
|
||||
from app.core.job_registry import register_job
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("deep_analysis", deep_analysis)
|
||||
|
||||
@@ -16,7 +16,8 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendSettingsPage
|
||||
from app.plugins.manifest import FrontendSettingsPage, PluginManifest, PluginRouteDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,16 +56,23 @@ class AIProactivePlugin(BasePlugin):
|
||||
super().__init__()
|
||||
self._proactive_handler = None
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion
|
||||
return {"proactive_suggestion": ProactiveSuggestion}
|
||||
|
||||
def get_job_modules(self) -> list[str]:
|
||||
return ["app.plugins.builtins.ai_proactive.jobs"]
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register context tools, subscribe to events, and register as participant."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
try:
|
||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||
register_context_tools,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.contracts import (
|
||||
get_tool_registry,
|
||||
)
|
||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||
register_context_tools,
|
||||
)
|
||||
|
||||
register_context_tools(get_tool_registry())
|
||||
logger.info("AI Proactive context tools registered")
|
||||
|
||||
@@ -15,8 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db, set_tenant_context
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.core.db import get_db
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.ai_proactive.models import (
|
||||
@@ -30,9 +29,9 @@ from app.plugins.builtins.ai_proactive.schemas import (
|
||||
ContextReport,
|
||||
SettingsResponse,
|
||||
SettingsUpdate,
|
||||
StatsResponse,
|
||||
SuggestionListResponse,
|
||||
SuggestionResponse,
|
||||
StatsResponse,
|
||||
)
|
||||
from app.plugins.builtins.ai_proactive.services import (
|
||||
execute_suggested_action,
|
||||
@@ -184,7 +183,7 @@ async def dismiss_suggestion(
|
||||
try:
|
||||
sid = uuid.UUID(suggestion_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid suggestion ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid suggestion ID") from None
|
||||
|
||||
success = await mark_dismissed(db, sid, user_id, tenant_id)
|
||||
if not success:
|
||||
@@ -210,7 +209,7 @@ async def act_on_suggestion(
|
||||
try:
|
||||
sid = uuid.UUID(suggestion_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid suggestion ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid suggestion ID") from None
|
||||
|
||||
result = await execute_suggested_action(
|
||||
db, sid, action.action_index, user_id, tenant_id, current_user
|
||||
@@ -245,7 +244,7 @@ async def stream_suggestions(
|
||||
try:
|
||||
suggestion = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
yield f"data: {json.dumps(suggestion, default=str)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -6,30 +6,28 @@ suggestions, pushes via SSE, and manages suggestion lifecycle.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
from app.ai.llm_client import llm_complete
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cache import get_cache
|
||||
from app.core.db import create_db_session, get_session_factory
|
||||
from app.ai.llm_client import llm_complete
|
||||
from app.core.db import create_db_session
|
||||
from app.core.notifications import create_notification
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.plugins.builtins.ai_proactive.models import (
|
||||
ContextLog,
|
||||
ProactiveSettings,
|
||||
ProactiveSuggestion,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
litellm.suppress_debug_info = True
|
||||
@@ -81,9 +79,10 @@ async def is_rate_limited(
|
||||
and ``window_seconds=rate_limit_seconds``.
|
||||
Returns ``True`` if rate-limited, ``False`` if allowed.
|
||||
"""
|
||||
from app.core.rate_limit import check_rate_limit
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.rate_limit import check_rate_limit
|
||||
|
||||
redis_key = f"rate:ai_proactive:{tenant_id}:{user_id}"
|
||||
try:
|
||||
await check_rate_limit(redis_key, max_attempts=1, window_seconds=rate_limit_seconds)
|
||||
@@ -208,18 +207,18 @@ async def gather_context(
|
||||
# Upcoming calendar events
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
CalendarEntryLink = _cal.CalendarEntryLink
|
||||
calendar_entry = _cal.calendar_entry
|
||||
calendar_entry_link = _cal.calendar_entry_link
|
||||
|
||||
now = datetime.now(UTC)
|
||||
event_result = await db.execute(
|
||||
select(CalendarEntry)
|
||||
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
|
||||
.where(CalendarEntryLink.entity_type == "contact")
|
||||
.where(CalendarEntryLink.entity_id == entity_id)
|
||||
.where(CalendarEntry.tenant_id == tenant_id)
|
||||
.where(CalendarEntry.start_at > now)
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
select(calendar_entry)
|
||||
.join(calendar_entry_link, calendar_entry_link.entry_id == calendar_entry.id)
|
||||
.where(calendar_entry_link.entity_type == "contact")
|
||||
.where(calendar_entry_link.entity_id == entity_id)
|
||||
.where(calendar_entry.tenant_id == tenant_id)
|
||||
.where(calendar_entry.start_at > now)
|
||||
.order_by(calendar_entry.start_at.asc())
|
||||
.limit(5)
|
||||
)
|
||||
context["events"] = [_serialize_row(e) for e in event_result.scalars().all()]
|
||||
@@ -323,18 +322,18 @@ async def gather_context(
|
||||
# Upcoming events
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
CalendarEntryLink = _cal.CalendarEntryLink
|
||||
calendar_entry = _cal.calendar_entry
|
||||
calendar_entry_link = _cal.calendar_entry_link
|
||||
|
||||
now = datetime.now(UTC)
|
||||
event_result = await db.execute(
|
||||
select(CalendarEntry)
|
||||
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
|
||||
.where(CalendarEntryLink.entity_type == "contact")
|
||||
.where(CalendarEntryLink.entity_id == entity_id)
|
||||
.where(CalendarEntry.tenant_id == tenant_id)
|
||||
.where(CalendarEntry.start_at > now)
|
||||
.order_by(CalendarEntry.start_at.asc())
|
||||
select(calendar_entry)
|
||||
.join(calendar_entry_link, calendar_entry_link.entry_id == calendar_entry.id)
|
||||
.where(calendar_entry_link.entity_type == "contact")
|
||||
.where(calendar_entry_link.entity_id == entity_id)
|
||||
.where(calendar_entry.tenant_id == tenant_id)
|
||||
.where(calendar_entry.start_at > now)
|
||||
.order_by(calendar_entry.start_at.asc())
|
||||
.limit(5)
|
||||
)
|
||||
context["events"] = [_serialize_row(e) for e in event_result.scalars().all()]
|
||||
@@ -365,7 +364,9 @@ async def gather_context(
|
||||
|
||||
# Semantically similar entities via unified_search
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
|
||||
from app.plugins.builtins.unified_search.contracts import (
|
||||
get_contract as get_search_contract,
|
||||
)
|
||||
_search = get_search_contract()
|
||||
find_similar_all_types = _search.hybrid_search
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ Exposes the WebSocket manager and UI command schemas for other plugins.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.ai_ui_control.websocket_manager import AIUIControlWSManager
|
||||
from app.plugins.builtins.ai_ui_control.schemas import (
|
||||
UICommand,
|
||||
UICommandCreate,
|
||||
@@ -16,6 +14,8 @@ from app.plugins.builtins.ai_ui_control.schemas import (
|
||||
UICommandStatusResponse,
|
||||
UICommandType,
|
||||
)
|
||||
from app.plugins.builtins.ai_ui_control.websocket_manager import AIUIControlWSManager
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class AiUiControlContract:
|
||||
|
||||
@@ -11,12 +11,10 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.deps import require_permission
|
||||
from app.plugins.builtins.ai_ui_control.schemas import (
|
||||
UICommand,
|
||||
UICommandCreate,
|
||||
UICommandFeedback,
|
||||
UICommandResponse,
|
||||
@@ -45,7 +43,7 @@ async def send_ui_command(
|
||||
Authentication: requires valid session (same-user commands only).
|
||||
"""
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_session_data, get_redis
|
||||
from app.core.auth import get_redis, get_session_data
|
||||
from app.core.service_container import get_container
|
||||
|
||||
settings = get_settings()
|
||||
@@ -117,7 +115,7 @@ async def get_command_status(
|
||||
AI agents call this to check if the frontend has executed the command.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_session_data, get_redis
|
||||
from app.core.auth import get_redis, get_session_data
|
||||
from app.core.service_container import get_container
|
||||
|
||||
settings = get_settings()
|
||||
@@ -173,7 +171,7 @@ async def get_command_status(
|
||||
async def get_online_users(request: Request):
|
||||
"""Check which users are currently online (have active frontend WS connections)."""
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_session_data, get_redis
|
||||
from app.core.auth import get_redis, get_session_data
|
||||
from app.core.service_container import get_container
|
||||
|
||||
settings = get_settings()
|
||||
@@ -225,9 +223,11 @@ async def ai_ui_control_ws(websocket: WebSocket):
|
||||
tenant_id = auth["tenant_id"]
|
||||
|
||||
# Plugin-Gate: check if ai_ui_control plugin is active (global + tenant)
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
from sqlalchemy import text as sa_text
|
||||
import uuid as _uuid
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
try:
|
||||
registry = get_permission_registry()
|
||||
if not registry.is_plugin_active("ai_ui_control"):
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UICommandType(str, Enum):
|
||||
class UICommandType(StrEnum):
|
||||
"""Supported UI command types."""
|
||||
navigate = "navigate"
|
||||
filter = "filter"
|
||||
@@ -18,7 +18,7 @@ class UICommandType(str, Enum):
|
||||
settings = "settings"
|
||||
|
||||
|
||||
class UICommandStatus(str, Enum):
|
||||
class UICommandStatus(StrEnum):
|
||||
"""Status of a UI command execution."""
|
||||
pending = "pending"
|
||||
delivered = "delivered"
|
||||
|
||||
@@ -15,6 +15,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ws_helpers import (
|
||||
authenticate_ws,
|
||||
@@ -28,7 +29,6 @@ from app.core.ws_pubsub import (
|
||||
get_tenant_channel,
|
||||
subscribe_to_channel,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -176,9 +176,9 @@ class AIUIControlWSManager:
|
||||
if command_id:
|
||||
self._feedback[command_id] = feedback
|
||||
# Enforce max feedback entries (FIFO eviction)
|
||||
MAX_FEEDBACK_ENTRIES = 100
|
||||
if len(self._feedback) > MAX_FEEDBACK_ENTRIES:
|
||||
keys_to_remove = list(self._feedback.keys())[:-MAX_FEEDBACK_ENTRIES]
|
||||
max_feedback_entries = 100
|
||||
if len(self._feedback) > max_feedback_entries:
|
||||
keys_to_remove = list(self._feedback.keys())[:-max_feedback_entries]
|
||||
for key in keys_to_remove:
|
||||
del self._feedback[key]
|
||||
logger.debug(f"AI UI Control: feedback stored for command {command_id}: {feedback.get('status')}")
|
||||
|
||||
@@ -27,8 +27,8 @@ async def send_agent_message(
|
||||
3. Enqueue run_agent for the target agent with the message as trigger_data
|
||||
4. Return delivery status
|
||||
"""
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
from app.plugins.builtins.automation.agent_runner import run_agent
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
# 1. Find target agent by name
|
||||
result = await db.execute(
|
||||
@@ -56,7 +56,6 @@ async def send_agent_message(
|
||||
# 2. Create a kommunikation message in a dedicated agent room
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import Message, Room
|
||||
from app.plugins.builtins.kommunikation.contracts import RoomService
|
||||
|
||||
# Find or create the agent-to-agent room
|
||||
room_name = f"agent:{from_agent_id}:{target_agent.id}"
|
||||
|
||||
@@ -12,7 +12,8 @@ import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
@@ -60,12 +61,12 @@ class AgentCoordinator:
|
||||
return subtask
|
||||
|
||||
@staticmethod
|
||||
async def wait_for_subtask(
|
||||
async def wait_for_subtask( # noqa: ASYNC109
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
subtask_id: uuid.UUID,
|
||||
poll_interval: float = 0.5,
|
||||
timeout: float = 300.0,
|
||||
timeout: float = 300.0, # noqa: ASYNC109
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a subtask to complete, fail, or be cancelled.
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db, set_tenant_context
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
@@ -216,7 +216,7 @@ async def get_agent(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
@@ -241,7 +241,7 @@ async def update_agent(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.update(
|
||||
db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id
|
||||
@@ -265,7 +265,7 @@ async def delete_agent(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
success = await AgentService.delete(db, tenant_id, aid)
|
||||
if not success:
|
||||
@@ -290,7 +290,7 @@ async def execute_agent(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
@@ -325,9 +325,10 @@ async def execute_agent(
|
||||
)
|
||||
|
||||
# Update run with results
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import update as sa_update
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
async with db.begin():
|
||||
await db.execute(
|
||||
sa_update(AgentRun)
|
||||
@@ -358,7 +359,7 @@ async def test_run_agent(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
@@ -397,7 +398,7 @@ async def list_agent_runs(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
items, total = await RunLogService.list_agent_runs(
|
||||
db, tenant_id, agent_id=aid, status=status, limit=limit, offset=offset
|
||||
@@ -428,7 +429,7 @@ async def list_agent_versions(
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
items, total = await AgentService.get_versions(
|
||||
db, tenant_id, aid, limit=limit, offset=offset
|
||||
@@ -457,7 +458,7 @@ async def restore_agent_version(
|
||||
aid = uuid.UUID(agent_id)
|
||||
vid = uuid.UUID(version_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid ID") from None
|
||||
|
||||
agent = await AgentService.restore_version(
|
||||
db, tenant_id, aid, vid, user_id=user_id
|
||||
@@ -485,7 +486,7 @@ async def send_agent_message_endpoint(
|
||||
try:
|
||||
aid = uuid.UUID(id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
# Verify the source agent exists
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
|
||||
@@ -16,8 +16,8 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.ai.llm_client import llm_complete
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,15 +161,6 @@ async def run_agent(
|
||||
try:
|
||||
async def _run_llm() -> None:
|
||||
"""Inner coroutine for LLM call with tool execution."""
|
||||
from app.ai.llm_client import LLMClient
|
||||
|
||||
llm = LLMClient(
|
||||
model=agent.model or None,
|
||||
api_key=agent.api_key or None,
|
||||
api_base=agent.api_base or None,
|
||||
provider=agent.provider or None,
|
||||
)
|
||||
|
||||
# Build system prompt from agent configuration
|
||||
system_prompt = agent.system_prompt or "You are a helpful AI assistant."
|
||||
user_prompt = f"Context: {context_data}"
|
||||
@@ -241,7 +232,7 @@ async def run_agent(
|
||||
# Run with timeout
|
||||
try:
|
||||
await asyncio.wait_for(_run_llm(), timeout=max_duration)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Agent %s execution timed out after %d seconds",
|
||||
agent.id, max_duration,
|
||||
@@ -295,6 +286,6 @@ async def run_agent(
|
||||
|
||||
|
||||
# Register all job functions with the job registry
|
||||
from app.core.job_registry import register_job
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("run_agent", run_agent)
|
||||
register_job("run_agent", run_agent)
|
||||
|
||||
@@ -5,7 +5,9 @@ Exposes models, services, scheduler, and agent communication for other plugins.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.automation.agent_comm import send_agent_message
|
||||
from app.plugins.builtins.automation.agent_runner import run_agent
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
@@ -15,19 +17,17 @@ from app.plugins.builtins.automation.models import (
|
||||
AutomationRun,
|
||||
AutomationVersion,
|
||||
)
|
||||
from app.plugins.builtins.automation.scheduler import (
|
||||
calculate_next_run,
|
||||
scheduler_tick,
|
||||
)
|
||||
from app.plugins.builtins.automation.services import (
|
||||
AgentService,
|
||||
AutomationService,
|
||||
CronJobService,
|
||||
RunLogService,
|
||||
)
|
||||
from app.plugins.builtins.automation.agent_runner import run_agent
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||
from app.plugins.builtins.automation.scheduler import (
|
||||
calculate_next_run,
|
||||
scheduler_tick,
|
||||
)
|
||||
from app.plugins.builtins.automation.agent_comm import send_agent_message
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class AutomationContract:
|
||||
@@ -73,8 +73,6 @@ get_contract_registry().register("automation", _contract)
|
||||
__all__ = [
|
||||
"AutomationContract",
|
||||
"AgentDefinition",
|
||||
"Automation",
|
||||
"CronJob",
|
||||
"AgentService",
|
||||
"AutomationService",
|
||||
"CronJobService",
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -166,9 +164,10 @@ async def _execute_action(
|
||||
return result
|
||||
|
||||
try:
|
||||
from app.services.workflow_service import create_instance
|
||||
from uuid import UUID
|
||||
|
||||
from app.services.workflow_service import create_instance
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
instance = await create_instance(
|
||||
@@ -280,6 +279,6 @@ async def run_automation(
|
||||
|
||||
|
||||
# Register all job functions with the job registry
|
||||
from app.core.job_registry import register_job
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("run_automation", run_automation)
|
||||
|
||||
@@ -16,9 +16,10 @@ async def backup_check(ctx: dict[str, Any]) -> None:
|
||||
Runs daily at 2:00. Checks the last backup timestamp from system settings
|
||||
and publishes backup.completed or backup.failed events accordingly.
|
||||
"""
|
||||
from app.core.event_bus import get_event_bus
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
event_bus = get_event_bus()
|
||||
factory = get_session_factory()
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from sqlalchemy import (
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
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
|
||||
|
||||
@@ -176,6 +176,18 @@ class AutomationPlugin(BasePlugin):
|
||||
self._contributed_cron_jobs: dict[str, list[str]] = {} # plugin_name -> [cron_job_name, ...]
|
||||
self._contributed_heartbeats: dict[str, list[str]] = {} # plugin_name -> [agent_name, ...]
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.plugins.builtins.automation.models import AgentDefinition, AutomationDefinition
|
||||
return {"agent_definition": AgentDefinition, "automation_definition": AutomationDefinition}
|
||||
|
||||
def get_job_modules(self) -> list[str]:
|
||||
return [
|
||||
"app.plugins.builtins.automation.scheduler",
|
||||
"app.plugins.builtins.automation.workflow_timeout",
|
||||
"app.plugins.builtins.automation.agent_runner",
|
||||
"app.plugins.builtins.automation.execution_engine",
|
||||
]
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register event listeners on activation."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
@@ -187,7 +199,9 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.exception("Failed to register agent communication tool")
|
||||
# Register agent coordinator tools
|
||||
try:
|
||||
from app.plugins.builtins.automation.agent_coordinator import register_agent_coordinator_tools
|
||||
from app.plugins.builtins.automation.agent_coordinator import (
|
||||
register_agent_coordinator_tools,
|
||||
)
|
||||
register_agent_coordinator_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to register agent coordinator tools")
|
||||
@@ -230,7 +244,9 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.exception("Failed to unregister agent communication tool")
|
||||
# Unregister agent coordinator tools
|
||||
try:
|
||||
from app.plugins.builtins.automation.agent_coordinator import unregister_agent_coordinator_tools
|
||||
from app.plugins.builtins.automation.agent_coordinator import (
|
||||
unregister_agent_coordinator_tools,
|
||||
)
|
||||
unregister_agent_coordinator_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister agent coordinator tools")
|
||||
@@ -249,12 +265,16 @@ class AutomationPlugin(BasePlugin):
|
||||
async def register_plugin_contributions(self, db, plugin_name: str, manifest) -> None:
|
||||
"""Register agent definitions, automation templates, cron jobs, and heartbeat configs
|
||||
from another plugin's manifest. Uses plugin name prefixing for conflict resolution."""
|
||||
from app.plugins.builtins.automation.services import AgentService, AutomationService, CronJobService
|
||||
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||
from sqlalchemy import select
|
||||
|
||||
# Get default tenant_id from the first tenant in the DB
|
||||
from app.models.tenant import Tenant
|
||||
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||
from app.plugins.builtins.automation.services import (
|
||||
AgentService,
|
||||
AutomationService,
|
||||
CronJobService,
|
||||
)
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
default_tenant_id = tenant.id if tenant else None
|
||||
@@ -356,7 +376,11 @@ class AutomationPlugin(BasePlugin):
|
||||
|
||||
async def unregister_plugin_contributions(self, db, plugin_name: str) -> None:
|
||||
"""Remove all contributed definitions from a plugin that is being deactivated."""
|
||||
from app.plugins.builtins.automation.services import AgentService, AutomationService, CronJobService
|
||||
from app.plugins.builtins.automation.services import (
|
||||
AgentService,
|
||||
AutomationService,
|
||||
CronJobService,
|
||||
)
|
||||
|
||||
# Remove contributed agents
|
||||
agent_names = self._contributed_agents.pop(plugin_name, [])
|
||||
@@ -384,8 +408,9 @@ class AutomationPlugin(BasePlugin):
|
||||
cron_job_names = self._contributed_cron_jobs.pop(plugin_name, [])
|
||||
for cron_name in cron_job_names:
|
||||
try:
|
||||
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||
result = await db.execute(
|
||||
select(AutomationCronJob).where(AutomationCronJob.name == cron_name).limit(1)
|
||||
)
|
||||
@@ -411,9 +436,10 @@ class AutomationPlugin(BasePlugin):
|
||||
|
||||
async def ensure_ai_proactive_heartbeat(self, db) -> None:
|
||||
"""Migrate the hardcoded ai_proactive heartbeat to a configurable cron job."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||
from app.plugins.builtins.automation.services import CronJobService
|
||||
from sqlalchemy import select
|
||||
|
||||
# Check if ai_proactive heartbeat cron job already exists
|
||||
result = await db.execute(
|
||||
@@ -456,4 +482,4 @@ class AutomationPlugin(BasePlugin):
|
||||
|
||||
async def on_workflow_timeout(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle workflow.timeout event — trigger matching automations."""
|
||||
logger.debug("workflow.timeout event received: %s", payload)
|
||||
logger.debug("workflow.timeout event received: %s", payload)
|
||||
|
||||
@@ -7,13 +7,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db, set_tenant_context
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AutomationDefinition,
|
||||
@@ -253,9 +253,10 @@ async def get_automation_settings(
|
||||
):
|
||||
"""Get automation settings (persisted in system_settings metadata)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
from app.models.system_settings import SystemSettings
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.system_settings import SystemSettings
|
||||
|
||||
result = await db.execute(
|
||||
select(SystemSettings).where(SystemSettings.tenant_id == tenant_id)
|
||||
)
|
||||
@@ -285,9 +286,9 @@ async def update_automation_settings(
|
||||
):
|
||||
"""Update automation settings (persisted in system_settings metadata)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
from app.models.system_settings import SystemSettings
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.models.system_settings import SystemSettings
|
||||
|
||||
result = await db.execute(
|
||||
select(SystemSettings).where(SystemSettings.tenant_id == tenant_id)
|
||||
@@ -345,7 +346,7 @@ async def get_automation(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
||||
if automation is None:
|
||||
@@ -370,7 +371,7 @@ async def update_automation(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
automation = await AutomationService.update(
|
||||
db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id
|
||||
@@ -394,7 +395,7 @@ async def delete_automation(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
success = await AutomationService.delete(db, tenant_id, aid)
|
||||
if not success:
|
||||
@@ -419,7 +420,7 @@ async def execute_automation(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
||||
if automation is None:
|
||||
@@ -441,7 +442,6 @@ async def execute_automation(
|
||||
await db.flush()
|
||||
|
||||
# Execute automation via execution engine
|
||||
import asyncio
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||
|
||||
run_id = str(run.id)
|
||||
@@ -456,9 +456,10 @@ async def execute_automation(
|
||||
)
|
||||
|
||||
# Update run with results
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import update as sa_update
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
async with db.begin():
|
||||
await db.execute(
|
||||
sa_update(AutomationRun)
|
||||
@@ -489,14 +490,16 @@ async def dry_run_automation(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
||||
if automation is None:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
|
||||
# Execute dry-run via execution engine
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation as execute_automation_engine
|
||||
from app.plugins.builtins.automation.execution_engine import (
|
||||
run_automation as execute_automation_engine,
|
||||
)
|
||||
|
||||
result = await execute_automation_engine(
|
||||
ctx={},
|
||||
@@ -558,7 +561,7 @@ async def list_automation_runs(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
items, total = await RunLogService.list_automation_runs(
|
||||
db, tenant_id, automation_id=aid, status=status, limit=limit, offset=offset
|
||||
@@ -589,7 +592,7 @@ async def list_automation_versions(
|
||||
try:
|
||||
aid = uuid.UUID(automation_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid automation ID") from None
|
||||
|
||||
items, total = await AutomationService.get_versions(
|
||||
db, tenant_id, aid, limit=limit, offset=offset
|
||||
@@ -618,7 +621,7 @@ async def restore_automation_version(
|
||||
aid = uuid.UUID(automation_id)
|
||||
vid = uuid.UUID(version_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid ID") from None
|
||||
|
||||
automation = await AutomationService.restore_version(
|
||||
db, tenant_id, aid, vid, user_id=user_id
|
||||
@@ -653,7 +656,7 @@ async def list_subtasks(
|
||||
parent_id = uuid.UUID(parent_agent_id) if parent_agent_id else None
|
||||
child_id = uuid.UUID(child_agent_id) if child_agent_id else None
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
@@ -688,7 +691,7 @@ async def create_subtask(
|
||||
parent_id = uuid.UUID(data.parent_agent_id)
|
||||
child_id = uuid.UUID(data.child_agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
@@ -718,10 +721,11 @@ async def get_subtask(
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID") from None
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
@@ -751,10 +755,11 @@ async def update_subtask(
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID") from None
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
@@ -795,7 +800,7 @@ async def cancel_subtask(
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID") from None
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
@@ -823,7 +828,7 @@ async def wait_for_subtask(
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID") from None
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
@@ -845,7 +850,7 @@ async def aggregate_subtasks(
|
||||
try:
|
||||
ids = [uuid.UUID(sid) for sid in subtask_ids]
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID in list")
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID in list") from None
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
|
||||
@@ -74,6 +74,6 @@ async def scheduler_tick(ctx: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
# Register all job functions with the job registry
|
||||
from app.core.job_registry import register_job
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("scheduler_tick", scheduler_tick)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Agent Definition Schemas ───
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, text, update
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
@@ -668,7 +668,7 @@ class CronJobService:
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(AutomationCronJob)
|
||||
.where(AutomationCronJob.is_active == True)
|
||||
.where(AutomationCronJob.is_active.is_(True))
|
||||
.where(AutomationCronJob.next_run_at <= now)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
@@ -7,33 +7,24 @@ since PostgreSQL may not be available in the dev container.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.db import Base
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
AgentVersion,
|
||||
AutomationCronJob,
|
||||
AutomationDefinition,
|
||||
AutomationRun,
|
||||
AutomationVersion,
|
||||
)
|
||||
from app.plugins.builtins.automation.services import (
|
||||
AgentService,
|
||||
AutomationService,
|
||||
CronJobService,
|
||||
RunLogService,
|
||||
)
|
||||
|
||||
|
||||
# ─── Fixtures ───
|
||||
|
||||
|
||||
@@ -536,12 +527,12 @@ class TestInfiniteLoopDetection:
|
||||
tool_call_count: dict[str, int] = {}
|
||||
tool_name = "send_email"
|
||||
|
||||
for i in range(5):
|
||||
for _i in range(5):
|
||||
tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1
|
||||
if tool_call_count[tool_name] >= 5:
|
||||
assert True
|
||||
return
|
||||
assert False, "Loop detection should have triggered"
|
||||
raise AssertionError("Loop detection should have triggered")
|
||||
|
||||
def test_different_tools_not_detected(self):
|
||||
"""Test that different tool calls don't trigger loop detection."""
|
||||
@@ -549,5 +540,5 @@ class TestInfiniteLoopDetection:
|
||||
for i in range(5):
|
||||
tool_call_count[f"tool_{i}"] = tool_call_count.get(f"tool_{i}", 0) + 1
|
||||
if tool_call_count[f"tool_{i}"] >= 5:
|
||||
assert False, "Different tools should not trigger loop detection"
|
||||
raise AssertionError("Different tools should not trigger loop detection")
|
||||
assert True
|
||||
|
||||
@@ -67,7 +67,7 @@ async def check_workflow_timeouts(ctx: dict[str, Any]) -> None:
|
||||
user_id=instance.initiated_by,
|
||||
type="workflow_timeout",
|
||||
title=f"Workflow '{workflow_name}' cancelled due to timeout",
|
||||
body=f"The workflow instance timed out and was automatically cancelled.",
|
||||
body="The workflow instance timed out and was automatically cancelled.",
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
@@ -83,6 +83,6 @@ async def check_workflow_timeouts(ctx: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
# Register all job functions with the job registry
|
||||
from app.core.job_registry import register_job
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("check_workflow_timeouts", check_workflow_timeouts)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class CalendarContract:
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute, FrontendDetailTab
|
||||
from app.plugins.manifest import (
|
||||
FrontendDetailTab,
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
|
||||
|
||||
class CalendarPlugin(BasePlugin):
|
||||
@@ -56,11 +62,45 @@ class CalendarPlugin(BasePlugin):
|
||||
contract_version="1.0.0",
|
||||
)
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Activate plugin: register restore config + history hooks."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
# Register restore config for CalendarEntry entities (P0-7 fix)
|
||||
from app.core.restore_registry import RestoreConfig, get_restore_registry
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
get_restore_registry().register(RestoreConfig(
|
||||
entity_type="calendar_entry",
|
||||
model_class=CalendarEntry,
|
||||
restore_permission="calendar:write",
|
||||
excluded_fields=frozenset({"calendar_id", "created_by", "assigned_to", "source_mail_id"}),
|
||||
))
|
||||
|
||||
# Register history hooks for CalendarEntry entities (P0-8 fix)
|
||||
from app.core.history_hooks import register_history_hooks
|
||||
from app.core.hooks import get_hook_registry
|
||||
register_history_hooks(
|
||||
get_hook_registry(), "calendar_entry",
|
||||
"calendar_entry.after_create", "calendar_entry.after_update", "calendar_entry.after_delete",
|
||||
owner_tag="calendar",
|
||||
)
|
||||
|
||||
async def on_deactivate(
|
||||
self, db, service_container, event_bus
|
||||
) -> None:
|
||||
"""Deactivate plugin: unregister contract and event listeners."""
|
||||
"""Deactivate plugin: unregister contract, restore, history, events."""
|
||||
# Contract abmelden
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
get_contract_registry().unregister(self.manifest.name)
|
||||
|
||||
# Unregister restore config (P0-7 fix)
|
||||
from app.core.restore_registry import get_restore_registry
|
||||
get_restore_registry().unregister("calendar_entry")
|
||||
|
||||
# Unregister history hooks (free functions, not bound methods)
|
||||
from app.core.hooks import get_hook_registry
|
||||
get_hook_registry().unregister_actions_by_owner("calendar_entry.after_create", "calendar")
|
||||
get_hook_registry().unregister_actions_by_owner("calendar_entry.after_update", "calendar")
|
||||
get_hook_registry().unregister_actions_by_owner("calendar_entry.after_delete", "calendar")
|
||||
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
@@ -22,7 +22,6 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.deps import get_current_user, require_admin, require_permission
|
||||
from app.plugins.builtins.calendar.ics_utils import (
|
||||
export_entries_to_ics,
|
||||
@@ -292,8 +291,8 @@ async def share_calendar(
|
||||
|
||||
# Grant calendar:read (or calendar:write) permission to the shared user's role
|
||||
if body.user_id:
|
||||
from app.models.user import UserTenant
|
||||
from app.models.role import Role
|
||||
from app.models.user import UserTenant
|
||||
shared_user_id = _parse_uuid(body.user_id, "user_id")
|
||||
ut_q = await db.execute(
|
||||
select(UserTenant).where(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
||||
|
||||
__all__ = ["ContactsPlugin"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Contacts plugin — Core CRM entity lifecycle management.
|
||||
|
||||
Registers Contact entity models, permissions, restore config, and history hooks
|
||||
via the same plugin lifecycle as all other business plugins. No Core special case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactsPlugin(BasePlugin):
|
||||
"""Contacts plugin — manages Contact entity lifecycle (models, permissions, restore, history).
|
||||
|
||||
Routes remain in app/routes/contacts.py as core routes, but entity lifecycle
|
||||
(permissions, entity models, restore, history) is managed through on_activate/on_deactivate.
|
||||
"""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="contacts",
|
||||
version="1.0.0",
|
||||
display_name="Contacts",
|
||||
description="Core CRM contacts — persons and companies.",
|
||||
dependencies=[],
|
||||
routes=[], # Routes are registered as core routes in main.py
|
||||
events=[],
|
||||
migrations=[],
|
||||
permissions=[
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"contacts:delete",
|
||||
],
|
||||
is_core=True,
|
||||
author="LeoCRM Team",
|
||||
min_app_version="1.0.0",
|
||||
contract_version="1.0.0",
|
||||
)
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.models.contact import Contact
|
||||
return {
|
||||
"contact": Contact,
|
||||
"contacts": Contact,
|
||||
"company": Contact,
|
||||
}
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Activate: register restore config + history hooks for Contact."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
# Register restore config for Contact entities
|
||||
from app.core.restore_registry import RestoreConfig, get_restore_registry
|
||||
from app.models.contact import Contact
|
||||
get_restore_registry().register(RestoreConfig(
|
||||
entity_type="contact",
|
||||
model_class=Contact,
|
||||
restore_permission="contacts:write",
|
||||
excluded_fields=frozenset({
|
||||
"search_tsv", "embedding", "default_person_id", "admin_contactperson_id",
|
||||
}),
|
||||
))
|
||||
|
||||
# Register history hooks for Contact entities
|
||||
from app.core.history_hooks import register_history_hooks
|
||||
from app.core.hooks import get_hook_registry
|
||||
register_history_hooks(
|
||||
get_hook_registry(), "contact",
|
||||
"contact.after_create", "contact.after_update", "contact.after_delete",
|
||||
owner_tag="contacts",
|
||||
)
|
||||
|
||||
logger.info("Contacts plugin activated: restore + history registered")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Deactivate: unregister restore config + history hooks for Contact."""
|
||||
from app.core.restore_registry import get_restore_registry
|
||||
get_restore_registry().unregister("contact")
|
||||
|
||||
# Unregister history hooks — use owner_tag to remove only this plugin's hooks
|
||||
from app.core.hooks import get_hook_registry
|
||||
hook_reg = get_hook_registry()
|
||||
hook_reg.unregister_actions_by_owner("contact.after_create", "contacts")
|
||||
hook_reg.unregister_actions_by_owner("contact.after_update", "contacts")
|
||||
hook_reg.unregister_actions_by_owner("contact.after_delete", "contacts")
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
get_contract_registry().unregister(self.manifest.name)
|
||||
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
logger.info("Contacts plugin deactivated: restore + history unregistered")
|
||||
@@ -47,7 +47,8 @@ class UploadFileCommand(BaseCommand):
|
||||
self.folder_id = folder_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
@@ -127,9 +128,10 @@ class DeleteFileCommand(BaseCommand):
|
||||
self.file_id = file_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
fid = uuid.UUID(self.file_id)
|
||||
@@ -3,7 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
|
||||
|
||||
class DmsContract:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user