chore(cleanup): toten AI-Copilot-Legacy entfernt — Router nie gemountet, Tabellen von Migration 0137 gedroppt (Chat läuft seitdem über kommunikation/comm_conversations); schemas/OpenAPI-Tag bereinigt; Geister-Test test_ai_copilot.py geloescht (pytest.skip seit Phase 2); test_contacts_lifecycle Route-Anzahl-Failure als Vorbestand bewiesen (83 Routen auch auf clean HEAD)
This commit is contained in:
@@ -438,7 +438,6 @@ def create_app() -> FastAPI:
|
||||
{"name": "entity-history", "description": "Audit trail and entity change history."},
|
||||
{"name": "import-export", "description": "Bulk import and export of contacts and data."},
|
||||
{"name": "plugins", "description": "Plugin management: list, install, activate, deactivate."},
|
||||
{"name": "ai-copilot", "description": "AI copilot: chat, suggestions, conversation history."},
|
||||
{"name": "workflows", "description": "Workflow definitions, instances, and execution."},
|
||||
{"name": "user-preferences", "description": "Per-user preference settings."},
|
||||
{"name": "currencies", "description": "Currency management for multi-currency support."},
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""AI Conversation and Message models — tenant-scoped with RLS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class AIConversation(Base, TenantMixin, OwnedMixin):
|
||||
"""AI Copilot conversation thread — tenant-scoped."""
|
||||
|
||||
__tablename__ = "ai_conversations"
|
||||
__table_args__ = (Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Untitled")
|
||||
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
|
||||
class AIMessage(Base, TenantMixin, OwnedMixin):
|
||||
"""Individual messages within an AI conversation — user input, AI response, actions."""
|
||||
|
||||
__tablename__ = "ai_messages"
|
||||
__table_args__ = (Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
proposed_actions: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
|
||||
executed_action: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
execution_result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
message_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""AI Copilot routes — query, execute, history.
|
||||
|
||||
.. deprecated:: Phase 2 (ARCH-059)
|
||||
Legacy module, not wired into main.py and without frontend consumers.
|
||||
Scheduled for removal together with app/services/ai_copilot_service.py
|
||||
and the ``ai_conversations`` tables (see service docstring for plan).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
|
||||
from app.deps import require_permission
|
||||
from app.schemas.ai_copilot import (
|
||||
CopilotExecuteRequest,
|
||||
CopilotQueryRequest,
|
||||
)
|
||||
from app.services import ai_copilot_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ai/copilot", tags=["ai-copilot"])
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
async def copilot_query(
|
||||
body: CopilotQueryRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("ai:write")),
|
||||
):
|
||||
"""Process a natural language query and return proposed actions.
|
||||
|
||||
Returns proposed_actions array for user confirmation.
|
||||
Does NOT execute any actions — user must call /execute to confirm.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Rate limit — AI policy (cost-sensitive LLM call)
|
||||
await check_rate_limit_policy(
|
||||
f"rate:ai:copilot:{tenant_id}:{user_id}",
|
||||
RateLimitPolicy.AI,
|
||||
)
|
||||
|
||||
result = await ai_copilot_service.process_query(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
query=body.query,
|
||||
conversation_id=body.conversation_id,
|
||||
context=body.context,
|
||||
)
|
||||
|
||||
if "error" in result and result.get("status_code") == 404:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"detail": result["error"], "code": "conversation_not_found"},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def copilot_execute(
|
||||
body: CopilotExecuteRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("ai:write")),
|
||||
):
|
||||
"""Execute a proposed action after user confirmation.
|
||||
|
||||
RBAC is enforced — the user must have permission for the action.
|
||||
Returns 200 with execution result or 403 if RBAC blocks.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Rate limit — AI policy (cost-sensitive LLM call)
|
||||
await check_rate_limit_policy(
|
||||
f"rate:ai:copilot:{tenant_id}:{user_id}",
|
||||
RateLimitPolicy.AI,
|
||||
)
|
||||
resolved = {
|
||||
"permissions": current_user.get("permissions", []),
|
||||
"denied": current_user.get("denied_permissions", []),
|
||||
"field_permissions": current_user.get("field_permissions", {}),
|
||||
"is_system_admin": current_user.get("is_system_admin", False),
|
||||
}
|
||||
|
||||
result = await ai_copilot_service.execute_action(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
resolved,
|
||||
conversation_id=body.conversation_id,
|
||||
action=body.action.model_dump(),
|
||||
)
|
||||
|
||||
if result.get("status_code") == 404:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"detail": result.get("error", "Not found"), "code": "not_found"},
|
||||
)
|
||||
|
||||
if result.get("status_code") == 403:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": result.get("error", "Insufficient permissions"), "code": "forbidden"},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def copilot_history(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("ai:read")),
|
||||
):
|
||||
"""Get paginated conversation history for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
return await ai_copilot_service.get_history(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
@@ -1,14 +1,5 @@
|
||||
"""Pydantic schemas package."""
|
||||
|
||||
from app.schemas.ai_copilot import (
|
||||
CopilotAction, # noqa: F401
|
||||
CopilotExecuteRequest, # noqa: F401
|
||||
CopilotExecuteResponse, # noqa: F401
|
||||
CopilotHistoryResponse, # noqa: F401
|
||||
CopilotMessageResponse, # noqa: F401
|
||||
CopilotQueryRequest, # noqa: F401
|
||||
CopilotQueryResponse, # noqa: F401
|
||||
)
|
||||
from app.schemas.plugin import (
|
||||
PluginActionResponse, # noqa: F401
|
||||
PluginInfo, # noqa: F401
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""AI Copilot schemas — query, execute, history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CopilotQueryRequest(BaseModel):
|
||||
"""Natural language query to the AI copilot."""
|
||||
|
||||
query: str = Field(..., min_length=1, max_length=2000)
|
||||
conversation_id: str | None = None
|
||||
context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CopilotAction(BaseModel):
|
||||
"""A proposed API action derived from NL input."""
|
||||
|
||||
method: str = Field(..., pattern="^(GET|POST|PATCH|DELETE)$")
|
||||
path: str = Field(..., min_length=1)
|
||||
body: dict | None = None
|
||||
description: str = ""
|
||||
confidence: float = Field(0.0, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class CopilotQueryResponse(BaseModel):
|
||||
"""Response from copilot query — proposed actions for user confirmation."""
|
||||
|
||||
conversation_id: str
|
||||
message: str
|
||||
proposed_actions: list[CopilotAction] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CopilotExecuteRequest(BaseModel):
|
||||
"""Execute a proposed action after user confirmation."""
|
||||
|
||||
conversation_id: str
|
||||
action: CopilotAction
|
||||
|
||||
|
||||
class CopilotExecuteResponse(BaseModel):
|
||||
"""Result of executing a proposed action."""
|
||||
|
||||
conversation_id: str
|
||||
success: bool
|
||||
status_code: int
|
||||
data: dict | list | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class CopilotMessageResponse(BaseModel):
|
||||
"""A single message in conversation history."""
|
||||
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
proposed_actions: list[dict] | None = None
|
||||
executed_action: dict | None = None
|
||||
execution_result: dict | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class CopilotHistoryResponse(BaseModel):
|
||||
"""Paginated conversation history."""
|
||||
|
||||
items: list[CopilotMessageResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -1,477 +0,0 @@
|
||||
"""AI Copilot service — NL query processing, action execution, RBAC, audit logging.
|
||||
|
||||
.. deprecated:: Phase 2
|
||||
This module is a legacy leftover of the pre-kommunikation AI chat system.
|
||||
It is NOT registered in main.py, has NO frontend consumers, and its test
|
||||
suite is skipped ("ai_copilot routes removed in Phase 2").
|
||||
|
||||
Shutdown plan (ARCH-059):
|
||||
1. ✅ Marked deprecated (this notice)
|
||||
2. Remove routes/service/model + ``ai_conversations`` tables in a dedicated
|
||||
migration once a release confirms zero traffic on /api/v1/ai/copilot/*
|
||||
3. Do NOT migrate to kommunikation — the plugin already covers chat via
|
||||
CommConversation/CommMessage; parity work would be wasted effort.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.llm_client import get_llm_client
|
||||
from app.core.audit import log_audit
|
||||
from app.core.permissions import check_permission
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
|
||||
try:
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
except ImportError:
|
||||
AIConversation = None # type: ignore
|
||||
AIMessage = None # type: ignore
|
||||
from app.models.contact import Contact
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
warnings.warn(
|
||||
"app.services.ai_copilot_service is deprecated (ARCH-059) — "
|
||||
"scheduled for removal; do not build new features on it",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def _safe_iso(dt) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
try:
|
||||
return dt.isoformat() if hasattr(dt, "isoformat") else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_attr(obj, name, default=None):
|
||||
try:
|
||||
val = getattr(obj, name)
|
||||
return val if val is not None else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _conversation_to_dict(c: AIConversation) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(c.id),
|
||||
"title": c.title,
|
||||
"context": c.context,
|
||||
"created_at": _safe_iso(_get_attr(c, "created_at")),
|
||||
"updated_at": _safe_iso(_get_attr(c, "updated_at")),
|
||||
}
|
||||
|
||||
|
||||
def _message_to_dict(m: AIMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(m.id),
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"proposed_actions": m.proposed_actions,
|
||||
"executed_action": m.executed_action,
|
||||
"execution_result": m.execution_result,
|
||||
"created_at": _safe_iso(_get_attr(m, "created_at")),
|
||||
}
|
||||
|
||||
|
||||
async def process_query(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
query: str,
|
||||
conversation_id: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a natural language query and return proposed actions.
|
||||
|
||||
1. Get or create conversation
|
||||
2. Store user message
|
||||
3. Call LLM client (mock or real) to get proposed actions
|
||||
4. Store assistant message with proposed actions
|
||||
5. Return response with conversation_id and proposed_actions
|
||||
"""
|
||||
context = context or {}
|
||||
|
||||
# Get or create conversation
|
||||
if conversation_id:
|
||||
conv_uuid = uuid.UUID(conversation_id)
|
||||
result = await db.execute(
|
||||
select(AIConversation).where(
|
||||
AIConversation.id == conv_uuid,
|
||||
AIConversation.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
if conversation is None:
|
||||
return {"error": "Conversation not found", "status_code": 404}
|
||||
else:
|
||||
conversation = AIConversation(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
title=query[:100] if query else "Untitled",
|
||||
context=context,
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.refresh(conversation)
|
||||
|
||||
# Get next message index
|
||||
count_q = select(func.count()).select_from(
|
||||
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
||||
)
|
||||
count_result = await db.execute(count_q)
|
||||
msg_index = count_result.scalar_one()
|
||||
|
||||
# Store user message
|
||||
user_msg = AIMessage(
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation.id,
|
||||
role="user",
|
||||
content=query,
|
||||
message_index=msg_index,
|
||||
)
|
||||
db.add(user_msg)
|
||||
await db.flush()
|
||||
await db.refresh(user_msg)
|
||||
|
||||
# Call LLM client
|
||||
llm = get_llm_client()
|
||||
llm_response = await llm.generate(query, context)
|
||||
|
||||
# Store assistant message
|
||||
assistant_msg = AIMessage(
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
content=llm_response.message,
|
||||
proposed_actions=llm_response.proposed_actions,
|
||||
message_index=msg_index + 1,
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
await db.flush()
|
||||
await db.refresh(assistant_msg)
|
||||
|
||||
# Log to audit
|
||||
await log_audit(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
action="query",
|
||||
entity_type="ai_copilot",
|
||||
entity_id=conversation.id,
|
||||
changes={"query": query, "proposed_action_count": len(llm_response.proposed_actions)},
|
||||
)
|
||||
|
||||
return {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message": llm_response.message,
|
||||
"proposed_actions": llm_response.proposed_actions,
|
||||
}
|
||||
|
||||
|
||||
async def execute_action(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resolved: dict[str, Any],
|
||||
conversation_id: str,
|
||||
action: dict[str, Any],
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a proposed action with RBAC enforcement.
|
||||
|
||||
1. Validate conversation belongs to tenant
|
||||
2. Check RBAC permissions for the action
|
||||
3. Execute the action (direct DB or API call)
|
||||
4. Store execution result in message
|
||||
5. Log to audit
|
||||
"""
|
||||
conv_uuid = uuid.UUID(conversation_id)
|
||||
|
||||
# Validate conversation ownership
|
||||
result = await db.execute(
|
||||
select(AIConversation).where(
|
||||
AIConversation.id == conv_uuid,
|
||||
AIConversation.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
if conversation is None:
|
||||
return {"error": "Conversation not found", "status_code": 404}
|
||||
|
||||
method = action.get("method", "GET").upper()
|
||||
path = action.get("path", "")
|
||||
body = action.get("body") or {}
|
||||
|
||||
# Determine module and action_type from path for RBAC
|
||||
module, action_type = _derive_rbac_from_path(method, path)
|
||||
required_perm = f"{module}:{action_type}"
|
||||
|
||||
if not check_permission(resolved, required_perm):
|
||||
return {
|
||||
"error": "Insufficient permissions for this action",
|
||||
"status_code": 403,
|
||||
"success": False,
|
||||
}
|
||||
|
||||
# Check single entity access for write operations
|
||||
if method in ("POST", "PATCH", "DELETE"):
|
||||
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||
entity_type = parts[0] if parts else ""
|
||||
entity_id = parts[1] if len(parts) > 1 else None
|
||||
if entity_id:
|
||||
try:
|
||||
entity_uuid = uuid.UUID(entity_id)
|
||||
except (ValueError, TypeError):
|
||||
entity_uuid = None
|
||||
if entity_uuid:
|
||||
has_access = await check_single_entity_access(
|
||||
db, entity_type, entity_uuid, user_id, tenant_id,
|
||||
required_level="write", is_system_admin=is_system_admin,
|
||||
)
|
||||
if not has_access:
|
||||
return {
|
||||
"error": "Insufficient access to this entity",
|
||||
"status_code": 403,
|
||||
"success": False,
|
||||
}
|
||||
|
||||
# Execute the action
|
||||
try:
|
||||
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body, is_system_admin=is_system_admin)
|
||||
except Exception as exc:
|
||||
exec_result = {"error": str(exc), "status_code": 500}
|
||||
|
||||
# Get next message index
|
||||
count_q = select(func.count()).select_from(
|
||||
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
||||
)
|
||||
count_result = await db.execute(count_q)
|
||||
msg_index = count_result.scalar_one()
|
||||
|
||||
# Store execution message
|
||||
exec_msg = AIMessage(
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
content=f"Executed {method} {path}",
|
||||
executed_action=action,
|
||||
execution_result=exec_result,
|
||||
message_index=msg_index,
|
||||
)
|
||||
db.add(exec_msg)
|
||||
await db.flush()
|
||||
await db.refresh(exec_msg)
|
||||
|
||||
# Log to audit
|
||||
await log_audit(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
action="execute",
|
||||
entity_type="ai_copilot",
|
||||
entity_id=conversation.id,
|
||||
changes={"action": action, "result": exec_result},
|
||||
)
|
||||
|
||||
return {
|
||||
"conversation_id": str(conversation.id),
|
||||
"success": exec_result.get("success", True),
|
||||
"status_code": exec_result.get("status_code", 200),
|
||||
"data": exec_result.get("data"),
|
||||
"error": exec_result.get("error"),
|
||||
}
|
||||
|
||||
|
||||
async def get_history(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Get paginated conversation history for the current user."""
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(100, page_size))
|
||||
|
||||
base = select(AIConversation).where(
|
||||
AIConversation.tenant_id == tenant_id,
|
||||
AIConversation.user_id == user_id,
|
||||
)
|
||||
|
||||
count_q = select(func.count()).select_from(base.subquery())
|
||||
total_result = await db.execute(count_q)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
paginated = base.order_by(desc(AIConversation.created_at)).offset(offset).limit(page_size)
|
||||
result = await db.execute(paginated)
|
||||
conversations = result.scalars().all()
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for conv in conversations:
|
||||
# Get messages for each conversation
|
||||
msg_q = (
|
||||
select(AIMessage)
|
||||
.where(
|
||||
AIMessage.conversation_id == conv.id,
|
||||
AIMessage.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(AIMessage.message_index)
|
||||
)
|
||||
msg_result = await db.execute(msg_q)
|
||||
messages = msg_result.scalars().all()
|
||||
|
||||
items.append(
|
||||
{
|
||||
**_conversation_to_dict(conv),
|
||||
"messages": [_message_to_dict(m) for m in messages],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def _execute_api_action(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any],
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute an API action directly against the database.
|
||||
|
||||
Supports contacts CRUD (including company-type contacts), plus workflow listing.
|
||||
"""
|
||||
# Parse path to determine entity and operation
|
||||
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||
entity = parts[0] if parts else ""
|
||||
entity_id = parts[1] if len(parts) > 1 else None
|
||||
|
||||
if entity in ("companies", "contacts"):
|
||||
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body, is_system_admin=is_system_admin)
|
||||
elif entity == "workflows":
|
||||
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body, is_system_admin=is_system_admin)
|
||||
else:
|
||||
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
||||
|
||||
|
||||
async def _exec_contacts(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
method: str,
|
||||
entity_id: str | None,
|
||||
body: dict[str, Any],
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute contact operations (unified: company + person)."""
|
||||
if method == "GET":
|
||||
query = select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "contact", Contact, user_id, tenant_id, is_system_admin=is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
contacts = result.scalars().all()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": [{"id": str(c.id), "name": c.name or c.displayname, "email": c.email_1, "type": c.type} for c in contacts],
|
||||
}
|
||||
|
||||
elif method == "POST":
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type=body.get("type", "company"),
|
||||
name=body.get("name", "Untitled"),
|
||||
email_1=body.get("email"),
|
||||
phone_1=body.get("phone"),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(contact)
|
||||
await db.flush()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 201,
|
||||
"data": {"id": str(contact.id), "name": contact.name, "type": contact.type},
|
||||
}
|
||||
|
||||
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||
|
||||
|
||||
async def _exec_workflows(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
method: str,
|
||||
entity_id: str | None,
|
||||
body: dict[str, Any],
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute workflow operations."""
|
||||
if method == "GET":
|
||||
result = await db.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.tenant_id == tenant_id,
|
||||
Workflow.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
workflows = result.scalars().all()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": [
|
||||
{"id": str(w.id), "name": w.name, "trigger_event": w.trigger_event}
|
||||
for w in workflows
|
||||
],
|
||||
}
|
||||
|
||||
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||
|
||||
|
||||
def _derive_rbac_from_path(method: str, path: str) -> tuple[str, str]:
|
||||
"""Derive module and action_type from HTTP method and path for RBAC.
|
||||
|
||||
Returns (module, action_type) suitable for check_permission().
|
||||
"""
|
||||
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||
entity = parts[0] if parts else ""
|
||||
|
||||
method_to_action = {
|
||||
"GET": "read",
|
||||
"POST": "create",
|
||||
"PATCH": "update",
|
||||
"DELETE": "delete",
|
||||
}
|
||||
action_type = method_to_action.get(method, "read")
|
||||
|
||||
# Map path entities to permission modules
|
||||
entity_to_module = {
|
||||
"companies": "companies",
|
||||
"contacts": "contacts",
|
||||
"workflows": "workflows",
|
||||
"ai": "ai_copilot",
|
||||
}
|
||||
module = entity_to_module.get(entity, entity)
|
||||
|
||||
return module, action_type
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user