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,51 @@
|
||||
"""Command pattern package for LeoCRM.
|
||||
|
||||
Commands encapsulate business operations with:
|
||||
- Authorization (permission check)
|
||||
- Execution (delegates to services)
|
||||
- Audit logging
|
||||
- Outbox event enqueuing
|
||||
- State machine validation
|
||||
|
||||
Commands do NOT commit or rollback — the calling layer (FastAPI dependency
|
||||
``get_db``) manages the transaction boundary.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseCommand",
|
||||
"CommandResult",
|
||||
"CreateContactCommand",
|
||||
"UpdateContactCommand",
|
||||
"DeleteContactCommand",
|
||||
"MergeContactsCommand",
|
||||
"UploadFileCommand",
|
||||
"DeleteFileCommand",
|
||||
"SendMailCommand",
|
||||
"MarkMailReadCommand",
|
||||
"DeleteMailCommand",
|
||||
"CreateCalendarEntryCommand",
|
||||
"UpdateCalendarEntryCommand",
|
||||
"DeleteCalendarEntryCommand",
|
||||
]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Base command infrastructure — CommandResult and BaseCommand.
|
||||
|
||||
Template method pattern:
|
||||
execute() → authorize() → run() → audit()
|
||||
|
||||
Commands must NOT call db.commit() or db.rollback().
|
||||
The transaction boundary is owned by the calling layer (FastAPI ``get_db``).
|
||||
Commands MAY call db.flush() to send SQL within the transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution.
|
||||
|
||||
Attributes:
|
||||
success: Whether the command succeeded.
|
||||
data: Response data on success (dict or None).
|
||||
error: Error message on failure (str or None).
|
||||
events: List of outbox event dicts that were enqueued.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
data: dict | None = None
|
||||
error: str | None = None
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
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":
|
||||
"""Create a failed result."""
|
||||
return cls(success=False, error=error)
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
"""Base class for all commands using the template method pattern.
|
||||
|
||||
Subclasses must implement:
|
||||
- authorize(current_user) → check permissions
|
||||
- run(db, redis, current_user) → execute business logic, return CommandResult
|
||||
- audit(db, current_user) → create audit log entry
|
||||
|
||||
The ``permission`` attribute is checked by the default ``authorize``
|
||||
implementation. Set it to the required permission string (e.g. "contacts:write").
|
||||
"""
|
||||
|
||||
permission: str | None = None
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
"""Execute the command: authorize → run → audit.
|
||||
|
||||
Does NOT commit — the caller manages the transaction.
|
||||
"""
|
||||
# Authorization
|
||||
auth_result = await self.authorize(current_user)
|
||||
if not auth_result:
|
||||
return CommandResult.fail(
|
||||
f"Permission denied: '{self.permission}' required"
|
||||
)
|
||||
|
||||
# Execute business logic
|
||||
result = await self.run(db, redis, current_user)
|
||||
|
||||
# Audit (only if the command succeeded)
|
||||
if result.success:
|
||||
try:
|
||||
await self.audit(db, current_user)
|
||||
except Exception:
|
||||
logger.exception("Audit logging failed for %s", self.__class__.__name__)
|
||||
# Audit failure should not roll back the business operation
|
||||
|
||||
return result
|
||||
|
||||
async def authorize(self, current_user: dict[str, Any]) -> bool:
|
||||
"""Check if the current user has the required permission.
|
||||
|
||||
Override in subclass for custom authorization logic.
|
||||
"""
|
||||
if self.permission is None:
|
||||
return True
|
||||
return check_permission(current_user, self.permission)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
"""Execute the business logic. Must be overridden by subclasses."""
|
||||
raise NotImplementedError(f"{self.__class__.__name__}.run() not implemented")
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
"""Create an audit log entry. Override in subclass."""
|
||||
pass
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
@staticmethod
|
||||
def _tenant_id(current_user: dict[str, Any]) -> uuid.UUID:
|
||||
"""Extract tenant_id from current_user session."""
|
||||
return uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
@staticmethod
|
||||
def _user_id(current_user: dict[str, Any]) -> uuid.UUID:
|
||||
"""Extract user_id from current_user session."""
|
||||
return uuid.UUID(current_user["user_id"])
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Calendar commands — create, update, delete entries via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateCalendarEntryCommand(BaseCommand):
|
||||
"""Create a new calendar entry (appointment, task, reminder)."""
|
||||
|
||||
permission = "calendar:write"
|
||||
|
||||
def __init__(self, calendar_id: str, title: str, start_at: str, end_at: str | None = None,
|
||||
description: str | None = None, location: str | None = None,
|
||||
entry_type: str = "appointment", status: str = "open"):
|
||||
self.calendar_id = calendar_id
|
||||
self.title = title
|
||||
self.start_at = start_at
|
||||
self.end_at = end_at
|
||||
self.description = description
|
||||
self.location = location
|
||||
self.entry_type = entry_type
|
||||
self.status = status
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
try:
|
||||
cal_id = uuid.UUID(self.calendar_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid calendar_id")
|
||||
|
||||
# Verify calendar belongs to tenant
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id, Calendar.tenant_id == tenant_id)
|
||||
)
|
||||
if cal_result.scalar_one_or_none() is None:
|
||||
return CommandResult.fail("Calendar not found")
|
||||
|
||||
entry_id = uuid.uuid4()
|
||||
entry = CalendarEntry(
|
||||
id=entry_id,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=cal_id,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
location=self.location,
|
||||
start_at=datetime.fromisoformat(self.start_at),
|
||||
end_at=datetime.fromisoformat(self.end_at) if self.end_at else None,
|
||||
entry_type=self.entry_type,
|
||||
status=self.status,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.created", {
|
||||
"entry_id": str(entry_id),
|
||||
"title": self.title,
|
||||
"start_at": self.start_at,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(entry_id),
|
||||
"title": self.title,
|
||||
"start_at": self.start_at,
|
||||
"status": self.status,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.create", entity_type="calendar_entry",
|
||||
changes={"title": self.title, "start_at": self.start_at},
|
||||
)
|
||||
|
||||
|
||||
class UpdateCalendarEntryCommand(BaseCommand):
|
||||
"""Update an existing calendar entry."""
|
||||
|
||||
permission = "calendar:write"
|
||||
|
||||
def __init__(self, entry_id: str, data: dict[str, Any]):
|
||||
self.entry_id = entry_id
|
||||
self.data = data
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
eid = uuid.UUID(self.entry_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid entry_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return CommandResult.fail("Calendar entry not found")
|
||||
|
||||
# Apply updates
|
||||
for key, value in self.data.items():
|
||||
if hasattr(entry, key) and key not in ("id", "tenant_id", "created_at"):
|
||||
if key in ("start_at", "end_at") and isinstance(value, str):
|
||||
value = datetime.fromisoformat(value)
|
||||
setattr(entry, key, value)
|
||||
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.updated", {
|
||||
"entry_id": self.entry_id,
|
||||
"changes": self.data,
|
||||
})
|
||||
|
||||
return CommandResult.ok({"id": self.entry_id, "updated": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.update", entity_type="calendar_entry",
|
||||
changes={"entry_id": self.entry_id, "fields": list(self.data.keys())},
|
||||
)
|
||||
|
||||
|
||||
class DeleteCalendarEntryCommand(BaseCommand):
|
||||
"""Delete a calendar entry."""
|
||||
|
||||
permission = "calendar:delete"
|
||||
|
||||
def __init__(self, entry_id: str):
|
||||
self.entry_id = entry_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
eid = uuid.UUID(self.entry_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid entry_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return CommandResult.fail("Calendar entry not found")
|
||||
|
||||
await db.delete(entry)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.deleted", {"entry_id": self.entry_id})
|
||||
|
||||
return CommandResult.ok({"id": self.entry_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.delete", entity_type="calendar_entry",
|
||||
changes={"entry_id": self.entry_id},
|
||||
)
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Contact commands — Create, Update, Delete (soft), Merge.
|
||||
|
||||
Each command:
|
||||
- Checks permissions via ``require_permission`` semantics
|
||||
- Delegates business logic to existing services
|
||||
- Creates an AuditLog entry
|
||||
- Enqueues outbox events
|
||||
- Validates state transitions via the state machine
|
||||
|
||||
Commands do NOT commit — the transaction is managed by ``get_db``.
|
||||
"""
|
||||
|
||||
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 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.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__)
|
||||
|
||||
|
||||
class CreateContactCommand(BaseCommand):
|
||||
"""Create a new contact (company or person).
|
||||
|
||||
Args:
|
||||
data: Contact fields dict (from ContactCreate schema).
|
||||
"""
|
||||
|
||||
permission = "contacts:write"
|
||||
|
||||
def __init__(self, data: dict[str, Any]) -> None:
|
||||
self.data = data
|
||||
self._created_contact_id: uuid.UUID | None = None
|
||||
self._serialized: dict | None = None
|
||||
|
||||
async def run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
# Set default status for new contacts
|
||||
if "status" not in self.data:
|
||||
self.data["status"] = "lead"
|
||||
|
||||
# Validate status if provided
|
||||
status = self.data.get("status", "lead")
|
||||
if status not in contact_state_machine.transitions:
|
||||
return CommandResult.fail(f"Invalid contact status: '{status}'")
|
||||
|
||||
# Delegate to existing service
|
||||
try:
|
||||
serialized = await contact_service.create_contact(
|
||||
db, tenant_id, user_id, self.data
|
||||
)
|
||||
except ValueError as exc:
|
||||
return CommandResult.fail(str(exc))
|
||||
|
||||
self._created_contact_id = uuid.UUID(serialized["id"])
|
||||
self._serialized = serialized
|
||||
|
||||
# Enqueue outbox events
|
||||
events: list[dict] = []
|
||||
await enqueue_outbox_event(db, tenant_id, "contact.created", {
|
||||
"contact_id": serialized["id"],
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"type": self.data.get("type", "company"),
|
||||
})
|
||||
events.append({"event": "contact.created", "contact_id": serialized["id"]})
|
||||
|
||||
if self.data.get("type") == "company":
|
||||
await enqueue_outbox_event(db, tenant_id, "lead.created", {
|
||||
"contact_id": serialized["id"],
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
})
|
||||
events.append({"event": "lead.created", "contact_id": serialized["id"]})
|
||||
|
||||
return CommandResult.ok(data=serialized, events=events)
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
if self._created_contact_id is None:
|
||||
return
|
||||
entry = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="create",
|
||||
entity_type="contact",
|
||||
entity_id=self._created_contact_id,
|
||||
changes=self._serialized,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
|
||||
class UpdateContactCommand(BaseCommand):
|
||||
"""Update an existing contact.
|
||||
|
||||
Args:
|
||||
contact_id: UUID string of the contact to update.
|
||||
data: Contact fields to update (from ContactUpdate schema).
|
||||
"""
|
||||
|
||||
permission = "contacts:write"
|
||||
|
||||
def __init__(self, contact_id: str, data: dict[str, Any]) -> None:
|
||||
self.contact_id = contact_id
|
||||
self.data = data
|
||||
self._contact_uuid: uuid.UUID | None = None
|
||||
self._serialized: dict | None = None
|
||||
self._changes: dict | None = None
|
||||
|
||||
async def run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
# Validate status transition if status is being updated
|
||||
if "status" in self.data:
|
||||
new_status = self.data["status"]
|
||||
# Query only the status column to avoid lazy-loading issues
|
||||
from sqlalchemy import select
|
||||
|
||||
status_q = select(Contact.status).where(
|
||||
Contact.id == uuid.UUID(self.contact_id),
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
status_result = await db.execute(status_q)
|
||||
current_status = status_result.scalar_one_or_none()
|
||||
if current_status is None:
|
||||
return CommandResult.fail("Contact not found")
|
||||
|
||||
try:
|
||||
contact_state_machine.transition(current_status, new_status)
|
||||
except StateMachineError as exc:
|
||||
return CommandResult.fail(str(exc))
|
||||
|
||||
# Delegate to existing service
|
||||
try:
|
||||
serialized = await contact_service.update_contact(
|
||||
db, tenant_id, user_id, self.contact_id, self.data
|
||||
)
|
||||
except ValueError as exc:
|
||||
return CommandResult.fail(str(exc))
|
||||
|
||||
self._contact_uuid = uuid.UUID(self.contact_id)
|
||||
self._serialized = serialized
|
||||
|
||||
# Compute changes for audit
|
||||
self._changes = {k: {"new": v} for k, v in self.data.items()}
|
||||
|
||||
# Enqueue outbox event
|
||||
events: list[dict] = []
|
||||
await enqueue_outbox_event(db, tenant_id, "contact.updated", {
|
||||
"contact_id": self.contact_id,
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"type": serialized.get("type"),
|
||||
})
|
||||
events.append({"event": "contact.updated", "contact_id": self.contact_id})
|
||||
|
||||
return CommandResult.ok(data=serialized, events=events)
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
if self._contact_uuid is None:
|
||||
return
|
||||
entry = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="update",
|
||||
entity_type="contact",
|
||||
entity_id=self._contact_uuid,
|
||||
changes=self._changes,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
|
||||
class DeleteContactCommand(BaseCommand):
|
||||
"""Soft-delete a contact.
|
||||
|
||||
Args:
|
||||
contact_id: UUID string of the contact to delete.
|
||||
hard: If True, perform GDPR hard-delete instead of soft-delete.
|
||||
"""
|
||||
|
||||
permission = "contacts:write"
|
||||
|
||||
def __init__(self, contact_id: str, hard: bool = False) -> None:
|
||||
self.contact_id = contact_id
|
||||
self.hard = hard
|
||||
self._contact_uuid: uuid.UUID | None = None
|
||||
self._snapshot: dict | None = None
|
||||
|
||||
async def run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
# Fetch contact for snapshot before deletion
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
q = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(
|
||||
Contact.id == uuid.UUID(self.contact_id),
|
||||
Contact.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
return CommandResult.fail("Contact not found")
|
||||
|
||||
self._contact_uuid = contact.id
|
||||
self._snapshot = contact_service._serialize_contact_detail(contact)
|
||||
|
||||
if self.hard:
|
||||
await contact_service.hard_delete_contact(
|
||||
db, tenant_id, self.contact_id
|
||||
)
|
||||
else:
|
||||
await contact_service.delete_contact(
|
||||
db, tenant_id, self.contact_id, user_id
|
||||
)
|
||||
|
||||
# Enqueue outbox event
|
||||
events: list[dict] = []
|
||||
event_name = "contact.hard_deleted" if self.hard else "contact.deleted"
|
||||
await enqueue_outbox_event(db, tenant_id, event_name, {
|
||||
"contact_id": self.contact_id,
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
})
|
||||
events.append({"event": event_name, "contact_id": self.contact_id})
|
||||
|
||||
return CommandResult.ok(data=None, events=events)
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
if self._contact_uuid is None:
|
||||
return
|
||||
|
||||
action = "hard_delete" if self.hard else "delete"
|
||||
entry = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
entity_type="contact",
|
||||
entity_id=self._contact_uuid,
|
||||
changes={"snapshot": self._snapshot},
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
|
||||
class MergeContactsCommand(BaseCommand):
|
||||
"""Merge two contacts (source → target).
|
||||
|
||||
Args:
|
||||
source_contact_id: UUID string of the source contact (will be soft-deleted).
|
||||
target_contact_id: UUID string of the target contact (will survive).
|
||||
field_overrides: Optional field overrides to apply to the target.
|
||||
note: Optional note for the merge history.
|
||||
"""
|
||||
|
||||
permission = "contacts:write"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_contact_id: str,
|
||||
target_contact_id: str,
|
||||
field_overrides: dict[str, Any] | None = None,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
self.source_contact_id = source_contact_id
|
||||
self.target_contact_id = target_contact_id
|
||||
self.field_overrides = field_overrides
|
||||
self.note = note
|
||||
self._result_data: dict | None = None
|
||||
|
||||
async def run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
if self.source_contact_id == self.target_contact_id:
|
||||
return CommandResult.fail("Source and target contacts must be different")
|
||||
|
||||
try:
|
||||
result = await dedup_service.merge_contacts(
|
||||
db, tenant_id, user_id,
|
||||
source_id=self.source_contact_id,
|
||||
target_id=self.target_contact_id,
|
||||
field_overrides=self.field_overrides,
|
||||
note=self.note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return CommandResult.fail(str(exc))
|
||||
|
||||
self._result_data = result
|
||||
|
||||
# Enqueue outbox events
|
||||
events: list[dict] = []
|
||||
await enqueue_outbox_event(db, tenant_id, "contact.merged", {
|
||||
"source_contact_id": self.source_contact_id,
|
||||
"target_contact_id": self.target_contact_id,
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
})
|
||||
events.append({
|
||||
"event": "contact.merged",
|
||||
"source_contact_id": self.source_contact_id,
|
||||
"target_contact_id": self.target_contact_id,
|
||||
})
|
||||
|
||||
return CommandResult.ok(data=result, events=events)
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
if self._result_data is None:
|
||||
return
|
||||
|
||||
entry = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="merge",
|
||||
entity_type="contact",
|
||||
entity_id=uuid.UUID(self.target_contact_id),
|
||||
changes={
|
||||
"source_contact_id": self.source_contact_id,
|
||||
"target_contact_id": self.target_contact_id,
|
||||
"note": self.note,
|
||||
},
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""DMS commands — file upload, delete, restore via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
from app.core.storage import get_storage_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename for safe use in Content-Disposition headers."""
|
||||
import re
|
||||
safe = os.path.basename(filename.replace("\\", "/"))
|
||||
safe = re.sub(r"[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]", "_", safe)
|
||||
safe = re.sub(r"\.{2,}", "_", safe)
|
||||
safe = re.sub(r" {2,}", " ", safe)
|
||||
safe = safe.lstrip(".").strip()
|
||||
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"
|
||||
|
||||
|
||||
class UploadFileCommand(BaseCommand):
|
||||
"""Upload a file to DMS with chunked streaming and SHA-256 hashing."""
|
||||
|
||||
permission = "dms:write"
|
||||
|
||||
def __init__(self, file_content: bytes, filename: str, mime_type: str, folder_id: str | None = None):
|
||||
self.file_content = file_content
|
||||
self.filename = _sanitize_filename(filename)
|
||||
self.mime_type = mime_type
|
||||
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
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
# Validate folder if specified
|
||||
fid = None
|
||||
if self.folder_id:
|
||||
try:
|
||||
fid = uuid.UUID(self.folder_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid folder_id")
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None))
|
||||
)
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
return CommandResult.fail("Folder not found")
|
||||
|
||||
# Calculate SHA-256
|
||||
sha256 = hashlib.sha256()
|
||||
sha256.update(self.file_content)
|
||||
content_hash = sha256.hexdigest()
|
||||
file_size = len(self.file_content)
|
||||
|
||||
# Create file record
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = f"{tenant_id}/{file_id}"
|
||||
|
||||
# Save file
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, self.file_content)
|
||||
|
||||
dms_file = DmsFile(
|
||||
id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
name=self.filename,
|
||||
folder_id=fid,
|
||||
uploaded_by=user_id,
|
||||
mime_type=self.mime_type,
|
||||
size_bytes=file_size,
|
||||
storage_path=storage_path,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
db.add(dms_file)
|
||||
await db.flush()
|
||||
|
||||
# Enqueue outbox event
|
||||
await enqueue_outbox_event(db, tenant_id, "dms.file.uploaded", {
|
||||
"file_id": str(file_id),
|
||||
"name": self.filename,
|
||||
"size_bytes": file_size,
|
||||
"content_hash": content_hash,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(file_id),
|
||||
"name": self.filename,
|
||||
"size_bytes": file_size,
|
||||
"content_hash": content_hash,
|
||||
"mime_type": self.mime_type,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="dms.file.upload", entity_type="dms_file",
|
||||
changes={"name": self.filename, "size": len(self.file_content)},
|
||||
)
|
||||
|
||||
|
||||
class DeleteFileCommand(BaseCommand):
|
||||
"""Soft-delete a DMS file."""
|
||||
|
||||
permission = "dms:delete"
|
||||
|
||||
def __init__(self, file_id: str):
|
||||
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
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
fid = uuid.UUID(self.file_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid file_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None))
|
||||
)
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file is None:
|
||||
return CommandResult.fail("File not found")
|
||||
|
||||
dms_file.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "dms.file.deleted", {"file_id": self.file_id})
|
||||
|
||||
return CommandResult.ok({"id": self.file_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="dms.file.delete", entity_type="dms_file",
|
||||
changes={"file_id": self.file_id},
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Mail commands — send, mark read/unread, delete via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SendMailCommand(BaseCommand):
|
||||
"""Send an email via a configured IMAP/SMTP account."""
|
||||
|
||||
permission = "mail:send"
|
||||
|
||||
def __init__(self, account_id: str, to: list[str], subject: str, body_text: str, body_html: str | None = None, cc: list[str] | None = None, in_reply_to: str | None = None):
|
||||
self.account_id = account_id
|
||||
self.to = to
|
||||
self.subject = subject
|
||||
self.body_text = body_text
|
||||
self.body_html = body_html
|
||||
self.cc = cc or []
|
||||
self.in_reply_to = in_reply_to
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
try:
|
||||
account_uuid = uuid.UUID(self.account_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid account_id")
|
||||
|
||||
# Verify account belongs to tenant
|
||||
acct_result = await db.execute(
|
||||
select(MailAccount).where(MailAccount.id == account_uuid, MailAccount.tenant_id == tenant_id)
|
||||
)
|
||||
account = acct_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
return CommandResult.fail("Mail account not found")
|
||||
|
||||
# Create mail record
|
||||
mail_id = uuid.uuid4()
|
||||
mail = Mail(
|
||||
id=mail_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_uuid,
|
||||
message_id=f"<leocrm-{mail_id}@{account.email_address}>",
|
||||
from_addr=account.email_address,
|
||||
to_addr=",".join(self.to),
|
||||
cc_addr=",".join(self.cc) if self.cc else None,
|
||||
subject=self.subject,
|
||||
body_text=self.body_text,
|
||||
body_html_sanitized=self.body_html,
|
||||
direction="outgoing",
|
||||
received_at=datetime.now(UTC),
|
||||
is_read=True,
|
||||
folder="Sent",
|
||||
)
|
||||
db.add(mail)
|
||||
await db.flush()
|
||||
|
||||
# Enqueue outbox event for async SMTP send
|
||||
await enqueue_outbox_event(db, tenant_id, "mail.send", {
|
||||
"mail_id": str(mail_id),
|
||||
"account_id": self.account_id,
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(mail_id),
|
||||
"status": "queued",
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.send", entity_type="mail",
|
||||
changes={"to": self.to, "subject": self.subject},
|
||||
)
|
||||
|
||||
|
||||
class MarkMailReadCommand(BaseCommand):
|
||||
"""Mark a mail as read or unread."""
|
||||
|
||||
permission = "mail:write"
|
||||
|
||||
def __init__(self, mail_id: str, is_read: bool = True):
|
||||
self.mail_id = mail_id
|
||||
self.is_read = is_read
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
mid = uuid.UUID(self.mail_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid mail_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id)
|
||||
)
|
||||
mail = result.scalar_one_or_none()
|
||||
if mail is None:
|
||||
return CommandResult.fail("Mail not found")
|
||||
|
||||
mail.is_read = self.is_read
|
||||
await db.flush()
|
||||
|
||||
return CommandResult.ok({"id": self.mail_id, "is_read": self.is_read})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.mark_read", entity_type="mail",
|
||||
changes={"mail_id": self.mail_id, "is_read": self.is_read},
|
||||
)
|
||||
|
||||
|
||||
class DeleteMailCommand(BaseCommand):
|
||||
"""Soft-delete a mail."""
|
||||
|
||||
permission = "mail:delete"
|
||||
|
||||
def __init__(self, mail_id: str):
|
||||
self.mail_id = mail_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
mid = uuid.UUID(self.mail_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid mail_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None))
|
||||
)
|
||||
mail = result.scalar_one_or_none()
|
||||
if mail is None:
|
||||
return CommandResult.fail("Mail not found")
|
||||
|
||||
mail.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "mail.deleted", {"mail_id": self.mail_id})
|
||||
|
||||
return CommandResult.ok({"id": self.mail_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.delete", entity_type="mail",
|
||||
changes={"mail_id": self.mail_id},
|
||||
)
|
||||
+12
-3
@@ -35,13 +35,13 @@ class Settings(BaseSettings):
|
||||
# Auth
|
||||
bcrypt_rounds: int = 12
|
||||
session_cookie_name: str = "leocrm_session"
|
||||
session_cookie_secure: bool = False # True in production behind HTTPS
|
||||
session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development
|
||||
session_cookie_samesite: str = "strict"
|
||||
session_cookie_httponly: bool = True
|
||||
password_reset_expiry_hours: int = 1
|
||||
|
||||
# Storage
|
||||
storage_path: str = "/tmp"
|
||||
storage_path: str = "/data/storage"
|
||||
|
||||
# SMTP
|
||||
smtp_host: str = "localhost"
|
||||
@@ -76,7 +76,16 @@ class Settings(BaseSettings):
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
s = Settings()
|
||||
# Production safety checks
|
||||
if s.environment == "production":
|
||||
if not s.session_cookie_secure:
|
||||
raise RuntimeError("SESSION_COOKIE_SECURE must be True in production")
|
||||
if s.secret_key == "change-me-in-production-use-a-secure-random-string":
|
||||
raise RuntimeError("SECRET_KEY must be changed from default in production")
|
||||
if s.storage_path == "/tmp":
|
||||
raise RuntimeError("STORAGE_PATH must not be /tmp in production")
|
||||
return s
|
||||
|
||||
|
||||
# Module-level singleton for backward-compatible imports
|
||||
|
||||
+54
-2
@@ -8,6 +8,8 @@ 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
|
||||
@@ -16,10 +18,53 @@ from app.config import get_settings
|
||||
from app.models.session import Session as SessionModel
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pwd_context = CryptContext(
|
||||
schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds
|
||||
)
|
||||
|
||||
# ── Global Redis client singleton ────────────────────────────────────────────
|
||||
_redis_client: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def init_redis() -> aioredis.Redis:
|
||||
"""Create and store the global Redis client. Called once during app lifespan startup."""
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
logger.warning("init_redis() called but Redis client already initialized")
|
||||
return _redis_client
|
||||
_redis_client = aioredis.from_url(
|
||||
get_settings().redis_url, decode_responses=True
|
||||
)
|
||||
logger.info("Global Redis client initialized")
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
"""Close the global Redis client. Called during app lifespan shutdown."""
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
await _redis_client.aclose()
|
||||
_redis_client = None
|
||||
logger.info("Global Redis client closed")
|
||||
|
||||
|
||||
def get_redis() -> aioredis.Redis:
|
||||
"""Return the global Redis client singleton.
|
||||
|
||||
If init_redis() has not been called yet (e.g. during testing or
|
||||
outside the app lifespan), a new client is created lazily so callers
|
||||
always get a working connection.
|
||||
"""
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = aioredis.from_url(
|
||||
get_settings().redis_url, decode_responses=True
|
||||
)
|
||||
logger.debug("Redis client created lazily (init_redis not called)")
|
||||
return _redis_client
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
@@ -56,9 +101,13 @@ async def create_session(
|
||||
redis: aioredis.Redis,
|
||||
user: User,
|
||||
tenant_id: uuid.UUID,
|
||||
role: str = "viewer",
|
||||
) -> tuple[str, str]:
|
||||
"""Create a session in Redis (runtime) and PostgreSQL (audit trail).
|
||||
Returns (session_id, csrf_token).
|
||||
|
||||
``role`` comes from UserTenant — the built-in role string for the
|
||||
active tenant membership.
|
||||
"""
|
||||
settings = get_settings()
|
||||
session_id = str(uuid.uuid4())
|
||||
@@ -71,7 +120,7 @@ async def create_session(
|
||||
"tenant_id": str(tenant_id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role": role,
|
||||
"is_system_admin": user.is_system_admin,
|
||||
"csrf_token": csrf_token,
|
||||
"is_active": user.is_active,
|
||||
@@ -123,8 +172,9 @@ async def update_session_tenant(
|
||||
redis: aioredis.Redis,
|
||||
session_id: str,
|
||||
new_tenant_id: uuid.UUID,
|
||||
role: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update the active tenant in a Redis session."""
|
||||
"""Update the active tenant (and optionally role) in a Redis session."""
|
||||
import json
|
||||
|
||||
settings = get_settings()
|
||||
@@ -133,6 +183,8 @@ async def update_session_tenant(
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
data["tenant_id"] = str(new_tenant_id)
|
||||
if role is not None:
|
||||
data["role"] = role
|
||||
ttl = await redis.ttl(f"session:{session_id}")
|
||||
if ttl <= 0:
|
||||
ttl = settings.session_ttl_seconds
|
||||
|
||||
+41
-1
@@ -1,4 +1,23 @@
|
||||
"""In-process event bus for publish/subscribe."""
|
||||
"""In-process event bus for publish/subscribe.
|
||||
|
||||
.. note::
|
||||
|
||||
This bus is **in-process only** — events are lost on crash, restart, or
|
||||
when multiple replicas are running. For **domain/business events** that
|
||||
must be delivered reliably (e.g. ``contact.created``, ``contact.updated``,
|
||||
``user.created``), use the :mod:`app.core.outbox` transactional outbox
|
||||
instead::
|
||||
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
await enqueue_outbox_event(db, tenant_id, "contact.created", {...})
|
||||
|
||||
The outbox worker (see :mod:`app.core.worker`) polls the ``event_outbox``
|
||||
table every 5 seconds and publishes events to this in-process bus, so
|
||||
local handlers still receive them — but with durability guarantees.
|
||||
|
||||
``publish()`` may still be used for **uncritical local events** that do
|
||||
not require persistence (e.g. cache invalidation signals).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,6 +54,27 @@ class EventBus:
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def publish_with_results(
|
||||
self, event_name: str, payload: dict[str, Any]
|
||||
) -> list[Exception | None]:
|
||||
"""Publish an event and return per-handler results.
|
||||
|
||||
Unlike :meth:`publish`, this method does **not** swallow exceptions.
|
||||
Each list entry is ``None`` on success or the caught ``Exception``
|
||||
on failure, so callers (e.g. the outbox processor) can detect handler
|
||||
errors and apply retry logic.
|
||||
"""
|
||||
handlers = self._handlers.get(event_name, [])
|
||||
wildcard_handlers = self._handlers.get('*', [])
|
||||
all_handlers = handlers + wildcard_handlers
|
||||
if not all_handlers:
|
||||
return []
|
||||
tasks = [asyncio.create_task(h(payload)) for h in all_handlers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return [
|
||||
r if isinstance(r, Exception) else None for r in results
|
||||
]
|
||||
|
||||
|
||||
# Global event bus instance
|
||||
_event_bus = EventBus()
|
||||
|
||||
+44
-4
@@ -2,19 +2,59 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arq import create_pool
|
||||
from arq.connections import RedisSettings
|
||||
from arq.connections import RedisSettings, ArqRedis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_job_pool():
|
||||
"""Get an ARQ job pool for enqueueing background tasks."""
|
||||
# ── Global ARQ pool singleton ────────────────────────────────────────────────
|
||||
_job_pool: ArqRedis | None = None
|
||||
|
||||
|
||||
async def init_job_pool() -> ArqRedis:
|
||||
"""Create and store the global ARQ job pool.
|
||||
|
||||
Called once during app lifespan startup so every subsequent enqueue
|
||||
reuses the same connection instead of opening a new one per call.
|
||||
"""
|
||||
global _job_pool
|
||||
if _job_pool is not None:
|
||||
logger.warning("init_job_pool() called but pool already initialized")
|
||||
return _job_pool
|
||||
settings = get_settings()
|
||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||
return await create_pool(redis_settings)
|
||||
_job_pool = await create_pool(redis_settings)
|
||||
logger.info("Global ARQ job pool initialized")
|
||||
return _job_pool
|
||||
|
||||
|
||||
async def close_job_pool() -> None:
|
||||
"""Close the global ARQ job pool. Called during app lifespan shutdown."""
|
||||
global _job_pool
|
||||
if _job_pool is not None:
|
||||
await _job_pool.close()
|
||||
_job_pool = None
|
||||
logger.info("Global ARQ job pool closed")
|
||||
|
||||
|
||||
async def get_job_pool() -> ArqRedis:
|
||||
"""Return the global ARQ job pool singleton.
|
||||
|
||||
If init_job_pool() has not been called yet (e.g. during testing),
|
||||
a new pool is created lazily so callers always get a working connection.
|
||||
"""
|
||||
global _job_pool
|
||||
if _job_pool is None:
|
||||
settings = get_settings()
|
||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||
_job_pool = await create_pool(redis_settings)
|
||||
logger.debug("ARQ job pool created lazily (init_job_pool not called)")
|
||||
return _job_pool
|
||||
|
||||
|
||||
async def enqueue_job(job_name: str, *args: Any, **kwargs: Any) -> str | None:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Transactional outbox for reliable domain event delivery.
|
||||
|
||||
Instead of publishing events directly to an in-process bus (which is lost
|
||||
on crash/restart), domain events are written to the ``event_outbox`` table
|
||||
**within the same database transaction** as the business operation. A
|
||||
background worker then polls the outbox and publishes events to the
|
||||
in-process event bus.
|
||||
|
||||
Usage in services::
|
||||
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "contact.created", {
|
||||
"contact_id": str(contact.id),
|
||||
"tenant_id": str(tenant_id),
|
||||
})
|
||||
# ... later, the transaction commits and the event is durable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── SQL statements (raw text for FOR UPDATE SKIP LOCKED) ────────────────────
|
||||
|
||||
_INSERT_SQL = text(
|
||||
"""
|
||||
INSERT INTO event_outbox (tenant_id, event_name, payload)
|
||||
VALUES (:tenant_id, :event_name, CAST(:payload AS JSONB))
|
||||
"""
|
||||
)
|
||||
|
||||
_CLAIM_SQL = text(
|
||||
"""
|
||||
UPDATE event_outbox
|
||||
SET status = 'processing',
|
||||
updated_at = now()
|
||||
WHERE id IN (
|
||||
SELECT id FROM event_outbox
|
||||
WHERE status = 'pending'
|
||||
AND (next_retry_at IS NULL OR next_retry_at <= now())
|
||||
ORDER BY created_at
|
||||
LIMIT :batch_size
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, tenant_id, event_name, payload, attempts, max_attempts
|
||||
"""
|
||||
)
|
||||
|
||||
_MARK_PUBLISHED_SQL = text(
|
||||
"""
|
||||
UPDATE event_outbox
|
||||
SET status = 'published',
|
||||
published_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
|
||||
_FAIL_SQL = text(
|
||||
"""
|
||||
UPDATE event_outbox
|
||||
SET status = 'failed',
|
||||
updated_at = now()
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
|
||||
_RETRY_SQL = text(
|
||||
"""
|
||||
UPDATE event_outbox
|
||||
SET status = 'pending',
|
||||
attempts = :attempts,
|
||||
next_retry_at = :next_retry_at,
|
||||
updated_at = now()
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _json_payload(payload: dict[str, Any]) -> str:
|
||||
"""Serialise payload to a JSON string suitable for JSONB cast."""
|
||||
import json
|
||||
|
||||
return json.dumps(payload, default=str)
|
||||
|
||||
|
||||
async def enqueue_outbox_event(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Insert an event into the outbox table within the current transaction.
|
||||
|
||||
The event is only persisted when the surrounding transaction commits.
|
||||
This guarantees at-least-once delivery — no event is lost even if the
|
||||
process crashes after the business operation but before the event is
|
||||
published.
|
||||
|
||||
Args:
|
||||
db: Active async SQLAlchemy session (part of the business transaction).
|
||||
tenant_id: Tenant scope for the event.
|
||||
event_name: Logical event name (e.g. ``"contact.created"``).
|
||||
payload: Event payload dict (will be stored as JSONB).
|
||||
"""
|
||||
await db.execute(
|
||||
_INSERT_SQL,
|
||||
{
|
||||
"tenant_id": str(tenant_id),
|
||||
"event_name": event_name,
|
||||
"payload": _json_payload(payload),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def process_outbox_batch(
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis | None = None,
|
||||
batch_size: int = 50,
|
||||
) -> int:
|
||||
"""Process one batch of pending outbox events.
|
||||
|
||||
1. Claim up to *batch_size* pending events using ``FOR UPDATE SKIP LOCKED``
|
||||
so multiple workers don't interfere.
|
||||
2. Publish each event to the in-process event bus (for local handlers).
|
||||
3. On success: mark as ``published``.
|
||||
4. On failure: increment attempts, schedule retry with exponential
|
||||
backoff, or mark as ``failed`` if max attempts exceeded.
|
||||
|
||||
Args:
|
||||
db: Async SQLAlchemy session for this batch.
|
||||
redis: Optional Redis client (unused for now, reserved for future
|
||||
cross-process pub/sub).
|
||||
batch_size: Maximum events to process in one batch.
|
||||
|
||||
Returns:
|
||||
Number of events successfully published.
|
||||
"""
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
event_bus = get_event_bus()
|
||||
published_count = 0
|
||||
|
||||
# Claim a batch of pending events
|
||||
rows = (
|
||||
await db.execute(_CLAIM_SQL, {"batch_size": batch_size})
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
for row in rows:
|
||||
event_id = row[0]
|
||||
event_name = row[2]
|
||||
payload = row[3]
|
||||
attempts = row[4]
|
||||
max_attempts = row[5]
|
||||
|
||||
# payload comes back as a dict from JSONB
|
||||
if isinstance(payload, str):
|
||||
import json
|
||||
payload_dict = json.loads(payload)
|
||||
else:
|
||||
payload_dict = payload
|
||||
|
||||
try:
|
||||
results = await event_bus.publish_with_results(event_name, payload_dict)
|
||||
# If any handler raised, treat as failure
|
||||
handler_errors = [r for r in results if r is not None]
|
||||
if handler_errors:
|
||||
raise handler_errors[0]
|
||||
await db.execute(_MARK_PUBLISHED_SQL, {"id": str(event_id)})
|
||||
published_count += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to publish outbox event %s (%s): %s",
|
||||
event_id, event_name, exc,
|
||||
exc_info=True,
|
||||
)
|
||||
new_attempts = attempts + 1
|
||||
if new_attempts >= max_attempts:
|
||||
await db.execute(_FAIL_SQL, {"id": str(event_id)})
|
||||
logger.warning(
|
||||
"Outbox event %s marked as failed after %d attempts",
|
||||
event_id, new_attempts,
|
||||
)
|
||||
else:
|
||||
backoff = timedelta(seconds=(2 ** new_attempts) * 10)
|
||||
next_retry = datetime.now(timezone.utc) + backoff
|
||||
await db.execute(
|
||||
_RETRY_SQL,
|
||||
{
|
||||
"id": str(event_id),
|
||||
"attempts": new_attempts,
|
||||
"next_retry_at": next_retry,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return published_count
|
||||
+215
-73
@@ -16,7 +16,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -30,6 +30,9 @@ logger = logging.getLogger(__name__)
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
CACHE_PREFIX = "resolved"
|
||||
|
||||
# Severity ordering for field permissions: highest wins
|
||||
_FIELD_PERM_SEVERITY = {"hidden": 3, "readonly": 2, "read": 1}
|
||||
|
||||
|
||||
def _matches_permission(granted: str, required: str) -> bool:
|
||||
"""Check if a granted permission matches the required permission.
|
||||
@@ -44,7 +47,6 @@ def _matches_permission(granted: str, required: str) -> bool:
|
||||
return True
|
||||
g_parts = granted.split(":")
|
||||
r_parts = required.split(":")
|
||||
# Wildcard * matches any single segment, but remaining segments must still match
|
||||
if len(g_parts) != len(r_parts):
|
||||
return False
|
||||
for i, g_part in enumerate(g_parts):
|
||||
@@ -88,6 +90,87 @@ def _normalize_permissions(permissions: Any) -> set[str]:
|
||||
return result
|
||||
|
||||
|
||||
def _merge_field_permissions(
|
||||
existing: dict[str, dict[str, str]],
|
||||
incoming: dict[str, Any],
|
||||
) -> None:
|
||||
"""Merge incoming field permissions into existing dict.
|
||||
|
||||
Uses 'strictest right wins': hidden > readonly > read.
|
||||
When a field already exists, the more restrictive (higher severity) value wins.
|
||||
"""
|
||||
for module, fields in incoming.items():
|
||||
if not isinstance(fields, dict):
|
||||
continue
|
||||
if module not in existing:
|
||||
existing[module] = {}
|
||||
for field, perm in fields.items():
|
||||
if not isinstance(perm, str):
|
||||
continue
|
||||
perm_lower = perm.lower()
|
||||
if perm_lower not in _FIELD_PERM_SEVERITY:
|
||||
# Unknown permission level — skip with warning
|
||||
logger.warning(
|
||||
"Unknown field permission level '%s' for %s.%s — skipping",
|
||||
perm, module, field,
|
||||
)
|
||||
continue
|
||||
current = existing[module].get(field)
|
||||
if current is None:
|
||||
existing[module][field] = perm_lower
|
||||
else:
|
||||
# Strictest (highest severity) wins
|
||||
if _FIELD_PERM_SEVERITY[perm_lower] > _FIELD_PERM_SEVERITY.get(current, 0):
|
||||
existing[module][field] = perm_lower
|
||||
|
||||
|
||||
async def _get_current_permission_version(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> int:
|
||||
"""Get the current max permission_version from DB for cache validation.
|
||||
|
||||
Uses a SAVEPOINT so that a failure here does not abort the outer transaction.
|
||||
"""
|
||||
async with db.begin_nested():
|
||||
# Check role version
|
||||
ut_q = select(UserTenant.role_id).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
role_id = ut_result.scalar_one_or_none()
|
||||
|
||||
max_version = 0
|
||||
if role_id is not None:
|
||||
role_q = select(Role.permission_version).where(Role.id == role_id)
|
||||
role_result = await db.execute(role_q)
|
||||
role_ver = role_result.scalar_one_or_none()
|
||||
if role_ver is not None:
|
||||
max_version = max(max_version, role_ver)
|
||||
|
||||
# Check group versions
|
||||
ug_q = select(UserGroup.group_id).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
ug_result = await db.execute(ug_q)
|
||||
group_ids = [row[0] for row in ug_result.all()]
|
||||
|
||||
if group_ids:
|
||||
groups_q = select(func.max(Group.permission_version)).where(
|
||||
Group.id.in_(group_ids),
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
groups_result = await db.execute(groups_q)
|
||||
group_max = groups_result.scalar()
|
||||
if group_max is not None:
|
||||
max_version = max(max_version, group_max)
|
||||
|
||||
return max_version
|
||||
|
||||
|
||||
async def resolve_permissions(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
@@ -104,18 +187,24 @@ async def resolve_permissions(
|
||||
"version": int, # permission_version for cache invalidation
|
||||
}
|
||||
"""
|
||||
# Check system admin first
|
||||
# If a previous query in this session failed, the transaction may be aborted.
|
||||
# Rollback to recover before executing our query.
|
||||
# Use SAVEPOINT for the initial query so a failure doesn't abort
|
||||
# the outer transaction.
|
||||
try:
|
||||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
is_system_admin = user_result.scalar() or False
|
||||
async with db.begin_nested():
|
||||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
is_system_admin = user_result.scalar() or False
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
is_system_admin = user_result.scalar() or False
|
||||
logger.warning(
|
||||
"SAVEPOINT failed for is_system_admin query (user=%s), retrying without savepoint",
|
||||
user_id,
|
||||
exc_info=True,
|
||||
)
|
||||
# Last-resort fallback: still use savepoint to isolate
|
||||
async with db.begin_nested():
|
||||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
is_system_admin = user_result.scalar() or False
|
||||
|
||||
if is_system_admin:
|
||||
return {
|
||||
@@ -126,13 +215,14 @@ async def resolve_permissions(
|
||||
"version": 0, # system admin doesn't need version tracking
|
||||
}
|
||||
|
||||
# Load UserTenant to get role_id
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
# Load UserTenant to get role_id — use SAVEPOINT
|
||||
async with db.begin_nested():
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
|
||||
allowed: set[str] = set()
|
||||
denied: set[str] = set()
|
||||
@@ -141,72 +231,75 @@ async def resolve_permissions(
|
||||
|
||||
# Load role permissions
|
||||
if user_tenant and user_tenant.role_id:
|
||||
role_q = select(Role).where(Role.id == user_tenant.role_id)
|
||||
role_result = await db.execute(role_q)
|
||||
role = role_result.scalar_one_or_none()
|
||||
async with db.begin_nested():
|
||||
role_q = select(Role).where(Role.id == user_tenant.role_id)
|
||||
role_result = await db.execute(role_q)
|
||||
role = role_result.scalar_one_or_none()
|
||||
|
||||
if role:
|
||||
allowed |= _normalize_permissions(role.permissions)
|
||||
denied |= _normalize_permissions(role.denied_permissions)
|
||||
max_version = max(max_version, role.permission_version)
|
||||
# Merge field permissions
|
||||
max_version = max(max_version, role.permission_version or 0)
|
||||
# Merge field permissions using strictest-wins
|
||||
if role.field_permissions:
|
||||
for module, fields in role.field_permissions.items():
|
||||
if isinstance(fields, dict):
|
||||
if module not in field_perms:
|
||||
field_perms[module] = {}
|
||||
field_perms[module].update(fields)
|
||||
_merge_field_permissions(field_perms, role.field_permissions)
|
||||
|
||||
# Also check built-in role string on UserTenant for backward compatibility
|
||||
if user_tenant is not None and user_tenant.role_id is None:
|
||||
legacy_role = user_tenant.role
|
||||
|
||||
# Also check legacy role string on User for backward compatibility
|
||||
if user_tenant is None or user_tenant.role_id is None:
|
||||
legacy_q = select(User.role).where(User.id == user_id)
|
||||
legacy_result = await db.execute(legacy_q)
|
||||
legacy_role = legacy_result.scalar_one_or_none()
|
||||
if legacy_role == "admin":
|
||||
allowed.add("*:*")
|
||||
elif legacy_role == "editor":
|
||||
allowed |= {"contacts:read", "contacts:write", "contacts:read", "contacts:write",
|
||||
"users:read", "roles:read", "audit:read", "attachments:read",
|
||||
"attachments:write", "workflows:read", "workflows:write",
|
||||
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
|
||||
"taxes:read", "taxes:write", "currencies:read", "currencies:write",
|
||||
"notifications:read", "notifications:write", "import_export:read",
|
||||
"import_export:write",
|
||||
"user_preferences:read", "user_preferences:write"}
|
||||
allowed |= {
|
||||
"contacts:read", "contacts:write",
|
||||
"users:read", "roles:read", "audit:read",
|
||||
"attachments:read", "attachments:write",
|
||||
"workflows:read", "workflows:write",
|
||||
"sequences:read", "sequences:write",
|
||||
"addresses:read", "addresses:write",
|
||||
"taxes:read", "taxes:write",
|
||||
"currencies:read", "currencies:write",
|
||||
"notifications:read", "notifications:write",
|
||||
"import_export:read", "import_export:write",
|
||||
"user_preferences:read", "user_preferences:write",
|
||||
}
|
||||
elif legacy_role == "viewer":
|
||||
allowed |= {"contacts:read", "contacts:read", "users:read", "roles:read",
|
||||
"audit:read", "attachments:read", "workflows:read", "sequences:read",
|
||||
"addresses:read", "taxes:read", "currencies:read",
|
||||
"notifications:read", "import_export:read",
|
||||
"user_preferences:read", "user_preferences:write"}
|
||||
allowed |= {
|
||||
"contacts:read", "users:read", "roles:read",
|
||||
"audit:read", "attachments:read", "workflows:read",
|
||||
"sequences:read", "addresses:read", "taxes:read",
|
||||
"currencies:read", "notifications:read",
|
||||
"import_export:read",
|
||||
"user_preferences:read", "user_preferences:write",
|
||||
}
|
||||
|
||||
# Load group permissions
|
||||
ug_q = select(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
ug_result = await db.execute(ug_q)
|
||||
user_groups = ug_result.scalars().all()
|
||||
async with db.begin_nested():
|
||||
ug_q = select(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
ug_result = await db.execute(ug_q)
|
||||
user_groups = ug_result.scalars().all()
|
||||
|
||||
if user_groups:
|
||||
group_ids = [ug.group_id for ug in user_groups]
|
||||
groups_q = select(Group).where(
|
||||
Group.id.in_(group_ids),
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
groups_result = await db.execute(groups_q)
|
||||
groups = groups_result.scalars().all()
|
||||
async with db.begin_nested():
|
||||
groups_q = select(Group).where(
|
||||
Group.id.in_(group_ids),
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
groups_result = await db.execute(groups_q)
|
||||
groups = groups_result.scalars().all()
|
||||
|
||||
for group in groups:
|
||||
allowed |= _normalize_permissions(group.permissions)
|
||||
denied |= _normalize_permissions(group.denied_permissions)
|
||||
max_version = max(max_version, group.permission_version)
|
||||
# Merge field permissions
|
||||
max_version = max(max_version, group.permission_version or 0)
|
||||
# Merge field permissions using strictest-wins
|
||||
if group.field_permissions:
|
||||
for module, fields in group.field_permissions.items():
|
||||
if isinstance(fields, dict):
|
||||
if module not in field_perms:
|
||||
field_perms[module] = {}
|
||||
field_perms[module].update(fields)
|
||||
_merge_field_permissions(field_perms, group.field_permissions)
|
||||
|
||||
# Apply deny list
|
||||
resolved = allowed - denied
|
||||
@@ -226,15 +319,42 @@ async def get_cached_permissions(
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Get resolved permissions from Redis cache or resolve from DB."""
|
||||
"""Get resolved permissions from Redis cache or resolve from DB.
|
||||
|
||||
Validates the cached permission_version against the current DB version.
|
||||
If they differ, the cache entry is stale and will be re-resolved.
|
||||
"""
|
||||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||||
|
||||
raw = await redis.get(cache_key)
|
||||
if raw is not None:
|
||||
data = json.loads(raw)
|
||||
return data
|
||||
cached_version = data.get("version", -1)
|
||||
|
||||
# Cache miss — resolve from DB
|
||||
# Validate cached version against current DB version
|
||||
try:
|
||||
current_version = await _get_current_permission_version(db, user_id, tenant_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to query current permission_version for cache validation "
|
||||
"(user=%s, tenant=%s) — using cached data",
|
||||
user_id, tenant_id,
|
||||
exc_info=True,
|
||||
)
|
||||
current_version = cached_version # assume cache is valid if we can't check
|
||||
|
||||
if cached_version == current_version:
|
||||
return data
|
||||
|
||||
# Version mismatch — invalidate stale cache and re-resolve
|
||||
logger.info(
|
||||
"Permission cache version mismatch for user=%s tenant=%s "
|
||||
"(cached=%s, current=%s) — re-resolving",
|
||||
user_id, tenant_id, cached_version, current_version,
|
||||
)
|
||||
await redis.delete(cache_key)
|
||||
|
||||
# Cache miss or stale — resolve from DB
|
||||
resolved = await resolve_permissions(db, user_id, tenant_id)
|
||||
|
||||
# Store in cache (convert sets to lists for JSON)
|
||||
@@ -263,11 +383,33 @@ async def invalidate_all_user_permissions(
|
||||
redis: aioredis.Redis,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Invalidate permission cache for all users in a tenant (e.g. after role/group change)."""
|
||||
"""Invalidate permission cache for all users in a tenant (e.g. after role/group change).
|
||||
|
||||
Uses SCAN (non-blocking) instead of KEYS to avoid blocking Redis.
|
||||
"""
|
||||
pattern = f"{CACHE_PREFIX}:*:{tenant_id}"
|
||||
keys = await redis.keys(pattern)
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
batch_size = 200
|
||||
cursor: int | bytes | str = 0
|
||||
deleted_count = 0
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis.scan(
|
||||
cursor=cursor,
|
||||
match=pattern,
|
||||
count=batch_size,
|
||||
)
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
deleted_count += len(keys)
|
||||
# SCAN returns cursor as bytes or int depending on redis-py version
|
||||
cursor_int = int(cursor) if cursor else 0
|
||||
if cursor_int == 0:
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Invalidated %d permission cache entries for tenant=%s",
|
||||
deleted_count, tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Generic finite state machine for domain entity status transitions.
|
||||
|
||||
Defines allowed state transitions for Contact and Workflow entities.
|
||||
Usage in Commands:
|
||||
|
||||
from app.core.state_machine import contact_state_machine
|
||||
contact_state_machine.transition(contact.status, "qualified")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class StateMachineError(Exception):
|
||||
"""Raised when an invalid state transition is attempted."""
|
||||
|
||||
|
||||
class StateMachine:
|
||||
"""Finite state machine that validates and executes state transitions.
|
||||
|
||||
Attributes:
|
||||
transitions: Mapping from a state to the list of states it can transition to.
|
||||
"""
|
||||
|
||||
def __init__(self, transitions: dict[str, list[str]]) -> None:
|
||||
self.transitions: dict[str, list[str]] = transitions
|
||||
|
||||
def can_transition(self, current: str, target: str) -> bool:
|
||||
"""Return True if transitioning from *current* to *target* is allowed."""
|
||||
allowed = self.transitions.get(current, [])
|
||||
return target in allowed
|
||||
|
||||
def transition(self, current: str, target: str) -> str:
|
||||
"""Validate and return the new state.
|
||||
|
||||
Raises:
|
||||
StateMachineError: if the transition is not allowed.
|
||||
"""
|
||||
if not self.can_transition(current, target):
|
||||
raise StateMachineError(
|
||||
f"Invalid state transition: '{current}' -> '{target}'. "
|
||||
f"Allowed targets from '{current}': {self.transitions.get(current, [])}"
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
# ── Contact lifecycle: lead → qualified → customer → inactive ──
|
||||
# Allows skipping 'qualified' and reactivation from inactive.
|
||||
contact_state_machine = StateMachine(
|
||||
transitions={
|
||||
"lead": ["qualified", "customer", "inactive"],
|
||||
"qualified": ["customer", "lead", "inactive"],
|
||||
"customer": ["inactive"],
|
||||
"inactive": ["lead"],
|
||||
}
|
||||
)
|
||||
|
||||
# ── Workflow lifecycle: draft → active → paused → completed → cancelled ──
|
||||
workflow_state_machine = StateMachine(
|
||||
transitions={
|
||||
"draft": ["active", "cancelled"],
|
||||
"active": ["paused", "completed", "cancelled"],
|
||||
"paused": ["active", "completed", "cancelled"],
|
||||
"completed": [],
|
||||
"cancelled": [],
|
||||
}
|
||||
)
|
||||
+85
-11
@@ -9,15 +9,18 @@ Configuration via environment variables:
|
||||
- S3_SECRET_KEY: Secret key
|
||||
- S3_REGION: Region (default: us-east-1)
|
||||
- S3_SECURE: Use HTTPS (default: true)
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import aiofiles
|
||||
|
||||
@@ -32,6 +35,11 @@ class StorageBackend(ABC):
|
||||
"""Save data to storage at the given path. Returns the full storage path."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
|
||||
"""Stream chunks to storage. Returns total bytes written."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def read(self, path: str) -> bytes:
|
||||
"""Read data from storage at the given path."""
|
||||
@@ -77,6 +85,18 @@ class LocalStorage(StorageBackend):
|
||||
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
|
||||
return path
|
||||
|
||||
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
|
||||
"""Stream chunks directly to a local file. Returns total bytes written."""
|
||||
full_path = self._full_path(path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
total = 0
|
||||
async with aiofiles.open(full_path, "wb") as f:
|
||||
async for chunk in chunk_aiter:
|
||||
await f.write(chunk)
|
||||
total += len(chunk)
|
||||
logger.debug("LocalStorage: streamed %s (%d bytes)", path, total)
|
||||
return total
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
full_path = self._full_path(path)
|
||||
async with aiofiles.open(full_path, "rb") as f:
|
||||
@@ -155,25 +175,33 @@ class S3Storage(StorageBackend):
|
||||
logger.error("S3Storage: failed to connect to %s: %s", self.endpoint, e)
|
||||
raise
|
||||
|
||||
async def save(self, path: str, data: bytes) -> str:
|
||||
from io import BytesIO
|
||||
# ── Sync helper methods (called via asyncio.to_thread) ──────────────────
|
||||
|
||||
def _save_sync(self, path: str, data: bytes) -> str:
|
||||
client = self._get_client()
|
||||
client.put_object(
|
||||
bucket_name=self.bucket,
|
||||
object_name=path,
|
||||
data=BytesIO(data),
|
||||
data=io.BytesIO(data),
|
||||
length=len(data),
|
||||
)
|
||||
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
|
||||
return path
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
def _put_file_sync(self, object_name: str, file_path: str) -> str:
|
||||
client = self._get_client()
|
||||
client.fput_object(self.bucket, object_name, file_path)
|
||||
return object_name
|
||||
|
||||
def _read_sync(self, path: str) -> bytes:
|
||||
client = self._get_client()
|
||||
response = client.get_object(self.bucket, path)
|
||||
return response.read()
|
||||
try:
|
||||
return response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
async def delete(self, path: str) -> bool:
|
||||
def _delete_sync(self, path: str) -> bool:
|
||||
client = self._get_client()
|
||||
try:
|
||||
client.remove_object(self.bucket, path)
|
||||
@@ -181,7 +209,7 @@ class S3Storage(StorageBackend):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def exists(self, path: str) -> bool:
|
||||
def _exists_sync(self, path: str) -> bool:
|
||||
client = self._get_client()
|
||||
try:
|
||||
client.stat_object(self.bucket, path)
|
||||
@@ -189,17 +217,63 @@ class S3Storage(StorageBackend):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||||
def _get_url_sync(self, path: str, expires: int) -> str:
|
||||
from datetime import timedelta
|
||||
|
||||
client = self._get_client()
|
||||
return client.presigned_get_object(self.bucket, path, expires=timedelta(seconds=expires))
|
||||
|
||||
async def list_files(self, prefix: str) -> list[str]:
|
||||
def _list_files_sync(self, prefix: str) -> list[str]:
|
||||
client = self._get_client()
|
||||
objects = client.list_objects(self.bucket, prefix=prefix, recursive=True)
|
||||
return [obj.object_name for obj in objects]
|
||||
|
||||
# ── Async public API (wraps sync calls in asyncio.to_thread) ─────────────
|
||||
|
||||
async def save(self, path: str, data: bytes) -> str:
|
||||
result = await asyncio.to_thread(self._save_sync, path, data)
|
||||
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
|
||||
return result
|
||||
|
||||
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
|
||||
"""Stream chunks to a temp file, then upload to S3 via fput_object.
|
||||
|
||||
This avoids loading the entire file into RAM. The temp file is
|
||||
cleaned up after upload.
|
||||
"""
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(prefix="s3_upload_")
|
||||
os.close(tmp_fd)
|
||||
total = 0
|
||||
try:
|
||||
async with aiofiles.open(tmp_path, "wb") as f:
|
||||
async for chunk in chunk_aiter:
|
||||
await f.write(chunk)
|
||||
total += len(chunk)
|
||||
await asyncio.to_thread(self._put_file_sync, path, tmp_path)
|
||||
logger.debug("S3Storage: streamed %s (%d bytes)", path, total)
|
||||
return total
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
logger.warning("S3Storage: failed to clean up temp file %s", tmp_path)
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
return await asyncio.to_thread(self._read_sync, path)
|
||||
|
||||
async def delete(self, path: str) -> bool:
|
||||
return await asyncio.to_thread(self._delete_sync, path)
|
||||
|
||||
async def exists(self, path: str) -> bool:
|
||||
return await asyncio.to_thread(self._exists_sync, path)
|
||||
|
||||
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||||
return await asyncio.to_thread(self._get_url_sync, path, expires)
|
||||
|
||||
async def list_files(self, prefix: str) -> list[str]:
|
||||
return await asyncio.to_thread(self._list_files_sync, prefix)
|
||||
|
||||
|
||||
# ─── Factory ───
|
||||
|
||||
|
||||
+106
-2
@@ -14,6 +14,73 @@ from app.core.job_registry import get_all_jobs, get_job, register_job
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Distributed lock helpers ─────────────────────────────────────────────────
|
||||
# When multiple worker replicas run concurrently, cron jobs must not fire
|
||||
# on every replica. We use a short-lived Redis SET NX lock per cron call
|
||||
# so only one replica actually executes the job.
|
||||
|
||||
import redis.asyncio as aioredis # noqa: E402
|
||||
import uuid # noqa: E402
|
||||
|
||||
|
||||
async def _acquire_cron_lock(job_name: str, ttl_seconds: int = 120) -> str | None:
|
||||
"""Try to acquire a distributed lock for a cron job.
|
||||
|
||||
Returns a lock token (random UUID) if acquired, or None if another
|
||||
replica already holds the lock. The lock auto-expires after
|
||||
*ttl_seconds* to avoid deadlocks if a worker crashes mid-job.
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = aioredis.from_url(settings.redis_url)
|
||||
token = str(uuid.uuid4())
|
||||
lock_key = f"leocrm:cron_lock:{job_name}"
|
||||
try:
|
||||
acquired = await client.set(lock_key, token, nx=True, ex=ttl_seconds)
|
||||
return token if acquired else None
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def _release_cron_lock(job_name: str, token: str) -> None:
|
||||
"""Release a previously acquired cron lock using a safe compare-and-delete."""
|
||||
settings = get_settings()
|
||||
client = aioredis.from_url(settings.redis_url)
|
||||
lock_key = f"leocrm:cron_lock:{job_name}"
|
||||
try:
|
||||
# Lua script ensures we only delete if the token matches (avoid
|
||||
# releasing a lock that was already expired and re-acquired).
|
||||
script = (
|
||||
b"if redis.call('get', KEYS[1]) == ARGV[1] "
|
||||
b"then return redis.call('del', KEYS[1]) "
|
||||
b"else return 0 end"
|
||||
)
|
||||
await client.eval(script, 1, lock_key, token.encode())
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def _wrap_cron_with_lock(job_name: str, func: Any, ttl_seconds: int = 120) -> Any:
|
||||
"""Wrap a cron callable so it acquires a distributed lock first.
|
||||
|
||||
If the lock cannot be acquired (another replica is handling it), the
|
||||
wrapped function is silently skipped.
|
||||
"""
|
||||
import functools
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _locked_wrapper(ctx: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||||
token = await _acquire_cron_lock(job_name, ttl_seconds=ttl_seconds)
|
||||
if token is None:
|
||||
logger.debug("Cron job '%s' skipped — lock held by another replica", job_name)
|
||||
return None
|
||||
try:
|
||||
return await func(ctx, *args, **kwargs)
|
||||
finally:
|
||||
await _release_cron_lock(job_name, token)
|
||||
|
||||
return _locked_wrapper
|
||||
|
||||
|
||||
def _get_redis_settings() -> RedisSettings:
|
||||
"""Get Redis settings from app config."""
|
||||
settings = get_settings()
|
||||
@@ -70,6 +137,32 @@ def _lazy_register_plugin_jobs() -> None:
|
||||
_lazy_register_plugin_jobs()
|
||||
|
||||
|
||||
# ── Outbox processor job ────────────────────────────────────────────────────
|
||||
|
||||
async def process_outbox_job(ctx: dict[str, Any]) -> None:
|
||||
"""Poll the transactional outbox and publish pending events.
|
||||
|
||||
Uses a distributed Redis lock so only one worker replica processes the
|
||||
outbox at a time. Runs every 5 seconds.
|
||||
"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.outbox import process_outbox_batch
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
count = await process_outbox_batch(db, batch_size=50)
|
||||
if count:
|
||||
logger.info("Outbox: published %d events", count)
|
||||
except Exception:
|
||||
logger.error("Outbox processing failed", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
# Register the outbox job so it appears in get_all_jobs()
|
||||
register_job("process_outbox", process_outbox_job)
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
"""ARQ worker settings."""
|
||||
functions = get_all_jobs()
|
||||
@@ -80,6 +173,17 @@ class WorkerSettings:
|
||||
job_timeout = 300
|
||||
queue_name = "arq:queue"
|
||||
cron_jobs = [
|
||||
cron(get_job("scheduler_tick"), minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
|
||||
cron(get_job("tasks_due_reminder"), hour=8, minute=0),
|
||||
cron(
|
||||
_wrap_cron_with_lock("scheduler_tick", get_job("scheduler_tick")),
|
||||
minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55},
|
||||
),
|
||||
cron(
|
||||
_wrap_cron_with_lock("tasks_due_reminder", get_job("tasks_due_reminder")),
|
||||
hour=8, minute=0,
|
||||
),
|
||||
# Outbox processor — every 5 seconds, guarded by distributed lock
|
||||
cron(
|
||||
_wrap_cron_with_lock("process_outbox", process_outbox_job, ttl_seconds=30),
|
||||
second="*/5",
|
||||
),
|
||||
]
|
||||
|
||||
+59
-35
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -13,6 +14,27 @@ 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
|
||||
|
||||
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",
|
||||
"attachments:write",
|
||||
"workflows:write",
|
||||
"sequences:write",
|
||||
"addresses:write",
|
||||
"taxes:write",
|
||||
"currencies:write",
|
||||
"notifications:write",
|
||||
"import_export:write",
|
||||
"user_preferences:write",
|
||||
]
|
||||
|
||||
|
||||
async def get_redis_dep() -> aioredis.Redis:
|
||||
"""FastAPI dependency for Redis client."""
|
||||
@@ -24,40 +46,13 @@ async def get_current_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
) -> dict[str, Any]:
|
||||
"""Get the current authenticated user from session cookie or internal headers.
|
||||
"""Get the current authenticated user from session cookie.
|
||||
|
||||
Returns session data dict with user_id, tenant_id, email, name, role,
|
||||
and resolved permissions from Redis cache.
|
||||
|
||||
Supports internal calls via X-Internal-Call: true header with
|
||||
X-Tenant-Id and X-User-Id headers (for AI tool API access).
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Check for internal call (AI tool access)
|
||||
if request.headers.get("X-Internal-Call") == "true":
|
||||
tenant_id_str = request.headers.get("X-Tenant-Id", "")
|
||||
user_id_str = request.headers.get("X-User-Id", "")
|
||||
if tenant_id_str and user_id_str:
|
||||
try:
|
||||
tenant_id = uuid.UUID(tenant_id_str)
|
||||
user_id = uuid.UUID(user_id_str)
|
||||
await set_tenant_context(db, tenant_id)
|
||||
|
||||
from app.core.permissions import get_cached_permissions
|
||||
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
||||
return {
|
||||
"user_id": user_id_str,
|
||||
"tenant_id": tenant_id_str,
|
||||
"permissions": resolved.get("permissions", []),
|
||||
"denied_permissions": resolved.get("denied", []),
|
||||
"field_permissions": resolved.get("field_permissions", {}),
|
||||
"is_system_admin": resolved.get("is_system_admin", False),
|
||||
"is_active": True,
|
||||
}
|
||||
except (ValueError, Exception):
|
||||
pass # Fall through to session cookie auth
|
||||
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
@@ -101,14 +96,29 @@ async def get_current_user(
|
||||
async def require_admin(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Require admin role (legacy + new permission system)."""
|
||||
if current_user.get("is_system_admin") or current_user.get("role") == "admin":
|
||||
"""Require admin role (legacy + new permission system).
|
||||
|
||||
Legacy role string 'admin' is deprecated — log a warning when used.
|
||||
New system uses is_system_admin or *:* permission.
|
||||
"""
|
||||
if current_user.get("is_system_admin"):
|
||||
return current_user
|
||||
# Also check via permission system
|
||||
|
||||
# Legacy role string fallback — deprecated
|
||||
if current_user.get("role") == "admin":
|
||||
logger.warning(
|
||||
"Legacy role string 'admin' used for user=%s — deprecated, "
|
||||
"migrate to is_system_admin or *:* permission",
|
||||
current_user.get("user_id"),
|
||||
)
|
||||
return current_user
|
||||
|
||||
# New permission system check
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
if check_permission(current_user, "*:*"):
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Admin access required", "code": "forbidden"},
|
||||
@@ -118,17 +128,31 @@ async def require_admin(
|
||||
async def require_write(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Require write permission (admin, editor, or custom role with write perms)."""
|
||||
"""Require write permission (admin, editor, or custom role with write perms).
|
||||
|
||||
Legacy role strings 'admin'/'editor' are deprecated — log a warning when used.
|
||||
New system checks specific module:write permissions instead of broad wildcards.
|
||||
"""
|
||||
if current_user.get("is_system_admin"):
|
||||
return current_user
|
||||
|
||||
# Legacy role string fallback — deprecated
|
||||
role = current_user.get("role", "viewer")
|
||||
if role in ("admin", "editor"):
|
||||
logger.warning(
|
||||
"Legacy role string '%s' used for user=%s in require_write — deprecated, "
|
||||
"migrate to specific module:write permissions",
|
||||
role, current_user.get("user_id"),
|
||||
)
|
||||
return current_user
|
||||
# Check via permission system for custom roles
|
||||
|
||||
# Check via permission system for specific write permissions
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
if check_permission(current_user, "*:write") or check_permission(current_user, "*:create"):
|
||||
return current_user
|
||||
for perm in _WRITE_PERMISSIONS:
|
||||
if check_permission(current_user, perm):
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Write access required", "code": "forbidden"},
|
||||
|
||||
+34
-29
@@ -100,6 +100,13 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
@asynccontextmanager
|
||||
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
|
||||
|
||||
await init_redis()
|
||||
await init_job_pool()
|
||||
|
||||
# Initialize service container
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
@@ -109,7 +116,7 @@ async def lifespan(app: FastAPI):
|
||||
registry.initialize(get_engine(), app)
|
||||
registry.discover_builtins()
|
||||
|
||||
# Auto-install and activate all discovered builtin plugins
|
||||
# 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
|
||||
@@ -131,18 +138,18 @@ async def lifespan(app: FastAPI):
|
||||
plugin_record = result.scalar_one_or_none()
|
||||
|
||||
if plugin_record is None:
|
||||
# Create DB record for this builtin plugin
|
||||
# Create DB record for this builtin plugin — inactive by default (except core)
|
||||
plugin_record = PluginModel(
|
||||
name=name,
|
||||
display_name=plugin.manifest.display_name,
|
||||
version=plugin.manifest.version,
|
||||
status="installed",
|
||||
active=True,
|
||||
active=plugin.manifest.is_core, # Only core plugins auto-activate
|
||||
is_core=plugin.manifest.is_core,
|
||||
)
|
||||
db.add(plugin_record)
|
||||
await db.flush()
|
||||
logger.info(f"Created plugin record: {name}")
|
||||
logger.info(f"Created plugin record: {name} (core={plugin.manifest.is_core})")
|
||||
|
||||
# Run migrations if not yet applied
|
||||
if plugin.manifest.migrations:
|
||||
@@ -151,7 +158,17 @@ async def lifespan(app: FastAPI):
|
||||
db, name, plugin.manifest.migrations
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Migration for {name}: {exc}")
|
||||
logger.error(f"Migration FAILED for {name}: {exc}")
|
||||
if plugin_record.active:
|
||||
logger.error(f"Deactivating plugin {name} due to migration failure")
|
||||
plugin_record.active = False
|
||||
plugin_record.status = "migration_failed"
|
||||
continue # Skip activation if migration fails
|
||||
|
||||
# Only activate plugins that are marked active in DB
|
||||
if not plugin_record.active:
|
||||
logger.info(f"Plugin {name} is inactive — skipping activation")
|
||||
continue
|
||||
|
||||
# Activate plugin and register routes
|
||||
try:
|
||||
@@ -160,13 +177,14 @@ async def lifespan(app: FastAPI):
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
app.include_router(router)
|
||||
plugin_record.active = True
|
||||
plugin_record.status = "active"
|
||||
print(f"[STARTUP] Activated plugin: {name} ({len(plugin.manifest.routes)} routes)", flush=True)
|
||||
logger.info(f"Activated plugin: {name} ({len(plugin.manifest.routes)} routes)")
|
||||
except Exception as exc:
|
||||
print(f"[STARTUP] Failed to activate plugin {name}: {exc}", flush=True)
|
||||
logger.warning(f"Failed to activate plugin {name}: {exc}")
|
||||
logger.error(f"Failed to activate plugin {name}: {exc}")
|
||||
plugin_record.active = False
|
||||
plugin_record.status = "activation_failed"
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -186,15 +204,15 @@ async def lifespan(app: FastAPI):
|
||||
init_permission_registry(active_plugin_names)
|
||||
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Register field definitions from active plugins
|
||||
# Register field definitions from active plugins only
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
for name in registry._plugins:
|
||||
for name in active_plugin_names:
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin:
|
||||
field_defs = plugin.get_field_definitions()
|
||||
if field_defs:
|
||||
get_permission_registry().register_field_definitions(name, field_defs)
|
||||
logger.info("Field definitions registered for %d plugins", len(registry._plugins))
|
||||
logger.info("Field definitions registered for %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
|
||||
from app.core.seeds import seed_default_data
|
||||
@@ -210,6 +228,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown: close global Redis and ARQ pool
|
||||
await close_job_pool()
|
||||
await close_redis()
|
||||
await close_engine()
|
||||
|
||||
|
||||
@@ -314,24 +335,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
|
||||
# ── Register plugin routes (before SPA catch-all) ──────────────────
|
||||
registry = get_registry()
|
||||
try:
|
||||
registry.discover_builtins()
|
||||
for name in registry._plugins:
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin is None:
|
||||
continue
|
||||
for route_def in plugin.manifest.routes:
|
||||
try:
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
app.include_router(router)
|
||||
logger.info(f"Registered plugin routes: {name}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to register routes for plugin {name}: {exc}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Plugin discovery failed: {exc}")
|
||||
# ── Plugin routes are registered in lifespan() after activation status is loaded ──
|
||||
# Do NOT register plugin routes here — lifespan() handles it for active plugins only
|
||||
|
||||
# ── Serve frontend static files (SPA) ──────────────────────────────
|
||||
# Mount built frontend assets (JS, CSS, images)
|
||||
@@ -351,7 +356,7 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
# Block path traversal and system file access
|
||||
blocked_prefixes = ("var/log/", "error/", "error_log", "var/", "etc/", "proc/", "sys/")
|
||||
if full_path.startswith(blocked_prefixes) or "/../" in full_path or full_path.endswith("/.."):
|
||||
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):
|
||||
|
||||
+19
-11
@@ -8,17 +8,20 @@ ansprechpartner (company employees / contact persons).
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Computed,
|
||||
ForeignKey,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
Float,
|
||||
JSON,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -37,6 +40,8 @@ class Contact(Base, TenantMixin):
|
||||
|
||||
__tablename__ = "contacts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
|
||||
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
||||
Index("ix_contacts_tenant_type", "tenant_id", "type"),
|
||||
Index("ix_contacts_tenant_name", "tenant_id", "name"),
|
||||
@@ -53,10 +58,13 @@ class Contact(Base, TenantMixin):
|
||||
# ── Identity & Type ──
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
|
||||
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
|
||||
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
|
||||
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
surfix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
||||
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
||||
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
|
||||
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
@@ -116,12 +124,12 @@ class Contact(Base, TenantMixin):
|
||||
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
|
||||
|
||||
# ── Discounts ──
|
||||
discount_crew: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_transport: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_rental: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_sale: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_subrent: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_total: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
|
||||
# ── Geo ──
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
@@ -151,7 +159,7 @@ class Contact(Base, TenantMixin):
|
||||
)
|
||||
|
||||
# ── Custom fields ──
|
||||
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
|
||||
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
||||
|
||||
# ── FTS ──
|
||||
search_tsv: Mapped[Any] = mapped_column(
|
||||
@@ -222,7 +230,7 @@ class ContactPerson(Base, TenantMixin):
|
||||
|
||||
# ── Other ──
|
||||
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
|
||||
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
||||
|
||||
# ── Audit ──
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""SQLAlchemy model for the transactional event outbox table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class EventOutbox(Base):
|
||||
"""Row in the ``event_outbox`` table.
|
||||
|
||||
Each row represents a domain event that was written within a business
|
||||
transaction and is waiting to be published to the in-process event bus
|
||||
by the outbox worker.
|
||||
"""
|
||||
|
||||
__tablename__ = "event_outbox"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=False, index=True,
|
||||
)
|
||||
event_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default="pending",
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0",
|
||||
)
|
||||
max_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="5",
|
||||
)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
+19
-14
@@ -6,36 +6,28 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.core.db import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class User(Base, TenantMixin):
|
||||
"""User entity — belongs to a tenant, can be member of multiple tenants."""
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""User entity — globally unique email, tenant membership via UserTenant."""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
is_system_admin: Mapped[bool] = mapped_column(
|
||||
@@ -44,7 +36,13 @@ class User(Base, TenantMixin):
|
||||
|
||||
|
||||
class UserTenant(Base):
|
||||
"""N:M association — user membership in tenants."""
|
||||
"""N:M association — user membership in tenants.
|
||||
|
||||
Single source of truth for tenant membership and role assignment.
|
||||
``role`` is a built-in role string (admin/editor/viewer).
|
||||
``role_id`` links to a custom Role record for granular RBAC.
|
||||
``status`` tracks membership lifecycle (active/invited/disabled).
|
||||
"""
|
||||
|
||||
__tablename__ = "user_tenants"
|
||||
|
||||
@@ -55,12 +53,19 @@ class UserTenant(Base):
|
||||
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -43,14 +43,14 @@ async def login(
|
||||
settings.rate_limit_login_window,
|
||||
)
|
||||
|
||||
result = await auth_service.login(db, redis, body.email, body.password)
|
||||
result = await auth_service.login(db, redis, body.email, body.password, body.tenant_slug)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Invalid email or password", "code": "invalid_credentials"},
|
||||
)
|
||||
|
||||
session_id, csrf_token, user, tenant = result
|
||||
session_id, csrf_token, user, tenant, role = result
|
||||
|
||||
# Reset rate limit on success
|
||||
await reset_rate_limit(f"auth:login:{ip}:{body.email}")
|
||||
@@ -74,7 +74,7 @@ async def login(
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role": role,
|
||||
"is_system_admin": user.is_system_admin,
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.name,
|
||||
|
||||
+51
-37
@@ -1,8 +1,11 @@
|
||||
"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete."""
|
||||
"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete.
|
||||
|
||||
Write operations (create, update, delete, merge) are delegated to Commands.
|
||||
Read operations (list, get, export, contact persons) use services directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from typing import Any
|
||||
@@ -11,8 +14,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.commands.contact_commands import (
|
||||
CreateContactCommand,
|
||||
UpdateContactCommand,
|
||||
DeleteContactCommand,
|
||||
MergeContactsCommand,
|
||||
)
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_permission
|
||||
from app.deps import get_current_user, get_redis_dep, require_permission
|
||||
from app.schemas.contact import (
|
||||
ContactCreate,
|
||||
ContactUpdate,
|
||||
@@ -89,13 +100,16 @@ async def export_contacts(
|
||||
async def create_contact(
|
||||
body: ContactCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
):
|
||||
"""Create a new contact (company or person)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
"""Create a new contact (company or person) via CreateContactCommand."""
|
||||
data = body.model_dump(exclude_none=True)
|
||||
return await contact_service.create_contact(db, tenant_id, user_id, data)
|
||||
cmd = CreateContactCommand(data=data)
|
||||
result = await cmd.execute(db, redis, current_user)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result.error)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.get("/merge-history")
|
||||
@@ -129,16 +143,20 @@ async def update_contact(
|
||||
contact_id: str,
|
||||
body: ContactUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
):
|
||||
"""Update a contact."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
"""Update a contact via UpdateContactCommand."""
|
||||
data = body.model_dump(exclude_none=True)
|
||||
try:
|
||||
return await contact_service.update_contact(db, tenant_id, user_id, contact_id, data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
cmd = UpdateContactCommand(contact_id=contact_id, data=data)
|
||||
result = await cmd.execute(db, redis, current_user)
|
||||
if not result.success:
|
||||
if "not found" in (result.error or "").lower():
|
||||
raise HTTPException(status_code=404, detail=result.error)
|
||||
if "Invalid state transition" in (result.error or ""):
|
||||
raise HTTPException(status_code=422, detail=result.error)
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -146,18 +164,15 @@ async def delete_contact(
|
||||
contact_id: str,
|
||||
hard: bool = Query(False, description="GDPR hard-delete"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
):
|
||||
"""Soft-delete (or hard-delete with ?hard=true) a contact."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
if hard:
|
||||
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
|
||||
else:
|
||||
await contact_service.delete_contact(db, tenant_id, contact_id, user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
"""Soft-delete (or hard-delete with ?hard=true) a contact via DeleteContactCommand."""
|
||||
cmd = DeleteContactCommand(contact_id=contact_id, hard=hard)
|
||||
result = await cmd.execute(db, redis, current_user)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=404, detail=result.error)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ── ContactPersons ──
|
||||
@@ -244,18 +259,17 @@ async def find_duplicate_contacts(
|
||||
async def merge_duplicate_contacts(
|
||||
body: MergeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
):
|
||||
"""Merge two contacts (source → target)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
return await dedup_service.merge_contacts(
|
||||
db, tenant_id, user_id,
|
||||
source_id=body.source_contact_id,
|
||||
target_id=body.target_contact_id,
|
||||
field_overrides=body.field_overrides,
|
||||
note=body.note,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
"""Merge two contacts (source → target) via MergeContactsCommand."""
|
||||
cmd = MergeContactsCommand(
|
||||
source_contact_id=body.source_contact_id,
|
||||
target_contact_id=body.target_contact_id,
|
||||
field_overrides=body.field_overrides,
|
||||
note=body.note,
|
||||
)
|
||||
result = await cmd.execute(db, redis, current_user)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
return result.data
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core.monitoring import generate_metrics
|
||||
from app.deps import get_current_user
|
||||
from app.deps import require_admin
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["metrics"])
|
||||
@router.get(
|
||||
"/api/v1/metrics",
|
||||
response_class=PlainTextResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def metrics():
|
||||
"""Prometheus metrics endpoint.
|
||||
|
||||
+14
-204
@@ -442,104 +442,13 @@ async def upload_plugin(
|
||||
):
|
||||
"""Upload and install a plugin from a ZIP file.
|
||||
|
||||
The ZIP must contain a plugin directory with a plugin.py that defines a BasePlugin subclass.
|
||||
Validates the manifest, checks for conflicts, runs migrations, and installs the plugin.
|
||||
DISABLED — Plugin upload is deactivated due to security vulnerabilities (RCE via exec_module before validation).
|
||||
Will be re-enabled with signed plugin artifacts and sandboxed execution.
|
||||
"""
|
||||
import uuid as uuid_mod
|
||||
|
||||
# Validate file is a ZIP
|
||||
if not file.filename or not file.filename.endswith(".zip"):
|
||||
raise HTTPException(400, detail={"detail": "File must be a .zip archive", "code": "invalid_file"})
|
||||
|
||||
# Check file size
|
||||
contents = await file.read()
|
||||
if len(contents) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
413,
|
||||
detail={
|
||||
"detail": f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB",
|
||||
"code": "file_too_large",
|
||||
},
|
||||
)
|
||||
|
||||
# Write to temp file
|
||||
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
|
||||
try:
|
||||
tmp_zip.write(contents)
|
||||
tmp_zip.close()
|
||||
|
||||
# Extract and validate
|
||||
extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name)
|
||||
|
||||
# Check for name conflicts with existing plugins
|
||||
service = get_plugin_service()
|
||||
existing_plugins = await service.list_plugins(db)
|
||||
existing_names = {p["name"] for p in existing_plugins}
|
||||
|
||||
if plugin_name in existing_names:
|
||||
# Check if version is higher
|
||||
existing_plugin = next(
|
||||
(p for p in existing_plugins if p["name"] == plugin_name), None
|
||||
)
|
||||
if existing_plugin:
|
||||
raise HTTPException(
|
||||
409,
|
||||
detail={
|
||||
"detail": f"Plugin '{plugin_name}' already exists (version {existing_plugin.get('version', 'unknown')}). "
|
||||
f"Uninstall the existing plugin first or upload a higher version.",
|
||||
"code": "plugin_exists",
|
||||
},
|
||||
)
|
||||
|
||||
# Install the plugin directory
|
||||
_install_plugin_from_dir(extract_dir, plugin_name, plugin_class)
|
||||
|
||||
# Run migrations and install via service
|
||||
result = await service.install_plugin(
|
||||
db,
|
||||
plugin_name,
|
||||
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||
)
|
||||
|
||||
# Log audit
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db,
|
||||
uuid_mod.UUID(current_user["tenant_id"]),
|
||||
uuid_mod.UUID(current_user["user_id"]),
|
||||
action="plugin.upload",
|
||||
entity_type="plugin",
|
||||
changes={"name": plugin_name, "version": result.get("version"), "method": "upload"},
|
||||
)
|
||||
|
||||
return {
|
||||
**result,
|
||||
"message": f"Plugin '{plugin_name}' uploaded and installed successfully",
|
||||
}
|
||||
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None
|
||||
except MigrationValidationError as exc:
|
||||
raise HTTPException(
|
||||
422, detail={"detail": str(exc), "code": "migration_validation_error"}
|
||||
) from None
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to upload plugin")
|
||||
raise HTTPException(
|
||||
500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"}
|
||||
) from None
|
||||
finally:
|
||||
# Clean up temp files
|
||||
try:
|
||||
os.unlink(tmp_zip.name)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "extract_dir" in dir():
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"detail": "Plugin upload is disabled. Use signed plugin artifacts from the allowlist.", "code": "upload_disabled"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/install-url")
|
||||
@@ -548,111 +457,12 @@ async def install_plugin_from_url(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("plugins:configure")),
|
||||
):
|
||||
"""Install a plugin from a URL (downloads ZIP and installs)."""
|
||||
import uuid as uuid_mod
|
||||
"""Install a plugin from a URL (downloads ZIP and installs).
|
||||
|
||||
if not body.url:
|
||||
raise HTTPException(400, detail={"detail": "URL is required", "code": "missing_url"})
|
||||
|
||||
# Download ZIP from URL
|
||||
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(body.url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
413,
|
||||
detail={
|
||||
"detail": f"Downloaded file too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB",
|
||||
"code": "file_too_large",
|
||||
},
|
||||
)
|
||||
|
||||
tmp_zip.write(content)
|
||||
tmp_zip.close()
|
||||
|
||||
# Extract and validate
|
||||
extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name)
|
||||
|
||||
# Check for name conflicts
|
||||
service = get_plugin_service()
|
||||
existing_plugins = await service.list_plugins(db)
|
||||
existing_names = {p["name"] for p in existing_plugins}
|
||||
|
||||
if plugin_name in existing_names:
|
||||
raise HTTPException(
|
||||
409,
|
||||
detail={
|
||||
"detail": f"Plugin '{plugin_name}' already exists. Uninstall the existing plugin first.",
|
||||
"code": "plugin_exists",
|
||||
},
|
||||
)
|
||||
|
||||
# Install the plugin directory
|
||||
_install_plugin_from_dir(extract_dir, plugin_name, plugin_class)
|
||||
|
||||
# Run migrations and install via service
|
||||
result = await service.install_plugin(
|
||||
db,
|
||||
plugin_name,
|
||||
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||
)
|
||||
|
||||
# Log audit
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db,
|
||||
uuid_mod.UUID(current_user["tenant_id"]),
|
||||
uuid_mod.UUID(current_user["user_id"]),
|
||||
action="plugin.install_url",
|
||||
entity_type="plugin",
|
||||
changes={"name": plugin_name, "version": result.get("version"), "url": body.url},
|
||||
)
|
||||
|
||||
return {
|
||||
**result,
|
||||
"message": f"Plugin '{plugin_name}' downloaded and installed successfully",
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"detail": f"Failed to download plugin from URL: HTTP {exc.response.status_code}",
|
||||
"code": "download_error",
|
||||
},
|
||||
) from None
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"detail": f"Failed to download plugin from URL: {str(exc)}",
|
||||
"code": "download_error",
|
||||
},
|
||||
) from None
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None
|
||||
except MigrationValidationError as exc:
|
||||
raise HTTPException(
|
||||
422, detail={"detail": str(exc), "code": "migration_validation_error"}
|
||||
) from None
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to install plugin from URL")
|
||||
raise HTTPException(
|
||||
500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"}
|
||||
) from None
|
||||
finally:
|
||||
# Clean up temp files
|
||||
try:
|
||||
os.unlink(tmp_zip.name)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "extract_dir" in dir():
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
DISABLED — URL installation is deactivated due to SSRF and RCE vulnerabilities.
|
||||
Will be re-enabled with signed plugin artifacts and allowlist.
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"detail": "Plugin URL installation is disabled. Use signed plugin artifacts from the allowlist.", "code": "install_url_disabled"},
|
||||
)
|
||||
|
||||
+22
-25
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis
|
||||
@@ -14,6 +15,7 @@ from app.core.db import get_db
|
||||
from app.core.notifications import create_notification
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.models.user import User, UserTenant
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PaginatedUsers
|
||||
from app.services.user_service import user_service, _UNSET
|
||||
|
||||
@@ -107,10 +109,10 @@ async def create_user(
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"role": body.role,
|
||||
"role_id": str(role_id) if role_id else None,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -129,18 +131,19 @@ async def get_user(
|
||||
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
user = await user_service.get_user(db, tenant_id, uid)
|
||||
if user is None:
|
||||
result = await user_service.get_user(db, tenant_id, uid)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
|
||||
user, user_tenant = result
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"role": user_tenant.role,
|
||||
"role_id": str(user_tenant.role_id) if user_tenant.role_id else None,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_id": str(user_tenant.tenant_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +214,7 @@ async def update_user(
|
||||
changes["password_changed"] = True
|
||||
|
||||
try:
|
||||
user = await user_service.update_user(
|
||||
result = await user_service.update_user(
|
||||
db,
|
||||
tenant_id,
|
||||
uid,
|
||||
@@ -228,9 +231,10 @@ async def update_user(
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None
|
||||
if user is None:
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
|
||||
user, user_tenant = result
|
||||
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
|
||||
|
||||
# Invalidate permission cache for the updated user
|
||||
@@ -244,10 +248,10 @@ async def update_user(
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"role": user_tenant.role,
|
||||
"role_id": str(user_tenant.role_id) if user_tenant.role_id else None,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_id": str(user_tenant.tenant_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -268,9 +272,10 @@ async def delete_user(
|
||||
) from None
|
||||
|
||||
# Get user snapshot for audit before deletion
|
||||
user = await user_service.get_user(db, tenant_id, uid)
|
||||
if user is None:
|
||||
result = await user_service.get_user(db, tenant_id, uid)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
user, user_tenant = result
|
||||
|
||||
success = await user_service.delete_user(db, tenant_id, uid)
|
||||
if not success:
|
||||
@@ -295,14 +300,10 @@ async def get_menu_order(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get the current user's menu order preference."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
@@ -319,12 +320,8 @@ async def update_menu_order(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's menu order preference."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
menu_order = body.get("menu_order")
|
||||
if not isinstance(menu_order, list) or not all(isinstance(x, str) for x in menu_order):
|
||||
raise HTTPException(
|
||||
@@ -333,7 +330,7 @@ async def update_menu_order(
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, EmailStr, Field
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr = Field(..., examples=["admin@leocrm.local"])
|
||||
password: str = Field(..., min_length=1, examples=["secure-password"])
|
||||
tenant_slug: str | None = Field(None, description="Tenant slug to select tenant at login", examples=["tenant-a"])
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
|
||||
+26
-21
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -70,10 +72,11 @@ class ContactPersonResponse(BaseModel):
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
type: str = Field("company", pattern="^(company|person)$")
|
||||
status: str | None = Field(None, pattern="^(lead|qualified|customer|inactive)$")
|
||||
name: str | None = Field(None, max_length=255)
|
||||
firstname: str | None = Field(None, max_length=100)
|
||||
surname: str | None = Field(None, max_length=100)
|
||||
surfix: str | None = Field(None, max_length=50)
|
||||
suffix: str | None = Field(None, max_length=50)
|
||||
ext_name_line: str | None = Field(None, max_length=255)
|
||||
gender: str | None = Field(None, max_length=20)
|
||||
code: str | None = Field(None, max_length=100)
|
||||
@@ -124,12 +127,12 @@ class ContactCreate(BaseModel):
|
||||
bic: str | None = Field(None, max_length=50)
|
||||
bank_account: str | None = Field(None, max_length=50)
|
||||
# Discounts
|
||||
discount_crew: float = 0
|
||||
discount_transport: float = 0
|
||||
discount_rental: float = 0
|
||||
discount_sale: float = 0
|
||||
discount_subrent: float = 0
|
||||
discount_total: float = 0
|
||||
discount_crew: Decimal = Decimal("0")
|
||||
discount_transport: Decimal = Decimal("0")
|
||||
discount_rental: Decimal = Decimal("0")
|
||||
discount_sale: Decimal = Decimal("0")
|
||||
discount_subrent: Decimal = Decimal("0")
|
||||
discount_total: Decimal = Decimal("0")
|
||||
# Geo
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
@@ -149,10 +152,11 @@ class ContactCreate(BaseModel):
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
type: str | None = Field(None, pattern="^(company|person)$")
|
||||
status: str | None = Field(None, pattern="^(lead|qualified|customer|inactive)$")
|
||||
name: str | None = Field(None, max_length=255)
|
||||
firstname: str | None = Field(None, max_length=100)
|
||||
surname: str | None = Field(None, max_length=100)
|
||||
surfix: str | None = Field(None, max_length=50)
|
||||
suffix: str | None = Field(None, max_length=50)
|
||||
ext_name_line: str | None = Field(None, max_length=255)
|
||||
gender: str | None = Field(None, max_length=20)
|
||||
code: str | None = Field(None, max_length=100)
|
||||
@@ -196,12 +200,12 @@ class ContactUpdate(BaseModel):
|
||||
purchase_number: str | None = Field(None, max_length=100)
|
||||
bic: str | None = Field(None, max_length=50)
|
||||
bank_account: str | None = Field(None, max_length=50)
|
||||
discount_crew: float | None = None
|
||||
discount_transport: float | None = None
|
||||
discount_rental: float | None = None
|
||||
discount_sale: float | None = None
|
||||
discount_subrent: float | None = None
|
||||
discount_total: float | None = None
|
||||
discount_crew: Decimal | None = None
|
||||
discount_transport: Decimal | None = None
|
||||
discount_rental: Decimal | None = None
|
||||
discount_sale: Decimal | None = None
|
||||
discount_subrent: Decimal | None = None
|
||||
discount_total: Decimal | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
projectnote: str | None = None
|
||||
@@ -219,10 +223,11 @@ class ContactResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
displayname: str
|
||||
status: str = "lead"
|
||||
name: str | None = None
|
||||
firstname: str | None = None
|
||||
surname: str | None = None
|
||||
surfix: str | None = None
|
||||
suffix: str | None = None
|
||||
ext_name_line: str | None = None
|
||||
gender: str | None = None
|
||||
code: str | None = None
|
||||
@@ -267,12 +272,12 @@ class ContactResponse(BaseModel):
|
||||
purchase_number: str | None = None
|
||||
bic: str | None = None
|
||||
bank_account: str | None = None
|
||||
discount_crew: float = 0
|
||||
discount_transport: float = 0
|
||||
discount_rental: float = 0
|
||||
discount_sale: float = 0
|
||||
discount_subrent: float = 0
|
||||
discount_total: float = 0
|
||||
discount_crew: Decimal = Decimal("0")
|
||||
discount_transport: Decimal = Decimal("0")
|
||||
discount_rental: Decimal = Decimal("0")
|
||||
discount_sale: Decimal = Decimal("0")
|
||||
discount_subrent: Decimal = Decimal("0")
|
||||
discount_total: Decimal = Decimal("0")
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
projectnote: str | None = None
|
||||
|
||||
+119
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -14,6 +15,7 @@ from app.config import get_settings
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import (
|
||||
create_session,
|
||||
get_redis,
|
||||
get_session_data,
|
||||
hash_password,
|
||||
hash_token,
|
||||
@@ -25,6 +27,8 @@ from app.models.auth import PasswordResetToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handles authentication operations."""
|
||||
@@ -36,11 +40,15 @@ class AuthService:
|
||||
email: str,
|
||||
password: str,
|
||||
tenant_slug: str | None = None,
|
||||
) -> tuple[str, str, User, Tenant] | None:
|
||||
) -> tuple[str, str, User, Tenant, str] | None:
|
||||
"""Authenticate user and create session.
|
||||
Returns (session_id, csrf_token, user, tenant) or None.
|
||||
Returns (session_id, csrf_token, user, tenant, role) or None.
|
||||
|
||||
Email is globally unique so we can safely use scalar_one_or_none().
|
||||
The tenant is resolved from UserTenant via tenant_slug or the
|
||||
user's default tenant membership.
|
||||
"""
|
||||
# Find user by email — need to check across tenants or use default tenant
|
||||
# Find user by email (globally unique now)
|
||||
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -75,7 +83,9 @@ class AuthService:
|
||||
if tenant is None:
|
||||
return None
|
||||
|
||||
session_id, csrf_token = await create_session(db, redis, user, tenant.id)
|
||||
session_id, csrf_token = await create_session(
|
||||
db, redis, user, tenant.id, role=user_tenant.role
|
||||
)
|
||||
|
||||
# Log the login in audit trail
|
||||
await log_audit(
|
||||
@@ -88,7 +98,7 @@ class AuthService:
|
||||
changes={"email": email},
|
||||
)
|
||||
|
||||
return session_id, csrf_token, user, tenant
|
||||
return session_id, csrf_token, user, tenant, user_tenant.role
|
||||
|
||||
async def logout(self, redis: aioredis.Redis, session_id: str) -> bool:
|
||||
"""Invalidate a session."""
|
||||
@@ -141,10 +151,11 @@ class AuthService:
|
||||
UserTenant.tenant_id == new_tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
if ut_result.scalar_one_or_none() is None:
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
updated = await update_session_tenant(redis, session_id, new_tenant_id)
|
||||
updated = await update_session_tenant(redis, session_id, new_tenant_id, role=user_tenant.role)
|
||||
if updated is None:
|
||||
return None
|
||||
|
||||
@@ -169,6 +180,22 @@ class AuthService:
|
||||
if user is None:
|
||||
return True # Don't reveal whether email exists
|
||||
|
||||
# Resolve tenant_id from UserTenant (default or specified)
|
||||
ut_q = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
if tenant_id is not None:
|
||||
ut_q = ut_q.where(UserTenant.tenant_id == tenant_id)
|
||||
else:
|
||||
ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
# Fallback: get first tenant membership
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return True
|
||||
|
||||
# Invalidate previous unused tokens
|
||||
prev_q = select(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user.id,
|
||||
@@ -187,7 +214,7 @@ class AuthService:
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_id=user_tenant.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
@@ -195,8 +222,26 @@ class AuthService:
|
||||
db.add(reset_token)
|
||||
await db.flush()
|
||||
|
||||
# In production: send email via SMTP. For now, log it.
|
||||
# The raw_token would be in the email link.
|
||||
# Enqueue ARQ job to send the password reset email
|
||||
try:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
await enqueue_job(
|
||||
"send_password_reset_email",
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
raw_token=raw_token,
|
||||
expires_at=expires_at.isoformat(),
|
||||
)
|
||||
logger.info("Enqueued password reset email job for user %s", user.id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ARQ enqueue failed for password reset email — "
|
||||
"raw_token for development: %s",
|
||||
raw_token,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def confirm_password_reset(
|
||||
@@ -232,13 +277,46 @@ class AuthService:
|
||||
reset_token.used_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Invalidate all active Redis sessions for this user
|
||||
try:
|
||||
redis = get_redis()
|
||||
# Scan for session keys and check which belong to this user
|
||||
import json
|
||||
|
||||
async for key in redis.scan_iter(match="session:*", count=100):
|
||||
raw = await redis.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
session_data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if session_data.get("user_id") == str(user.id):
|
||||
await redis.delete(key)
|
||||
logger.info("Deleted session %s for user %s after password reset", key, user.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate Redis sessions for user %s", user.id, exc_info=True)
|
||||
|
||||
# Audit log entry for password reset
|
||||
try:
|
||||
await log_audit(
|
||||
db,
|
||||
reset_token.tenant_id,
|
||||
user.id,
|
||||
"password_reset",
|
||||
"user",
|
||||
user.id,
|
||||
changes={"action": "password_changed"},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to create audit log for password reset of user %s", user.id, exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None:
|
||||
"""Get the raw (unhashed) reset token for testing purposes.
|
||||
This simulates what would be sent via email.
|
||||
"""
|
||||
# This is a test helper — in production the token goes via email only
|
||||
import secrets
|
||||
|
||||
q = select(User).where(User.email == email)
|
||||
@@ -247,13 +325,27 @@ class AuthService:
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
# Get tenant_id from UserTenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user.id,
|
||||
UserTenant.is_default == True, # noqa: E712
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
settings = get_settings()
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_id=user_tenant.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
@@ -272,12 +364,26 @@ class AuthService:
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
# Get tenant_id from UserTenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user.id,
|
||||
UserTenant.is_default == True, # noqa: E712
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_id=user_tenant.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.services.entity_history_service import record_history
|
||||
def _compute_displayname(data: dict) -> str:
|
||||
"""Compute displayname from type and name fields."""
|
||||
if data.get("type") == "person":
|
||||
parts = [data.get("surfix"), data.get("firstname"), data.get("surname")]
|
||||
parts = [data.get("suffix"), data.get("firstname"), data.get("surname")]
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
else:
|
||||
return data.get("name") or ""
|
||||
@@ -30,10 +30,11 @@ def _serialize_contact(c: Contact) -> dict:
|
||||
"id": str(c.id),
|
||||
"type": c.type,
|
||||
"displayname": c.displayname,
|
||||
"status": getattr(c, "status", "lead"),
|
||||
"name": c.name,
|
||||
"firstname": c.firstname,
|
||||
"surname": c.surname,
|
||||
"surfix": c.surfix,
|
||||
"suffix": c.suffix,
|
||||
"ext_name_line": c.ext_name_line,
|
||||
"gender": c.gender,
|
||||
"code": c.code,
|
||||
@@ -77,12 +78,12 @@ def _serialize_contact(c: Contact) -> dict:
|
||||
"purchase_number": c.purchase_number,
|
||||
"bic": c.bic,
|
||||
"bank_account": c.bank_account,
|
||||
"discount_crew": c.discount_crew,
|
||||
"discount_transport": c.discount_transport,
|
||||
"discount_rental": c.discount_rental,
|
||||
"discount_sale": c.discount_sale,
|
||||
"discount_subrent": c.discount_subrent,
|
||||
"discount_total": c.discount_total,
|
||||
"discount_crew": float(c.discount_crew) if c.discount_crew is not None else 0.0,
|
||||
"discount_transport": float(c.discount_transport) if c.discount_transport is not None else 0.0,
|
||||
"discount_rental": float(c.discount_rental) if c.discount_rental is not None else 0.0,
|
||||
"discount_sale": float(c.discount_sale) if c.discount_sale is not None else 0.0,
|
||||
"discount_subrent": float(c.discount_subrent) if c.discount_subrent is not None else 0.0,
|
||||
"discount_total": float(c.discount_total) if c.discount_total is not None else 0.0,
|
||||
"latitude": c.latitude,
|
||||
"longitude": c.longitude,
|
||||
"projectnote": c.projectnote,
|
||||
@@ -251,17 +252,16 @@ async def create_contact(
|
||||
action="create", snapshot_after=serialized,
|
||||
)
|
||||
|
||||
# Publish events
|
||||
from app.core.event_bus import get_event_bus
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish('contact.created', {
|
||||
# Enqueue domain events via transactional outbox (durable, at-least-once)
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
await enqueue_outbox_event(db, tenant_id, 'contact.created', {
|
||||
'contact_id': str(contact.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
'type': data.get('type', 'person'),
|
||||
})
|
||||
if data.get('type') == 'company':
|
||||
await event_bus.publish('lead.created', {
|
||||
await enqueue_outbox_event(db, tenant_id, 'lead.created', {
|
||||
'contact_id': str(contact.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
@@ -274,6 +274,8 @@ async def update_contact(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
|
||||
) -> dict:
|
||||
"""Update a contact."""
|
||||
# Expire all cached objects to ensure fresh data with selectinload
|
||||
db.expire_all()
|
||||
q = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
@@ -292,7 +294,7 @@ async def update_contact(
|
||||
snapshot_before = _serialize_contact_detail(contact)
|
||||
|
||||
# Recompute displayname if name fields changed
|
||||
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
|
||||
if any(k in data for k in ("type", "name", "firstname", "surname", "suffix")):
|
||||
merged = {**_serialize_contact(contact), **data}
|
||||
data["displayname"] = _compute_displayname(merged)
|
||||
|
||||
@@ -302,6 +304,15 @@ async def update_contact(
|
||||
contact.updated_by = user_id
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Re-query with selectinload to avoid lazy-loading issues after flush
|
||||
q2 = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
result2 = await db.execute(q2)
|
||||
contact = result2.scalar_one()
|
||||
snapshot_after = _serialize_contact_detail(contact)
|
||||
|
||||
# Compute changes diff
|
||||
@@ -320,10 +331,9 @@ async def update_contact(
|
||||
changes=changes or None,
|
||||
)
|
||||
|
||||
# Publish contact.updated event
|
||||
from app.core.event_bus import get_event_bus
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish('contact.updated', {
|
||||
# Enqueue domain event via transactional outbox (durable, at-least-once)
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
await enqueue_outbox_event(db, tenant_id, 'contact.updated', {
|
||||
'contact_id': str(contact.id),
|
||||
'tenant_id': str(tenant_id),
|
||||
'user_id': str(user_id),
|
||||
|
||||
@@ -218,7 +218,7 @@ def _serialize_full(c: Contact) -> dict:
|
||||
"name": c.name,
|
||||
"firstname": c.firstname,
|
||||
"surname": c.surname,
|
||||
"surfix": c.surfix,
|
||||
"suffix": c.suffix,
|
||||
"email_1": c.email_1,
|
||||
"email_2": c.email_2,
|
||||
"phone_1": c.phone_1,
|
||||
@@ -287,7 +287,6 @@ async def merge_contacts(
|
||||
setattr(target, key, value)
|
||||
|
||||
# Re-point entity_links from source to target
|
||||
from app.models.entity_link import EntityLink
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE entity_links SET entity_id = :target_id "
|
||||
@@ -297,7 +296,6 @@ async def merge_contacts(
|
||||
)
|
||||
|
||||
# Re-point tag_assignments from source to target
|
||||
from app.models.tag import TagAssignment
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE tag_assignments SET entity_id = :target_id "
|
||||
@@ -309,7 +307,7 @@ async def merge_contacts(
|
||||
# Re-point contact_persons from source to target
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE contact_persons SET contact_id = :target_id "
|
||||
"UPDATE contactpersons SET contact_id = :target_id "
|
||||
"WHERE contact_id = :source_id AND tenant_id = :tenant_id"
|
||||
),
|
||||
{"target_id": target_uuid, "source_id": source_uuid, "tenant_id": tenant_id},
|
||||
@@ -322,20 +320,37 @@ async def merge_contacts(
|
||||
# Record merge history
|
||||
history = ContactMergeHistory(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
source_id=source_uuid,
|
||||
target_id=target_uuid,
|
||||
merged_by=user_id,
|
||||
source_contact_id=source_uuid,
|
||||
target_contact_id=target_uuid,
|
||||
note=note,
|
||||
)
|
||||
db.add(history)
|
||||
await db.flush()
|
||||
|
||||
# Determine which fields were actually overridden
|
||||
merged_fields = field_overrides or {}
|
||||
if not merged_fields:
|
||||
# Auto-merge: fill empty target fields from source
|
||||
for attr in ("email_1", "email_2", "phone_1", "phone_2", "website",
|
||||
"mailing_street", "mailing_postalcode", "mailing_city",
|
||||
"mailing_country", "code", "vat_code"):
|
||||
target_val = getattr(target, attr, None)
|
||||
source_val = getattr(source, attr, None)
|
||||
if not target_val and source_val:
|
||||
setattr(target, attr, source_val)
|
||||
merged_fields[attr] = source_val
|
||||
|
||||
history.merged_fields = merged_fields
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"history": {
|
||||
"id": str(history.id),
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"note": note,
|
||||
"merged_fields": merged_fields,
|
||||
"created_at": history.created_at.isoformat() if history.created_at else None,
|
||||
},
|
||||
"target_contact": _serialize_full(target),
|
||||
|
||||
@@ -61,19 +61,23 @@ class TenantService:
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List users in a tenant."""
|
||||
q = select(User).where(User.tenant_id == tenant_id)
|
||||
"""List users in a tenant via UserTenant association."""
|
||||
q = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"name": u.name,
|
||||
"role": u.role,
|
||||
"role": ut.role,
|
||||
"is_active": u.is_active,
|
||||
}
|
||||
for u in users
|
||||
for u, ut in rows
|
||||
]
|
||||
|
||||
async def assign_user_to_tenant(
|
||||
|
||||
@@ -18,7 +18,11 @@ _UNSET: Any = object()
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Handles user CRUD operations."""
|
||||
"""Handles user CRUD operations.
|
||||
|
||||
All queries are tenant-scoped through the UserTenant association table.
|
||||
User.email is globally unique; tenant membership and role live in UserTenant.
|
||||
"""
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
@@ -31,25 +35,33 @@ class UserService:
|
||||
"""List users in a tenant with pagination and search."""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
q = select(User).where(User.tenant_id == tenant_id)
|
||||
count_q = select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
|
||||
base = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
count_q = (
|
||||
select(func.count())
|
||||
.select_from(UserTenant)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_filter = or_(
|
||||
User.name.ilike(f"%{search}%"),
|
||||
User.email.ilike(f"%{search}%"),
|
||||
)
|
||||
q = q.where(search_filter)
|
||||
count_q = count_q.where(search_filter)
|
||||
base = base.where(search_filter)
|
||||
count_q = count_q.join(User, User.id == UserTenant.user_id).where(search_filter)
|
||||
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
q = q.offset(offset).limit(page_size).order_by(User.created_at.desc())
|
||||
q = base.offset(offset).limit(page_size).order_by(User.created_at.desc())
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
"items": [self._user_to_dict(u) for u in users],
|
||||
"items": [self._user_to_dict(u, ut) for u, ut in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -60,11 +72,21 @@ class UserService:
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> User | None:
|
||||
"""Get a single user by ID within tenant scope."""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
) -> tuple[User, UserTenant] | None:
|
||||
"""Get a single user by ID within tenant scope.
|
||||
|
||||
Returns (User, UserTenant) tuple or None.
|
||||
"""
|
||||
q = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
return row[0], row[1]
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
@@ -77,29 +99,27 @@ class UserService:
|
||||
role_id: uuid.UUID | None = None,
|
||||
is_active: bool = True,
|
||||
) -> User:
|
||||
"""Create a new user in a tenant.
|
||||
"""Create a new user and add them to the specified tenant.
|
||||
|
||||
If role_id is provided it links the user to a custom Role record.
|
||||
The legacy ``role`` string is kept for backward compatibility.
|
||||
If role_id is provided it links the UserTenant to a custom Role record.
|
||||
The ``role`` string is the built-in role (admin/editor/viewer).
|
||||
"""
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
role_id=role_id,
|
||||
is_active=is_active,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Add user-tenant membership
|
||||
# Add user-tenant membership with role
|
||||
ut = UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
is_default=True,
|
||||
role=role,
|
||||
role_id=role_id,
|
||||
)
|
||||
db.add(ut)
|
||||
@@ -122,35 +142,34 @@ class UserService:
|
||||
email: str | None = None,
|
||||
current_password: str | None = None,
|
||||
new_password: str | None = None,
|
||||
) -> User | None:
|
||||
"""Update a user.
|
||||
) -> tuple[User, UserTenant] | None:
|
||||
"""Update a user and their tenant membership.
|
||||
|
||||
``role_id`` uses a sentinel to distinguish three states:
|
||||
- ``_UNSET`` (default): leave the existing role_id unchanged
|
||||
- ``None``: clear the FK (fall back to the legacy ``role`` string)
|
||||
- ``None``: clear the FK (fall back to the built-in ``role`` string)
|
||||
- ``uuid.UUID``: link to a custom Role record
|
||||
|
||||
Returns (User, UserTenant) tuple or None if not found.
|
||||
"""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
q = (
|
||||
select(User, UserTenant)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
user, user_tenant = row[0], row[1]
|
||||
|
||||
if name is not None:
|
||||
user.name = name
|
||||
if role is not None:
|
||||
user.role = role
|
||||
user_tenant.role = role
|
||||
if role_id is not _UNSET:
|
||||
user.role_id = role_id
|
||||
# Sync UserTenant.role_id so resolve_permissions picks up the change
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant:
|
||||
user_tenant.role_id = role_id
|
||||
user_tenant.role_id = role_id
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
if first_name is not None:
|
||||
@@ -170,7 +189,7 @@ class UserService:
|
||||
user.password_hash = hash_password(new_password)
|
||||
|
||||
await db.flush()
|
||||
return user
|
||||
return user, user_tenant
|
||||
|
||||
async def delete_user(
|
||||
self,
|
||||
@@ -178,28 +197,59 @@ class UserService:
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Delete a user from a tenant."""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
"""Remove a user from a tenant (delete UserTenant membership).
|
||||
|
||||
If this is the user's only tenant membership, the User record is
|
||||
also deleted. Otherwise only the UserTenant row is removed.
|
||||
"""
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return False
|
||||
|
||||
await db.delete(user)
|
||||
# Count total tenant memberships for this user
|
||||
count_q = select(func.count()).select_from(UserTenant).where(
|
||||
UserTenant.user_id == user_id
|
||||
)
|
||||
count_result = await db.execute(count_q)
|
||||
membership_count = count_result.scalar() or 0
|
||||
|
||||
await db.delete(user_tenant)
|
||||
|
||||
if membership_count <= 1:
|
||||
# User's only tenant — delete the User record too
|
||||
user_q = select(User).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
await db.delete(user)
|
||||
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
def _user_to_dict(self, user: User) -> dict[str, Any]:
|
||||
"""Convert user to response dict."""
|
||||
return {
|
||||
def _user_to_dict(
|
||||
self, user: User, user_tenant: UserTenant | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Convert user + user_tenant to response dict."""
|
||||
result: dict[str, Any] = {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
}
|
||||
if user_tenant is not None:
|
||||
result["role"] = user_tenant.role
|
||||
result["role_id"] = str(user_tenant.role_id) if user_tenant.role_id else None
|
||||
result["tenant_id"] = str(user_tenant.tenant_id)
|
||||
else:
|
||||
result["role"] = "viewer"
|
||||
result["role_id"] = None
|
||||
result["tenant_id"] = None
|
||||
return result
|
||||
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
Reference in New Issue
Block a user