feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Check Cross-Plugin Imports / check (push) Has been cancelled

Phase 1: Contracts konsequent nutzen
- 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts)
- 4 bestehende contracts.py an zentrale ContractRegistry angepasst
- Alle 19 Plugins haben on_deactivate mit Contract-Unregister
- 0 echte problematische INTER-Plugin Imports

Phase 2: Hooks/Filters-System
- app/core/hooks.py (HookRegistry mit actions + filters)
- 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms)
- BasePlugin.on_deactivate meldet alle Hooks ab

Phase 3: Plugin-Isolation
- scripts/check_cross_plugin_imports.py (Linting-Regel)
- .github/workflows/check-cross-plugin-imports.yml (CI/CD)
- .pre-commit-cross-plugin.yaml (Pre-commit Hook)
- 155 Dateien geprueft, 0 Verstoesse

Phase 4: Plugin-Versioning
- app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release)
- migration_runner.py erweitert: run_migration_down, rollback_to_version
- manifest.py: min_app_version Feld
- registry.py: App-Version-Compatibility-Check bei Installation
- GET /api/v1/plugins/updates Endpoint

Phase 5: Marketplace-Vorbereitung
- app/plugins/signature.py (Ed25519 Signatur-Validierung)
- app/plugins/quarantine.py (Plugin-Quarantine mit Validierung)
- app/models/plugin_allowlist.py + Migration 0046
- manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price
- registry.py: discover_external(), discover_all()
- POST /api/v1/plugins/install-marketplace (deaktiviert)

Phase 6: Manifest-Anpassung
- manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung
- MANIFEST_SCHEMA_DOC aktualisiert
- Alle 19 Plugin-Manifeste aktualisiert
- Frontend PluginUiManifest Typ erweitert

Zusaetzliche Bug-Fixes:
- test_sample-Modul erstellt
- conftest.py Deadlock-Prevention
- SESSION_COOKIE_SECURE=true
- dump.rdb aus Git entfernt + .gitignore
- backup.py datetime.utcnow -> func.now()
- system_settings.py JSONB-Import nach oben
- tax.py Mapped[float] -> Mapped[Decimal]
- notification.py type_key-Laengen vereinheitlicht

Tests: 91 neue Tests, alle bestanden
This commit is contained in:
Agent Zero
2026-07-26 23:15:34 +02:00
parent 744d595cae
commit 98eb1d0d89
62 changed files with 3284 additions and 18 deletions
@@ -49,6 +49,10 @@ class AIAssistantPlugin(BasePlugin):
settings_pages=[
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.after_create", "contact.after_update"],
contract_version="1.0.0",
)
def __init__(self) -> None:
@@ -111,6 +115,10 @@ class AIAssistantPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Deactivate plugin: unregister participant and event subscriptions."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.contracts import (
@@ -0,0 +1,80 @@
"""Public contract for the ai_proactive plugin.
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.models import (
ContextLog,
ProactiveSettings,
ProactiveSuggestion,
)
from app.plugins.builtins.ai_proactive.services import (
get_active_suggestions,
get_sse_queue,
get_stats,
get_user_settings,
handle_context_change,
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,
)
class AiProactiveContract:
"""Public API surface for the ai_proactive plugin."""
contract_name = "ai_proactive"
# ─── models ───
ProactiveSuggestion = ProactiveSuggestion
ContextLog = ContextLog
ProactiveSettings = ProactiveSettings
# ─── services ───
handle_context_change = staticmethod(handle_context_change)
get_active_suggestions = staticmethod(get_active_suggestions)
get_sse_queue = staticmethod(get_sse_queue)
get_stats = staticmethod(get_stats)
get_user_settings = staticmethod(get_user_settings)
mark_dismissed = staticmethod(mark_dismissed)
push_suggestion = staticmethod(push_suggestion)
# ─── context_tools ───
register_context_tools = staticmethod(register_context_tools)
# ─── jobs ───
deep_analysis = staticmethod(deep_analysis)
heartbeat = staticmethod(heartbeat)
# ─── self-registration ───
_contract = AiProactiveContract()
get_contract_registry().register("ai_proactive", _contract)
__all__ = [
"AiProactiveContract",
"ProactiveSuggestion",
"ContextLog",
"ProactiveSettings",
"handle_context_change",
"get_active_suggestions",
"get_sse_queue",
"get_stats",
"get_user_settings",
"mark_dismissed",
"push_suggestion",
"register_context_tools",
"deep_analysis",
"heartbeat",
]
@@ -46,6 +46,10 @@ class AIProactivePlugin(BasePlugin):
settings_pages=[
FrontendSettingsPage(path='ai-proactive', label_key='settings.aiProactive', label='Proactive AI', component='@/pages/ProactiveAISettings', icon='Sparkles', order=61),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.after_create", "mail.after_send"],
contract_version="1.0.0",
)
def __init__(self) -> None:
@@ -85,6 +89,10 @@ class AIProactivePlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister tools, event listeners, and participant."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.contracts import (
@@ -0,0 +1,51 @@
"""Public contract for the ai_ui_control plugin.
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,
UICommandFeedback,
UICommandResponse,
UICommandStatus,
UICommandStatusResponse,
UICommandType,
)
class AiUiControlContract:
"""Public API surface for the ai_ui_control plugin."""
contract_name = "ai_ui_control"
# ─── websocket_manager ───
AIUIControlWSManager = AIUIControlWSManager
# ─── schemas ───
UICommand = UICommand
UICommandCreate = UICommandCreate
UICommandFeedback = UICommandFeedback
UICommandResponse = UICommandResponse
UICommandStatus = UICommandStatus
UICommandStatusResponse = UICommandStatusResponse
UICommandType = UICommandType
# ─── self-registration ───
_contract = AiUiControlContract()
get_contract_registry().register("ai_ui_control", _contract)
__all__ = [
"AiUiControlContract",
"AIUIControlWSManager",
"UICommand",
"UICommandType",
"UICommandStatus",
]
@@ -42,6 +42,9 @@ class AIUIControlPlugin(BasePlugin):
"ai_ui_control:write",
],
is_core=True,
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -55,6 +58,10 @@ class AIUIControlPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up the WebSocket manager."""
# Contract abmelden
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)
if service_container.has("ai_ui_control_ws"):
service_container.remove("ai_ui_control_ws")
@@ -0,0 +1,82 @@
"""Public contract for the automation plugin.
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.models import (
AgentDefinition,
AgentRun,
AgentVersion,
AutomationCronJob,
AutomationDefinition,
AutomationRun,
AutomationVersion,
)
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
class AutomationContract:
"""Public API surface for the automation plugin."""
contract_name = "automation"
# ─── models ───
AgentDefinition = AgentDefinition
Automation = AutomationDefinition
CronJob = AutomationCronJob
AgentRun = AgentRun
AgentVersion = AgentVersion
AutomationRun = AutomationRun
AutomationVersion = AutomationVersion
# ─── services ───
AgentService = AgentService
AutomationService = AutomationService
CronJobService = CronJobService
RunLogService = RunLogService
# ─── agent_runner ───
run_agent = staticmethod(run_agent)
# ─── execution_engine ───
run_automation = staticmethod(run_automation)
# ─── scheduler ───
calculate_next_run = staticmethod(calculate_next_run)
scheduler_tick = staticmethod(scheduler_tick)
# ─── agent_comm ───
send_agent_message = staticmethod(send_agent_message)
# ─── self-registration ───
_contract = AutomationContract()
get_contract_registry().register("automation", _contract)
__all__ = [
"AutomationContract",
"AgentDefinition",
"Automation",
"CronJob",
"AgentService",
"AutomationService",
"CronJobService",
"RunLogService",
]
@@ -162,6 +162,10 @@ class AutomationPlugin(BasePlugin):
plugin_name="automation",
),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.before_create", "contact.after_create"],
contract_version="1.0.0",
)
def __init__(self) -> None:
@@ -207,6 +211,10 @@ class AutomationPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up on deactivation."""
# Contract abmelden
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)
# Unregister agent communication tool
try:
@@ -2,17 +2,26 @@
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
class CalendarContract:
"""Public contract for the calendar plugin."""
contract_name = "calendar"
Calendar = Calendar
CalendarEntry = CalendarEntry
CalendarEntryLink = CalendarEntryLink
# ─── self-registration ───
_contract = CalendarContract()
get_contract_registry().register("calendar", _contract)
# Backward-compatible local accessor
_contract_instance: CalendarContract | None = None
@@ -21,3 +30,6 @@ def get_contract() -> CalendarContract:
if _contract_instance is None:
_contract_instance = CalendarContract()
return _contract_instance
__all__ = ["CalendarContract", "Calendar", "CalendarEntry", "CalendarEntryLink"]
+13
View File
@@ -50,4 +50,17 @@ class CalendarPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, permission='calendar:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["calendar.before_appointment", "calendar.after_appointment"],
contract_version="1.0.0",
)
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
+9
View File
@@ -410,6 +410,11 @@ async def create_entry(
)
assigned_to = _parse_uuid(body.assigned_to, "assigned_to") if body.assigned_to else None
# ── Hook: calendar.before_appointment (Action) ──
from app.core.hooks import do_action
await do_action("calendar.before_appointment", body=body, tenant_id=tenant_id, user_id=user_id, cal_id=cal_id)
entry = CalendarEntry(
tenant_id=tenant_id,
calendar_id=cal_id,
@@ -432,6 +437,10 @@ async def create_entry(
db.add(entry)
await db.flush()
# ── Hook: calendar.after_appointment (Action) ──
from app.core.hooks import do_action
await do_action("calendar.after_appointment", entry_id=str(entry.id), tenant_id=tenant_id, user_id=user_id)
# Schedule reminder ARQ job if reminder is set
if body.reminder:
try:
+12
View File
@@ -2,16 +2,25 @@
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.dms.models import File as DmsFile, Folder
class DmsContract:
"""Public contract for the DMS plugin."""
contract_name = "dms"
DmsFile = DmsFile
Folder = Folder
# ─── self-registration ───
_contract = DmsContract()
get_contract_registry().register("dms", _contract)
# Backward-compatible local accessor
_contract_instance: DmsContract | None = None
@@ -20,3 +29,6 @@ def get_contract() -> DmsContract:
if _contract_instance is None:
_contract_instance = DmsContract()
return _contract_instance
__all__ = ["DmsContract", "DmsFile", "Folder"]
+13
View File
@@ -42,4 +42,17 @@ class DmsPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.files', label='Dateien', component='@/components/contact/ContactFilesTab', icon='FolderOpen', order=40, permission='dms:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["dms.before_upload"],
contract_version="1.0.0",
)
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
+14 -3
View File
@@ -35,7 +35,10 @@ from app.plugins.builtins.dms.schemas import (
ShareRequest,
)
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
from app.plugins.builtins.permissions.models import Permission # TODO: migrate to contract
# Get Permission model from the permissions contract
_perms_contract = get_perms_contract()
Permission = _perms_contract.Permission
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
@@ -461,6 +464,14 @@ async def upload_file(
sha256.update(chunk)
yield chunk
# ── Hook: dms.before_upload (Filter) — can modify filename ──
from app.core.hooks import apply_filters
upload_data = {
"filename": file.filename or "unnamed",
"mime_type": file.content_type or "application/octet-stream",
}
upload_data = await apply_filters("dms.before_upload", upload_data)
# Create file record
file_id = uuid.uuid4()
storage_path = _file_storage_path(tenant_id, file_id)
@@ -471,12 +482,12 @@ async def upload_file(
content_hash = sha256.hexdigest()
mime_type = file.content_type or "application/octet-stream"
mime_type = upload_data["mime_type"]
dms_file = DmsFile(
id=file_id,
tenant_id=tenant_id,
name=file.filename or "unnamed",
name=upload_data["filename"],
folder_id=fid,
uploaded_by=user_id,
mime_type=mime_type,
@@ -0,0 +1,38 @@
"""Public contract for the entity_links plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
el = get_contract("entity_links")
if el:
# use el.EntityLink
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.entity_links.models import EntityLink
class EntityLinksContract:
"""Public API surface for the entity_links plugin."""
contract_name = "entity_links"
# ─── models (read-only for queries) ───
EntityLink = EntityLink
# ─── self-registration ───
_contract = EntityLinksContract()
get_contract_registry().register("entity_links", _contract)
__all__ = [
"EntityLinksContract",
"EntityLink",
]
+13 -1
View File
@@ -40,7 +40,10 @@ class EntityLinksPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.links', label='Verknüpfungen', component='@/components/contact/ContactLinksTab', icon='Link', order=60, permission='entity_links:read'),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
@@ -66,3 +69,12 @@ class EntityLinksPlugin(BasePlugin):
)
)
await session.commit()
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
@@ -0,0 +1,43 @@
"""Public contract for the forgejo_error_reporter plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
reporter = get_contract("forgejo_error_reporter")
if reporter:
await reporter.report_error_to_forgejo(entry)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.forgejo_error_reporter.models import ReportedError
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
class ForgejoErrorReporterContract:
"""Public API surface for the forgejo_error_reporter plugin."""
contract_name = "forgejo_error_reporter"
# ─── services ───
report_error_to_forgejo = staticmethod(report_error_to_forgejo)
# ─── models (read-only for queries) ───
ReportedError = ReportedError
# ─── self-registration ───
_contract = ForgejoErrorReporterContract()
get_contract_registry().register("forgejo_error_reporter", _contract)
__all__ = [
"ForgejoErrorReporterContract",
"report_error_to_forgejo",
"ReportedError",
]
@@ -35,7 +35,10 @@ class ForgejoErrorReporterPlugin(BasePlugin):
router_attr="router",
),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
def __init__(self) -> None:
super().__init__()
@@ -94,6 +97,10 @@ class ForgejoErrorReporterPlugin(BasePlugin):
async def on_deactivate(self, db: Any, service_container: Any, event_bus: Any) -> None:
"""Deactivate the plugin."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
self._enabled = False
await super().on_deactivate(db, service_container, event_bus)
logger.info("Forgejo Error Reporter deactivated")
@@ -55,6 +55,9 @@ class KommunikationPlugin(BasePlugin):
page_routes=[
FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -125,6 +128,10 @@ class KommunikationPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up registries."""
# Contract abmelden
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("Kommunikation plugin deactivated")
+8
View File
@@ -69,6 +69,10 @@ class MailPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.email', label='E-Mails', component='@/components/contact/ContactMailTab', icon='Mail', order=20, permission='mail:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["mail.before_send", "mail.after_send"],
contract_version="1.0.0",
)
async def on_activate(
@@ -100,6 +104,10 @@ class MailPlugin(BasePlugin):
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: stop auto-sync task + unregister events."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
if self._auto_sync_task is not None and not self._auto_sync_task.done():
self._auto_sync_task.cancel()
try:
+17
View File
@@ -1545,6 +1545,19 @@ async def send_mail_via_smtp(
filename=filename,
)
# ── Hook: mail.before_send (Filter) ──
from app.core.hooks import apply_filters
mail_data = {
"subject": subject,
"body_html": body_html,
"body_text": body_text,
"to_addrs": to_addrs,
"cc_addrs": cc_addrs,
"bcc_addrs": bcc_addrs,
"attachment_paths": attachment_paths,
}
mail_data = await apply_filters("mail.before_send", mail_data)
# Send via SMTP
password = await get_account_password(account)
try:
@@ -1559,6 +1572,10 @@ async def send_mail_via_smtp(
await smtp.send_message(msg, recipients=recipients)
await smtp.quit()
# ── Hook: mail.after_send (Action) ──
from app.core.hooks import do_action
await do_action("mail.after_send", mail_data, db=db, account=account, msg_id=msg_id)
# Store sent mail in Sent folder — use configured mapping if set,
# otherwise flexible lookup to handle different IMAP naming conventions
if account.sent_folder_imap_name:
@@ -0,0 +1,47 @@
"""Public contract for the mcp_client plugin.
Exposes the MCP client and server config model for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mcp_client.models import McpServerConfig
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.schemas import (
McpServerExecuteRequest,
McpServerExecuteResponse,
McpServerToolInfo,
McpServerToolsResponse,
)
class McpClientContract:
"""Public API surface for the mcp_client plugin."""
contract_name = "mcp_client"
# ─── models ───
McpServerConfig = McpServerConfig
# ─── client ───
McpClient = McpClient
# ─── schemas ───
McpServerExecuteRequest = McpServerExecuteRequest
McpServerExecuteResponse = McpServerExecuteResponse
McpServerToolInfo = McpServerToolInfo
McpServerToolsResponse = McpServerToolsResponse
# ─── self-registration ───
_contract = McpClientContract()
get_contract_registry().register("mcp_client", _contract)
__all__ = [
"McpClientContract",
"McpServerConfig",
"McpClient",
]
+13 -1
View File
@@ -29,4 +29,16 @@ class McpClientPlugin(BasePlugin):
"mcp-client:write",
"mcp-client:admin",
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
@@ -0,0 +1,57 @@
"""Public contract for the mcp_server plugin.
Exposes tool definitions and schemas for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mcp_server.tool_definitions import (
TOOL_DEFINITIONS,
TOOL_HANDLERS,
get_all_tool_names,
get_tool_definition,
)
from app.plugins.builtins.mcp_server.schemas import (
McpToolDefinition,
McpToolExecuteRequest,
McpToolExecuteResponse,
McpToolListResponse,
McpToolParameter,
McpServerConfig,
)
class McpServerContract:
"""Public API surface for the mcp_server plugin."""
contract_name = "mcp_server"
# ─── tool_definitions ───
ToolDefinitions = TOOL_DEFINITIONS
ToolHandlers = TOOL_HANDLERS
get_tool_definition = staticmethod(get_tool_definition)
get_all_tool_names = staticmethod(get_all_tool_names)
# ─── schemas ───
McpToolDefinition = McpToolDefinition
McpToolExecuteRequest = McpToolExecuteRequest
McpToolExecuteResponse = McpToolExecuteResponse
McpToolListResponse = McpToolListResponse
McpToolParameter = McpToolParameter
McpServerConfig = McpServerConfig
# ─── self-registration ───
_contract = McpServerContract()
get_contract_registry().register("mcp_server", _contract)
__all__ = [
"McpServerContract",
"ToolDefinitions",
"McpToolDefinition",
"McpToolParameter",
"McpServerConfig",
]
+13 -1
View File
@@ -28,4 +28,16 @@ class McpServerPlugin(BasePlugin):
"mcp:read",
"mcp:write",
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
+14 -1
View File
@@ -2,15 +2,25 @@
from __future__ import annotations
from app.plugins.builtins.permissions.models import Permission
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.permissions.models import Permission, ShareLink
class PermissionsContract:
"""Public contract for the permissions plugin."""
contract_name = "permissions"
Permission = Permission
ShareLink = ShareLink
# ─── self-registration ───
_contract = PermissionsContract()
get_contract_registry().register("permissions", _contract)
# Backward-compatible local accessor
_contract_instance: PermissionsContract | None = None
@@ -19,3 +29,6 @@ def get_contract() -> PermissionsContract:
if _contract_instance is None:
_contract_instance = PermissionsContract()
return _contract_instance
__all__ = ["PermissionsContract", "Permission", "ShareLink"]
@@ -36,4 +36,16 @@ class PermissionsPlugin(BasePlugin):
FrontendSettingsPage(path='users', label_key='settings.users', label='Users', component='@/pages/SettingsUsers', icon='Users', order=11, permission='permissions:read'),
FrontendSettingsPage(path='groups', label_key='settings.groups', label='Groups', component='@/pages/SettingsGroups', icon='UsersRound', order=12, permission='permissions:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
@@ -0,0 +1,57 @@
"""Public contract for the report_generator plugin.
Exposes models and PDF generation functions for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.report_generator.models import (
ReportInstance,
ReportTemplate,
)
from app.plugins.builtins.report_generator.pdf_generator import (
PRESET_META,
PRESET_TEMPLATES,
generate_pdf,
generate_pdf_from_template_content,
generate_preset_report,
generate_print_pdf,
get_preset_list,
render_template_file,
render_template_string,
)
class ReportGeneratorContract:
"""Public API surface for the report_generator plugin."""
contract_name = "report_generator"
# ─── models ───
ReportTemplate = ReportTemplate
ReportInstance = ReportInstance
# ─── pdf_generator ───
generate_pdf = staticmethod(generate_pdf)
generate_print_pdf = staticmethod(generate_print_pdf)
generate_preset_report = staticmethod(generate_preset_report)
generate_pdf_from_template_content = staticmethod(generate_pdf_from_template_content)
render_template_file = staticmethod(render_template_file)
render_template_string = staticmethod(render_template_string)
get_preset_list = staticmethod(get_preset_list)
PRESET_META = PRESET_META
PRESET_TEMPLATES = PRESET_TEMPLATES
# ─── self-registration ───
_contract = ReportGeneratorContract()
get_contract_registry().register("report_generator", _contract)
__all__ = [
"ReportGeneratorContract",
"ReportTemplate",
"ReportInstance",
]
@@ -32,4 +32,16 @@ class ReportGeneratorPlugin(BasePlugin):
page_routes=[
FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
@@ -0,0 +1,38 @@
"""Public contract for the system_notif plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
sn = get_contract("system_notif")
if sn:
# use sn.SystemParticipantHandler
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
class SystemNotifContract:
"""Public API surface for the system_notif plugin."""
contract_name = "system_notif"
# ─── participant handler ───
SystemParticipantHandler = SystemParticipantHandler
# ─── self-registration ───
_contract = SystemNotifContract()
get_contract_registry().register("system_notif", _contract)
__all__ = [
"SystemNotifContract",
"SystemParticipantHandler",
]
+7 -1
View File
@@ -43,7 +43,10 @@ class SystemNotifPlugin(BasePlugin):
settings_pages=[
FrontendSettingsPage(path='notifications', label_key='settings.notifications', label='Notifications', component='@/pages/SettingsNotifications', icon='Bell', order=40),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
def __init__(self) -> None:
super().__init__()
@@ -64,6 +67,9 @@ class SystemNotifPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister participant."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
get_participant_registry().unregister("system")
+40
View File
@@ -0,0 +1,40 @@
"""Public contract for the tags plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
tags = get_contract("tags")
if tags:
# use tags.Tag, tags.TagAssignment
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.tags.models import Tag, TagAssignment
class TagsContract:
"""Public API surface for the tags plugin."""
contract_name = "tags"
# ─── models (read-only for queries) ───
Tag = Tag
TagAssignment = TagAssignment
# ─── self-registration ───
_contract = TagsContract()
get_contract_registry().register("tags", _contract)
__all__ = [
"TagsContract",
"Tag",
"TagAssignment",
]
+13 -1
View File
@@ -34,4 +34,16 @@ class TagsPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.tags', label='Tags', component='@/components/contact/ContactTagsTab', icon='Tag', order=50, permission='tags:read'),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
+66
View File
@@ -0,0 +1,66 @@
"""Public contract for the tasks plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
tasks = get_contract("tasks")
if tasks:
await tasks.create_task(db, tenant_id, user_id, data)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.tasks.models import Task
from app.plugins.builtins.tasks.services import (
assign_task,
create_task,
delete_task,
get_due_tasks,
get_task,
list_tasks,
update_task,
update_task_status,
)
class TasksContract:
"""Public API surface for the tasks plugin."""
contract_name = "tasks"
# ─── services ───
list_tasks = staticmethod(list_tasks)
get_task = staticmethod(get_task)
create_task = staticmethod(create_task)
update_task = staticmethod(update_task)
delete_task = staticmethod(delete_task)
assign_task = staticmethod(assign_task)
update_task_status = staticmethod(update_task_status)
get_due_tasks = staticmethod(get_due_tasks)
# ─── models (read-only for queries) ───
Task = Task
# ─── self-registration ───
_contract = TasksContract()
get_contract_registry().register("tasks", _contract)
__all__ = [
"TasksContract",
"Task",
"list_tasks",
"get_task",
"create_task",
"update_task",
"delete_task",
"assign_task",
"update_task_status",
"get_due_tasks",
]
+14 -1
View File
@@ -62,4 +62,17 @@ class TasksPlugin(BasePlugin):
plugin_name="tasks",
),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.after_create"],
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
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)
+7 -1
View File
@@ -21,7 +21,10 @@ class TestSamplePlugin(BasePlugin):
events=["contact.created"],
migrations=["0001_test_plugin.sql"],
permissions=[],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
def __init__(self) -> None:
super().__init__()
@@ -39,6 +42,9 @@ class TestSamplePlugin(BasePlugin):
await super().on_activate(db, service_container, event_bus)
async def on_deactivate(self, db, service_container, event_bus) -> None:
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
self.deactivate_called = True
await super().on_deactivate(db, service_container, event_bus)
@@ -0,0 +1,38 @@
"""Public contract for the test_sample plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
ts = get_contract("test_sample")
if ts:
# use ts.TestSamplePlugin
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.test_sample import TestSamplePlugin
class TestSampleContract:
"""Public API surface for the test_sample plugin."""
contract_name = "test_sample"
# ─── plugin class ───
TestSamplePlugin = TestSamplePlugin
# ─── self-registration ───
_contract = TestSampleContract()
get_contract_registry().register("test_sample", _contract)
__all__ = [
"TestSampleContract",
"TestSamplePlugin",
]
@@ -2,6 +2,7 @@
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.search_engine import hybrid_search
@@ -9,10 +10,18 @@ from app.plugins.builtins.unified_search.search_engine import hybrid_search
class UnifiedSearchContract:
"""Public contract for the unified_search plugin."""
contract_name = "unified_search"
generate_embedding = staticmethod(generate_embedding)
hybrid_search = staticmethod(hybrid_search)
# ─── self-registration ───
_contract = UnifiedSearchContract()
get_contract_registry().register("unified_search", _contract)
# Backward-compatible local accessor
_contract_instance: UnifiedSearchContract | None = None
@@ -21,3 +30,6 @@ def get_contract() -> UnifiedSearchContract:
if _contract_instance is None:
_contract_instance = UnifiedSearchContract()
return _contract_instance
__all__ = ["UnifiedSearchContract", "generate_embedding", "hybrid_search"]
@@ -43,6 +43,9 @@ class UnifiedSearchPlugin(BasePlugin):
page_routes=[
FrontendPageRoute(path='/search', component='@/pages/GlobalSearchResults', protected=True),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -59,6 +62,9 @@ class UnifiedSearchPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clear provider registry on deactivation."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
from app.plugins.builtins.unified_search.provider_registry import (
get_search_registry,
)