Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""Public contract for the ai_assistant plugin.
|
||||
|
||||
Exposes only the symbols that other builtins plugins need:
|
||||
- Tool registry (register, unregister, list tools)
|
||||
- get_default_provider (for LLM provider lookup)
|
||||
|
||||
Importers should use::
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
ai = get_contract("ai_assistant")
|
||||
if ai:
|
||||
registry = ai.get_tool_registry()
|
||||
registry.register("my_tool", ...)
|
||||
|
||||
instead of importing from internal modules directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.ai_assistant.services import get_default_provider
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||
AITool,
|
||||
ToolRegistry,
|
||||
get_tool_registry,
|
||||
)
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class AIAssistantContract:
|
||||
"""Public API surface for the ai_assistant plugin.
|
||||
|
||||
Exposes the tool registry and the default-provider lookup so that
|
||||
other plugins can register AI tools and obtain the tenant's default
|
||||
LLM provider without importing internal modules.
|
||||
"""
|
||||
|
||||
contract_name = "ai_assistant"
|
||||
|
||||
# ─── tool registry ───
|
||||
get_tool_registry = staticmethod(get_tool_registry)
|
||||
ToolRegistry = ToolRegistry
|
||||
AITool = AITool
|
||||
|
||||
# ─── provider lookup ───
|
||||
get_default_provider = staticmethod(get_default_provider)
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
_contract = AIAssistantContract()
|
||||
get_contract_registry().register("ai_assistant", _contract)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AIAssistantContract",
|
||||
"AITool",
|
||||
"ToolRegistry",
|
||||
"get_tool_registry",
|
||||
"get_default_provider",
|
||||
]
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
import litellm
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
|
||||
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -169,7 +169,7 @@ class AIParticipantHandler(ParticipantHandler):
|
||||
current_message: dict[str, Any],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Build a messages array from the conversation history for the LLM."""
|
||||
from app.plugins.builtins.kommunikation.services import get_messages
|
||||
from app.plugins.builtins.kommunikation.contracts import get_messages
|
||||
|
||||
messages: list[dict[str, str]] = []
|
||||
|
||||
@@ -232,7 +232,7 @@ class AIParticipantHandler(ParticipantHandler):
|
||||
|
||||
# Load conversation
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.services import get_conversation
|
||||
from app.plugins.builtins.kommunikation.contracts import get_conversation
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
# We need a user_id to load the conversation — use the sender_id from payload
|
||||
@@ -249,7 +249,7 @@ class AIParticipantHandler(ParticipantHandler):
|
||||
return
|
||||
|
||||
# Parse mentions from message content
|
||||
from app.plugins.builtins.kommunikation.services import parse_mentions
|
||||
from app.plugins.builtins.kommunikation.contracts import parse_mentions
|
||||
|
||||
mentions = parse_mentions(message_content)
|
||||
|
||||
@@ -265,7 +265,7 @@ class AIParticipantHandler(ParticipantHandler):
|
||||
|
||||
# If we got a response, send it to the conversation
|
||||
if response_messages:
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
from app.plugins.builtins.kommunikation.contracts import send_message
|
||||
|
||||
for resp_msg in response_messages:
|
||||
await send_message(
|
||||
|
||||
@@ -80,7 +80,7 @@ class AIAssistantPlugin(BasePlugin):
|
||||
from app.plugins.builtins.ai_assistant.participant_handler import (
|
||||
AIParticipantHandler,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
from app.plugins.builtins.kommunikation.contracts import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
@@ -113,7 +113,7 @@ class AIAssistantPlugin(BasePlugin):
|
||||
"""Deactivate plugin: unregister participant and event subscriptions."""
|
||||
# Unregister from participant registry
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
from app.plugins.builtins.kommunikation.contracts import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ 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.models import Mail
|
||||
from app.plugins.builtins.mail.contracts import Mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,9 +101,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.search_engine import (
|
||||
find_similar_all_types,
|
||||
)
|
||||
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
|
||||
|
||||
similar = await find_similar_all_types(
|
||||
db, entity_type, entity_id, tenant_id, limit=limit
|
||||
@@ -163,10 +163,10 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.plugins.builtins.calendar.models import (
|
||||
CalendarEntry,
|
||||
CalendarEntryLink,
|
||||
)
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
CalendarEntryLink = _cal.CalendarEntryLink
|
||||
|
||||
db, tenant_id, _ = await _get_db_and_tenant(context)
|
||||
entity_type = arguments["entity_type"]
|
||||
@@ -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.search_engine import hybrid_search
|
||||
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
|
||||
_search = get_search_contract()
|
||||
hybrid_search = _search.hybrid_search
|
||||
|
||||
db, tenant_id, _ = await _get_db_and_tenant(context)
|
||||
query = arguments["query"]
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.plugins.builtins.ai_proactive.services import (
|
||||
get_user_settings,
|
||||
push_suggestion,
|
||||
)
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
from app.plugins.builtins.mail.contracts import Mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -175,9 +175,10 @@ async def deep_analysis(
|
||||
|
||||
# Similar entities via unified_search
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.search_engine import (
|
||||
find_similar_all_types,
|
||||
)
|
||||
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(
|
||||
db, entity_type, eid, tid, limit=5
|
||||
@@ -335,7 +336,7 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
|
||||
try:
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.services import (
|
||||
from app.plugins.builtins.kommunikation.contracts import (
|
||||
create_plugin_room,
|
||||
send_message,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
|
||||
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -173,7 +173,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
|
||||
|
||||
# Load conversation
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.services import get_conversation
|
||||
from app.plugins.builtins.kommunikation.contracts import get_conversation
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
if not sender_id_str:
|
||||
@@ -188,7 +188,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
|
||||
return
|
||||
|
||||
# Parse mentions from message content
|
||||
from app.plugins.builtins.kommunikation.services import parse_mentions
|
||||
from app.plugins.builtins.kommunikation.contracts import parse_mentions
|
||||
|
||||
mentions = parse_mentions(message_content)
|
||||
|
||||
@@ -204,7 +204,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
|
||||
|
||||
# If we got a response, send it to the conversation
|
||||
if response_messages:
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
from app.plugins.builtins.kommunikation.contracts import send_message
|
||||
|
||||
for resp_msg in response_messages:
|
||||
await send_message(
|
||||
|
||||
@@ -59,7 +59,7 @@ class AIProactivePlugin(BasePlugin):
|
||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||
register_context_tools,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||
from app.plugins.builtins.ai_assistant.contracts import (
|
||||
get_tool_registry,
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ class AIProactivePlugin(BasePlugin):
|
||||
from app.plugins.builtins.ai_proactive.participant_handler import (
|
||||
AIProactiveParticipantHandler,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
from app.plugins.builtins.kommunikation.contracts import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ class AIProactivePlugin(BasePlugin):
|
||||
"""Unregister tools, event listeners, and participant."""
|
||||
# Unregister from participant registry
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
from app.plugins.builtins.kommunikation.contracts import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ class AIProactivePlugin(BasePlugin):
|
||||
self._proactive_handler = None
|
||||
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||
from app.plugins.builtins.ai_assistant.contracts import (
|
||||
get_tool_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ async def _get_llm_api_key(db: AsyncSession, tenant_id: uuid.UUID) -> tuple[str
|
||||
Returns (api_key, base_url, provider_type).
|
||||
"""
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.services import get_default_provider
|
||||
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
|
||||
@@ -167,7 +167,7 @@ async def gather_context(
|
||||
context["contact"] = _serialize_row(contact) if contact else None
|
||||
|
||||
# Last 10 mails
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
from app.plugins.builtins.mail.contracts import Mail
|
||||
|
||||
mail_result = await db.execute(
|
||||
select(Mail)
|
||||
@@ -203,7 +203,10 @@ async def gather_context(
|
||||
context["companies"] = companies
|
||||
|
||||
# Upcoming calendar events
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
CalendarEntryLink = _cal.CalendarEntryLink
|
||||
|
||||
now = datetime.now(UTC)
|
||||
event_result = await db.execute(
|
||||
@@ -229,7 +232,7 @@ async def gather_context(
|
||||
context["activities"] = [_serialize_row(a) for a in audit_result.scalars().all()]
|
||||
|
||||
elif entity_type == "mail":
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
from app.plugins.builtins.mail.contracts import Mail
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail)
|
||||
@@ -303,7 +306,7 @@ async def gather_context(
|
||||
context["contacts"] = contacts
|
||||
|
||||
# Mails for this contact
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
from app.plugins.builtins.mail.contracts import Mail
|
||||
|
||||
mail_result = await db.execute(
|
||||
select(Mail)
|
||||
@@ -315,7 +318,10 @@ async def gather_context(
|
||||
context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()]
|
||||
|
||||
# Upcoming events
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
CalendarEntryLink = _cal.CalendarEntryLink
|
||||
|
||||
now = datetime.now(UTC)
|
||||
event_result = await db.execute(
|
||||
@@ -356,9 +362,9 @@ async def gather_context(
|
||||
|
||||
# Semantically similar entities via unified_search
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.search_engine import (
|
||||
find_similar_all_types,
|
||||
)
|
||||
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
|
||||
|
||||
context["similar"] = await find_similar_all_types(
|
||||
db, entity_type, entity_id, tenant_id, limit=3
|
||||
|
||||
@@ -55,8 +55,8 @@ async def send_agent_message(
|
||||
|
||||
# 2. Create a kommunikation message in a dedicated agent room
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.models import Message, Room
|
||||
from app.plugins.builtins.kommunikation.services import RoomService
|
||||
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}"
|
||||
@@ -129,7 +129,7 @@ async def send_agent_message(
|
||||
|
||||
def register_agent_comm_tool():
|
||||
"""Register the send_agent_message tool in the global tool registry."""
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
|
||||
@@ -188,7 +188,7 @@ def register_agent_comm_tool():
|
||||
|
||||
def unregister_agent_comm_tool():
|
||||
"""Unregister the send_agent_message tool."""
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
registry.unregister("send_agent_message")
|
||||
|
||||
@@ -149,7 +149,7 @@ async def list_tools(
|
||||
):
|
||||
"""List available tools from the tool registry."""
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||
from app.plugins.builtins.ai_assistant.contracts import (
|
||||
get_tool_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ async def run_agent(
|
||||
|
||||
# Execute tool calls if LLM returned function calls
|
||||
if hasattr(response.choices[0].message, "tool_calls") and response.choices[0].message.tool_calls:
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
tool_call_count: dict[str, int] = {}
|
||||
|
||||
@@ -137,7 +137,7 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.exception("Failed to register agent communication tool")
|
||||
# Register MiniApps from manifest
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
registry = MiniAppRegistry()
|
||||
for miniapp in self.manifest.miniapps:
|
||||
registry.register(
|
||||
@@ -170,7 +170,7 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.exception("Failed to unregister agent communication tool")
|
||||
# Unregister MiniApps
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
registry = MiniAppRegistry()
|
||||
registry.unregister_plugin(self.manifest.name)
|
||||
logger.info("Unregistered MiniApps for plugin '%s'", self.manifest.name)
|
||||
|
||||
@@ -179,7 +179,7 @@ async def list_miniapps(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""List custom MiniApps from plugin config."""
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
registry = MiniAppRegistry()
|
||||
items = registry.list_apps()
|
||||
return {"items": items, "total": len(items)}
|
||||
@@ -196,7 +196,7 @@ async def create_miniapp(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create a custom MiniApp definition."""
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
registry = MiniAppRegistry()
|
||||
registry.register(
|
||||
app_id=data.app_id,
|
||||
@@ -225,7 +225,7 @@ async def delete_miniapp(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a custom MiniApp definition."""
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
registry = MiniAppRegistry()
|
||||
registry.unregister(app_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Calendar plugin contract — public interface for cross-plugin access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
|
||||
|
||||
|
||||
class CalendarContract:
|
||||
"""Public contract for the calendar plugin."""
|
||||
|
||||
Calendar = Calendar
|
||||
CalendarEntry = CalendarEntry
|
||||
CalendarEntryLink = CalendarEntryLink
|
||||
|
||||
|
||||
_contract_instance: CalendarContract | None = None
|
||||
|
||||
|
||||
def get_contract() -> CalendarContract:
|
||||
global _contract_instance
|
||||
if _contract_instance is None:
|
||||
_contract_instance = CalendarContract()
|
||||
return _contract_instance
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Central Contract Registry for inter-plugin communication.
|
||||
|
||||
Instead of plugins importing directly from each other's internal modules
|
||||
(e.g. ``from app.plugins.builtins.kommunikation.services import send_message``),
|
||||
plugins expose a **contract** module (``contracts.py``) that re-exports only
|
||||
the public symbols other plugins need.
|
||||
|
||||
Usage pattern::
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
komm_contract = get_contract("kommunikation")
|
||||
if komm_contract:
|
||||
await komm_contract.send_message(db, ...)
|
||||
|
||||
This breaks the tight coupling: plugins depend on the contract surface area,
|
||||
not on internal module paths. If a plugin is absent, ``get_contract``
|
||||
returns ``None`` and the caller can gracefully skip the feature.
|
||||
|
||||
Contracts are registered lazily on first access (import of the plugin's
|
||||
``contracts`` module). A plugin may also register itself explicitly during
|
||||
``on_activate``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContractError(Exception):
|
||||
"""Raised when a contract cannot be fulfilled."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PluginContract(Protocol):
|
||||
"""Marker protocol for plugin contract objects.
|
||||
|
||||
A contract can be any module or object that a plugin exposes via its
|
||||
``contracts.py``. The registry stores whatever the plugin registers.
|
||||
"""
|
||||
|
||||
contract_name: str
|
||||
|
||||
|
||||
class ContractRegistry:
|
||||
"""Thread-safe registry for plugin contracts.
|
||||
|
||||
A contract is identified by its plugin slug (e.g. ``"kommunikation"``).
|
||||
"""
|
||||
|
||||
_instance: ContractRegistry | None = None
|
||||
|
||||
def __new__(cls) -> ContractRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._contracts: dict[str, Any] = {}
|
||||
cls._instance._loaded: set[str] = set()
|
||||
return cls._instance
|
||||
|
||||
# ─── registration ───
|
||||
|
||||
def register(self, plugin_name: str, contract: Any) -> None:
|
||||
"""Register or replace a contract for a plugin."""
|
||||
self._contracts[plugin_name] = contract
|
||||
self._loaded.add(plugin_name)
|
||||
logger.debug("Contract registered for plugin '%s'", plugin_name)
|
||||
|
||||
def unregister(self, plugin_name: str) -> None:
|
||||
"""Remove a contract (e.g. when the plugin is deactivated)."""
|
||||
self._contracts.pop(plugin_name, None)
|
||||
self._loaded.discard(plugin_name)
|
||||
|
||||
# ─── lookup ───
|
||||
|
||||
def get_contract(self, plugin_name: str) -> Any | None:
|
||||
"""Return the contract for *plugin_name* or ``None``.
|
||||
|
||||
On first access the registry attempts to lazy-load the plugin's
|
||||
``contracts`` module, which will register itself on import.
|
||||
"""
|
||||
if plugin_name in self._contracts:
|
||||
return self._contracts[plugin_name]
|
||||
|
||||
if plugin_name not in self._loaded:
|
||||
self._try_lazy_load(plugin_name)
|
||||
|
||||
return self._contracts.get(plugin_name)
|
||||
|
||||
def require_contract(self, plugin_name: str) -> Any:
|
||||
"""Like :meth:`get_contract` but raise if unavailable."""
|
||||
contract = self.get_contract(plugin_name)
|
||||
if contract is None:
|
||||
raise ContractError(
|
||||
f"Plugin '{plugin_name}' has no registered contract. "
|
||||
"Ensure the plugin is installed and activated."
|
||||
)
|
||||
return contract
|
||||
|
||||
def list_available(self) -> list[str]:
|
||||
"""Return slugs of all plugins with registered contracts."""
|
||||
return sorted(self._contracts.keys())
|
||||
|
||||
# ─── internals ───
|
||||
|
||||
def _try_lazy_load(self, plugin_name: str) -> None:
|
||||
"""Attempt to import ``app.plugins.builtins.<plugin>.contracts``.
|
||||
|
||||
If the module is already in ``sys.modules`` (e.g. after a registry
|
||||
reset in tests), reload it so the registration code re-executes.
|
||||
"""
|
||||
import sys
|
||||
|
||||
self._loaded.add(plugin_name) # mark as attempted even on failure
|
||||
module_path = f"app.plugins.builtins.{plugin_name}.contracts"
|
||||
try:
|
||||
if module_path in sys.modules:
|
||||
importlib.reload(sys.modules[module_path])
|
||||
else:
|
||||
importlib.import_module(module_path)
|
||||
logger.debug("Lazy-loaded contract module '%s'", module_path)
|
||||
except ImportError:
|
||||
# Plugin not installed or has no contracts module — fine.
|
||||
logger.debug("No contract module for '%s'", plugin_name)
|
||||
except Exception:
|
||||
logger.exception("Failed to load contract module '%s'", module_path)
|
||||
|
||||
def _reset_for_testing(self) -> None:
|
||||
"""Clear all state — for unit tests only."""
|
||||
self._contracts.clear()
|
||||
self._loaded.clear()
|
||||
|
||||
|
||||
# ─── module-level helpers ───
|
||||
|
||||
def get_contract_registry() -> ContractRegistry:
|
||||
"""Return the global :class:`ContractRegistry` singleton."""
|
||||
return ContractRegistry()
|
||||
|
||||
|
||||
def get_contract(plugin_name: str) -> Any | None:
|
||||
"""Convenience wrapper: ``get_contract_registry().get_contract(name)``."""
|
||||
return get_contract_registry().get_contract(plugin_name)
|
||||
|
||||
|
||||
def require_contract(plugin_name: str) -> Any:
|
||||
"""Convenience wrapper that raises if the contract is missing."""
|
||||
return get_contract_registry().require_contract(plugin_name)
|
||||
|
||||
|
||||
def reset_contract_registry_for_testing() -> ContractRegistry:
|
||||
"""Return a fresh singleton — for unit tests only."""
|
||||
reg = get_contract_registry()
|
||||
reg._reset_for_testing()
|
||||
return reg
|
||||
@@ -0,0 +1,22 @@
|
||||
"""DMS plugin contract — public interface for cross-plugin access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
|
||||
|
||||
class DmsContract:
|
||||
"""Public contract for the DMS plugin."""
|
||||
|
||||
DmsFile = DmsFile
|
||||
Folder = Folder
|
||||
|
||||
|
||||
_contract_instance: DmsContract | None = None
|
||||
|
||||
|
||||
def get_contract() -> DmsContract:
|
||||
global _contract_instance
|
||||
if _contract_instance is None:
|
||||
_contract_instance = DmsContract()
|
||||
return _contract_instance
|
||||
@@ -64,4 +64,5 @@ class File(Base, TenantMixin):
|
||||
mime_type: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -34,7 +34,8 @@ from app.plugins.builtins.dms.schemas import (
|
||||
ShareRemoveRequest,
|
||||
ShareRequest,
|
||||
)
|
||||
from app.plugins.builtins.permissions.models import Permission
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
|
||||
|
||||
@@ -68,6 +69,28 @@ def _get_file_extension(filename: str) -> str:
|
||||
return os.path.splitext(filename)[1].lower()
|
||||
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename for safe use in Content-Disposition headers."""
|
||||
import re
|
||||
# Extract basename only (strip any path components)
|
||||
safe = os.path.basename(filename.replace('\\', '/'))
|
||||
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
|
||||
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
|
||||
# Collapse consecutive dots (path traversal prevention)
|
||||
safe = re.sub(r'\.{2,}', '_', safe)
|
||||
# Collapse multiple spaces
|
||||
safe = re.sub(r' {2,}', ' ', safe)
|
||||
# Strip leading dots and whitespace
|
||||
safe = safe.lstrip('.').strip()
|
||||
# Limit length
|
||||
if len(safe) > 200:
|
||||
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
|
||||
safe = name[:200] + ('.' + ext if ext else '')
|
||||
return safe or 'file'
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming uploads
|
||||
|
||||
# ─── Folders ───
|
||||
|
||||
|
||||
@@ -418,14 +441,26 @@ async def upload_file(
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
# Stream file in chunks — avoid loading entire file into RAM
|
||||
import hashlib
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
chunks: list[bytes] = []
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
|
||||
)
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
file_size += len(chunk)
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
|
||||
)
|
||||
sha256.update(chunk)
|
||||
chunks.append(chunk)
|
||||
|
||||
content_hash = sha256.hexdigest()
|
||||
|
||||
# Create file record
|
||||
file_id = uuid.uuid4()
|
||||
@@ -433,7 +468,8 @@ async def upload_file(
|
||||
|
||||
# Save file via storage backend
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, content)
|
||||
await storage.save(storage_path, b"".join(chunks))
|
||||
del chunks # Free memory
|
||||
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
@@ -446,6 +482,7 @@ async def upload_file(
|
||||
mime_type=mime_type,
|
||||
size_bytes=file_size,
|
||||
storage_path=storage_path,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
db.add(dms_file)
|
||||
await db.flush()
|
||||
@@ -457,7 +494,7 @@ async def upload_file(
|
||||
"uploaded_by": str(dms_file.uploaded_by),
|
||||
"mime_type": dms_file.mime_type,
|
||||
"size_bytes": dms_file.size_bytes,
|
||||
"storage_path": dms_file.storage_path,
|
||||
"content_hash": dms_file.content_hash,
|
||||
"deleted_at": None,
|
||||
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
||||
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
||||
@@ -492,7 +529,7 @@ async def get_file(
|
||||
"uploaded_by": str(dms_file.uploaded_by),
|
||||
"mime_type": dms_file.mime_type,
|
||||
"size_bytes": dms_file.size_bytes,
|
||||
"storage_path": dms_file.storage_path,
|
||||
"content_hash": dms_file.content_hash,
|
||||
"deleted_at": None,
|
||||
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
||||
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
||||
@@ -628,7 +665,7 @@ async def update_file(
|
||||
"uploaded_by": str(dms_file.uploaded_by),
|
||||
"mime_type": dms_file.mime_type,
|
||||
"size_bytes": dms_file.size_bytes,
|
||||
"storage_path": dms_file.storage_path,
|
||||
"content_hash": dms_file.content_hash,
|
||||
"deleted_at": None,
|
||||
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
||||
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
||||
@@ -695,7 +732,7 @@ async def restore_file(
|
||||
"uploaded_by": str(dms_file.uploaded_by),
|
||||
"mime_type": dms_file.mime_type,
|
||||
"size_bytes": dms_file.size_bytes,
|
||||
"storage_path": dms_file.storage_path,
|
||||
"content_hash": dms_file.content_hash,
|
||||
"deleted_at": None,
|
||||
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
||||
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
||||
|
||||
@@ -34,7 +34,7 @@ class FileMetadataResponse(BaseModel):
|
||||
uploaded_by: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
storage_path: str
|
||||
content_hash: str | None = None
|
||||
deleted_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Public contract for the kommunikation plugin.
|
||||
|
||||
Exposes only the symbols that other builtins plugins need.
|
||||
Importers should use::
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
komm = get_contract("kommunikation")
|
||||
if komm:
|
||||
await komm.send_message(db, tenant_id, ...)
|
||||
|
||||
instead of importing from internal modules directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import (
|
||||
MiniAppDef,
|
||||
MiniAppRegistry,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.models import (
|
||||
CommConversation,
|
||||
CommMessage,
|
||||
CommParticipant,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
ParticipantHandler,
|
||||
get_participant_registry,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.services import (
|
||||
create_plugin_room,
|
||||
get_conversation,
|
||||
get_messages,
|
||||
parse_mentions,
|
||||
send_message,
|
||||
)
|
||||
|
||||
|
||||
class KommunikationContract:
|
||||
"""Public API surface for the kommunikation plugin.
|
||||
|
||||
Exposes functions, classes, and model types that other plugins are
|
||||
allowed to use. Internal implementation details remain private to
|
||||
the plugin package.
|
||||
"""
|
||||
|
||||
contract_name = "kommunikation"
|
||||
|
||||
# ─── services ───
|
||||
parse_mentions = staticmethod(parse_mentions)
|
||||
get_conversation = staticmethod(get_conversation)
|
||||
get_messages = staticmethod(get_messages)
|
||||
send_message = staticmethod(send_message)
|
||||
create_plugin_room = staticmethod(create_plugin_room)
|
||||
|
||||
# ─── participant registry ───
|
||||
get_participant_registry = staticmethod(get_participant_registry)
|
||||
ParticipantHandler = ParticipantHandler
|
||||
|
||||
# ─── mini-app registry ───
|
||||
MiniAppRegistry = MiniAppRegistry
|
||||
MiniAppDef = MiniAppDef
|
||||
|
||||
# ─── models (read-only for queries) ───
|
||||
CommConversation = CommConversation
|
||||
CommMessage = CommMessage
|
||||
CommParticipant = CommParticipant
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
_contract = KommunikationContract()
|
||||
get_contract_registry().register("kommunikation", _contract)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KommunikationContract",
|
||||
"ParticipantHandler",
|
||||
"get_participant_registry",
|
||||
"MiniAppRegistry",
|
||||
"MiniAppDef",
|
||||
"parse_mentions",
|
||||
"get_conversation",
|
||||
"get_messages",
|
||||
"send_message",
|
||||
"create_plugin_room",
|
||||
"CommConversation",
|
||||
"CommMessage",
|
||||
"CommParticipant",
|
||||
]
|
||||
@@ -13,7 +13,10 @@ from fastapi import UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
from app.plugins.builtins.dms.contracts import get_contract as get_dms_contract
|
||||
_dms = get_dms_contract()
|
||||
DmsFile = _dms.DmsFile
|
||||
Folder = _dms.Folder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ from app.plugins.builtins.kommunikation.models import (
|
||||
CommMessage,
|
||||
CommParticipant,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.embedding import generate_embedding
|
||||
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
|
||||
_search = get_search_contract()
|
||||
generate_embedding = _search.generate_embedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Public contract for the mail plugin.
|
||||
|
||||
Exposes only the symbols that other builtins plugins need.
|
||||
Currently the only cross-plugin consumer is ai_proactive, which imports
|
||||
the ``Mail`` model for querying recent emails by contact.
|
||||
|
||||
Importers should use::
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
mail = get_contract("mail")
|
||||
if mail:
|
||||
result = await db.execute(select(mail.Mail).where(...))
|
||||
|
||||
instead of importing from internal modules directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
|
||||
class MailContract:
|
||||
"""Public API surface for the mail plugin.
|
||||
|
||||
Exposes the ``Mail`` ORM model so that other plugins can query the
|
||||
mails table without importing from ``mail.models`` directly.
|
||||
"""
|
||||
|
||||
contract_name = "mail"
|
||||
|
||||
# ─── models ───
|
||||
Mail = Mail
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
_contract = MailContract()
|
||||
get_contract_registry().register("mail", _contract)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MailContract",
|
||||
"Mail",
|
||||
]
|
||||
@@ -1477,7 +1477,10 @@ async def create_event_from_mail(
|
||||
account = await _get_account(db, mail.account_id, tenant_id, user_id)
|
||||
await _check_delegate_access(db, account, user_id, "write")
|
||||
try:
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
Calendar = _cal.Calendar
|
||||
CalendarEntry = _cal.CalendarEntry
|
||||
except ImportError:
|
||||
return {"created": False, "error": "Calendar plugin not available"}
|
||||
cal_id = _parse_uuid(data.calendar_id, "calendar_id")
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
from app.plugins.builtins.mcp_client.client import McpClient
|
||||
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Permissions plugin contract — public interface for cross-plugin access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.permissions.models import Permission
|
||||
|
||||
|
||||
class PermissionsContract:
|
||||
"""Public contract for the permissions plugin."""
|
||||
|
||||
Permission = Permission
|
||||
|
||||
|
||||
_contract_instance: PermissionsContract | None = None
|
||||
|
||||
|
||||
def get_contract() -> PermissionsContract:
|
||||
global _contract_instance
|
||||
if _contract_instance is None:
|
||||
_contract_instance = PermissionsContract()
|
||||
return _contract_instance
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
|
||||
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class SystemNotifPlugin(BasePlugin):
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
|
||||
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
|
||||
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
|
||||
|
||||
self._system_handler = SystemParticipantHandler(service_container)
|
||||
registry = get_participant_registry()
|
||||
@@ -64,7 +64,7 @@ class SystemNotifPlugin(BasePlugin):
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Unregister participant."""
|
||||
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
|
||||
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
|
||||
|
||||
get_participant_registry().unregister("system")
|
||||
self._system_handler = None
|
||||
@@ -132,7 +132,7 @@ class SystemNotifPlugin(BasePlugin):
|
||||
import uuid
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.services import create_plugin_room, send_message
|
||||
from app.plugins.builtins.kommunikation.contracts import create_plugin_room, send_message
|
||||
|
||||
tenant_id_str = payload.get("tenant_id")
|
||||
user_id_str = payload.get("user_id")
|
||||
@@ -201,7 +201,7 @@ class SystemNotifPlugin(BasePlugin):
|
||||
|
||||
# Find the System room conversation
|
||||
from sqlalchemy import select
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation, CommParticipant
|
||||
from app.plugins.builtins.kommunikation.contracts import CommConversation, CommParticipant
|
||||
|
||||
result = await db.execute(
|
||||
select(CommConversation).where(
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the plugin contract registry and contract modules.
|
||||
|
||||
Verifies that:
|
||||
1. ContractRegistry singleton works correctly
|
||||
2. Contracts for kommunikation, ai_assistant, and mail register and resolve
|
||||
3. Contract objects expose the expected public symbols
|
||||
4. Lazy loading works for unregistered plugins
|
||||
5. ContractError is raised for missing contracts via require_contract
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
from app.plugins.builtins.contracts import (
|
||||
ContractError,
|
||||
ContractRegistry,
|
||||
get_contract,
|
||||
get_contract_registry,
|
||||
reset_contract_registry_for_testing,
|
||||
)
|
||||
|
||||
|
||||
def _reload_contracts(plugin_name: str):
|
||||
"""Force re-import of a plugin's contracts module so it re-registers."""
|
||||
module_path = f"app.plugins.builtins.{plugin_name}.contracts"
|
||||
mod = importlib.import_module(module_path)
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
"""Ensure a fresh registry for each test."""
|
||||
reset_contract_registry_for_testing()
|
||||
yield
|
||||
reset_contract_registry_for_testing()
|
||||
|
||||
|
||||
# ─── ContractRegistry singleton ───
|
||||
|
||||
|
||||
class TestContractRegistry:
|
||||
def test_singleton_identity(self):
|
||||
"""get_contract_registry returns the same instance."""
|
||||
a = get_contract_registry()
|
||||
b = get_contract_registry()
|
||||
assert a is b
|
||||
|
||||
def test_register_and_get(self):
|
||||
"""register stores and get_contract retrieves."""
|
||||
reg = get_contract_registry()
|
||||
sentinel = object()
|
||||
reg.register("demo", sentinel)
|
||||
assert reg.get_contract("demo") is sentinel
|
||||
|
||||
def test_unregister(self):
|
||||
"""unregister removes the contract."""
|
||||
reg = get_contract_registry()
|
||||
sentinel = object()
|
||||
reg.register("demo", sentinel)
|
||||
reg.unregister("demo")
|
||||
assert reg.get_contract("demo") is None
|
||||
|
||||
def test_get_contract_returns_none_for_unknown(self):
|
||||
"""Unknown plugin returns None, not raises."""
|
||||
reg = get_contract_registry()
|
||||
assert reg.get_contract("does_not_exist") is None
|
||||
|
||||
def test_require_contract_raises_for_missing(self):
|
||||
"""require_contract raises ContractError when missing."""
|
||||
reg = get_contract_registry()
|
||||
with pytest.raises(ContractError):
|
||||
reg.require_contract("does_not_exist")
|
||||
|
||||
def test_require_contract_returns_contract(self):
|
||||
"""require_contract returns the contract when registered."""
|
||||
reg = get_contract_registry()
|
||||
sentinel = object()
|
||||
reg.register("demo", sentinel)
|
||||
assert reg.require_contract("demo") is sentinel
|
||||
|
||||
def test_list_available(self):
|
||||
"""list_available returns sorted plugin names."""
|
||||
reg = get_contract_registry()
|
||||
reg.register("zebra", object())
|
||||
reg.register("alpha", object())
|
||||
assert reg.list_available() == ["alpha", "zebra"]
|
||||
|
||||
def test_module_level_get_contract(self):
|
||||
"""Module-level get_contract function works."""
|
||||
reg = get_contract_registry()
|
||||
sentinel = object()
|
||||
reg.register("demo", sentinel)
|
||||
assert get_contract("demo") is sentinel
|
||||
|
||||
def test_reset_for_testing_clears_state(self):
|
||||
"""reset clears all registered contracts."""
|
||||
reg = get_contract_registry()
|
||||
reg.register("a", object())
|
||||
reg.register("b", object())
|
||||
assert len(reg.list_available()) == 2
|
||||
reset_contract_registry_for_testing()
|
||||
assert reg.list_available() == []
|
||||
|
||||
|
||||
# ─── Kommunikation contract ───
|
||||
|
||||
|
||||
class TestKommunikationContract:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _load_komm(self):
|
||||
"""Reload kommunikation contracts so it re-registers after reset."""
|
||||
_reload_contracts("kommunikation")
|
||||
|
||||
def test_contract_registers(self):
|
||||
"""Importing kommunikation.contracts registers it in the registry."""
|
||||
contract = get_contract("kommunikation")
|
||||
assert contract is not None
|
||||
assert contract.contract_name == "kommunikation"
|
||||
|
||||
def test_exposes_services(self):
|
||||
"""Contract exposes service functions."""
|
||||
contract = get_contract("kommunikation")
|
||||
assert callable(contract.parse_mentions)
|
||||
assert callable(contract.get_conversation)
|
||||
assert callable(contract.get_messages)
|
||||
assert callable(contract.send_message)
|
||||
assert callable(contract.create_plugin_room)
|
||||
|
||||
def test_exposes_participant_registry(self):
|
||||
"""Contract exposes participant registry types."""
|
||||
contract = get_contract("kommunikation")
|
||||
assert callable(contract.get_participant_registry)
|
||||
assert contract.ParticipantHandler is not None
|
||||
|
||||
def test_exposes_miniapp_registry(self):
|
||||
"""Contract exposes MiniAppRegistry."""
|
||||
contract = get_contract("kommunikation")
|
||||
assert contract.MiniAppRegistry is not None
|
||||
assert contract.MiniAppDef is not None
|
||||
|
||||
def test_exposes_models(self):
|
||||
"""Contract exposes ORM models."""
|
||||
contract = get_contract("kommunikation")
|
||||
assert contract.CommConversation is not None
|
||||
assert contract.CommMessage is not None
|
||||
assert contract.CommParticipant is not None
|
||||
|
||||
def test_parse_mentions_works(self):
|
||||
"""parse_mentions actually parses @mentions."""
|
||||
contract = get_contract("kommunikation")
|
||||
result = contract.parse_mentions("hello @ai_proactive and @system")
|
||||
assert result == ["ai_proactive", "system"]
|
||||
|
||||
|
||||
# ─── AI Assistant contract ───
|
||||
|
||||
|
||||
class TestAIAssistantContract:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _load_ai(self):
|
||||
"""Reload ai_assistant contracts so it re-registers after reset."""
|
||||
_reload_contracts("ai_assistant")
|
||||
|
||||
def test_contract_registers(self):
|
||||
"""Importing ai_assistant.contracts registers it."""
|
||||
contract = get_contract("ai_assistant")
|
||||
assert contract is not None
|
||||
assert contract.contract_name == "ai_assistant"
|
||||
|
||||
def test_exposes_tool_registry(self):
|
||||
"""Contract exposes tool registry functions and types."""
|
||||
contract = get_contract("ai_assistant")
|
||||
assert callable(contract.get_tool_registry)
|
||||
assert contract.ToolRegistry is not None
|
||||
assert contract.AITool is not None
|
||||
|
||||
def test_exposes_get_default_provider(self):
|
||||
"""Contract exposes get_default_provider."""
|
||||
contract = get_contract("ai_assistant")
|
||||
assert callable(contract.get_default_provider)
|
||||
|
||||
def test_tool_registry_singleton_works(self):
|
||||
"""get_tool_registry returns a working singleton."""
|
||||
contract = get_contract("ai_assistant")
|
||||
reg = contract.get_tool_registry()
|
||||
assert reg is not None
|
||||
reg2 = contract.get_tool_registry()
|
||||
assert reg is reg2
|
||||
|
||||
|
||||
# ─── Mail contract ───
|
||||
|
||||
|
||||
class TestMailContract:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _load_mail(self):
|
||||
"""Reload mail contracts so it re-registers after reset."""
|
||||
_reload_contracts("mail")
|
||||
|
||||
def test_contract_registers(self):
|
||||
"""Importing mail.contracts registers it."""
|
||||
contract = get_contract("mail")
|
||||
assert contract is not None
|
||||
assert contract.contract_name == "mail"
|
||||
|
||||
def test_exposes_mail_model(self):
|
||||
"""Contract exposes the Mail ORM model."""
|
||||
contract = get_contract("mail")
|
||||
assert contract.Mail is not None
|
||||
from app.plugins.builtins.mail.models import Mail as MailModel
|
||||
assert contract.Mail is MailModel
|
||||
|
||||
|
||||
# ─── Lazy loading ───
|
||||
|
||||
|
||||
class TestLazyLoading:
|
||||
def test_lazy_load_on_first_access(self):
|
||||
"""get_contract triggers lazy load of contracts module."""
|
||||
reg = get_contract_registry()
|
||||
contract = reg.get_contract("kommunikation")
|
||||
assert contract is not None
|
||||
assert contract.contract_name == "kommunikation"
|
||||
|
||||
def test_lazy_load_missing_plugin_returns_none(self):
|
||||
"""Lazy load of non-existent plugin returns None."""
|
||||
reg = get_contract_registry()
|
||||
assert reg.get_contract("nonexistent_plugin_xyz") is None
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Unified Search plugin contract — public interface for cross-plugin access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.unified_search.embedding import generate_embedding
|
||||
from app.plugins.builtins.unified_search.search_engine import hybrid_search
|
||||
|
||||
|
||||
class UnifiedSearchContract:
|
||||
"""Public contract for the unified_search plugin."""
|
||||
|
||||
generate_embedding = staticmethod(generate_embedding)
|
||||
hybrid_search = staticmethod(hybrid_search)
|
||||
|
||||
|
||||
_contract_instance: UnifiedSearchContract | None = None
|
||||
|
||||
|
||||
def get_contract() -> UnifiedSearchContract:
|
||||
global _contract_instance
|
||||
if _contract_instance is None:
|
||||
_contract_instance = UnifiedSearchContract()
|
||||
return _contract_instance
|
||||
@@ -40,7 +40,7 @@ async def _get_api_credentials(
|
||||
# Fallback to DB provider
|
||||
if db and tenant_id:
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.services import get_default_provider
|
||||
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
|
||||
|
||||
@@ -38,7 +38,7 @@ async def _get_api_credentials(
|
||||
"""
|
||||
if db and tenant_id:
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.services import get_default_provider
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user