Phase 0 Complete: Tasks 0.7-0.20
- 0.7: UI-Design-Richtlinien (docs/ui-design-guidelines.md, 535 lines) - 0.8: Theme-Customization Backend (4 theme fields, migration 0023) - 0.9: Theme-Customization Frontend (SettingsTheme.tsx, themeStore.ts, live preview) - 0.10: RBAC-Audit (4 plugins secured, 53 routes with require_permission) - 0.11: LiteLLM-Cleanup (llm_client.py migrated from httpx to litellm) - 0.12: KI-Agent-Framework docs (plugin-development-guide.md, agent_capabilities field) - 0.13: Heartbeat configurable (ProactiveSettings, migration 0024, frontend UI) - 0.14: Unified Search Field-Level RBAC (resolve_permissions + filter_fields_by_permission) - 0.15: Undo/History-System (EntityHistory model, service, routes, migration 0025, HistoryViewer) - 0.16: Storage Backend (LocalStorage + S3Storage, DMS/attachments/mail updated) - 0.17: Import/Export unified Contact fields (firstname, surname, email_1, phone_1) - 0.18: .gitignore & Config-Cleanup (webui→frontend, python-jose removed, .env untracked) - 0.19: Mail-Salt Security-Fix (per-account random salt, migration 0026) - 0.20: AGPL replaced (PyMuPDF→pypdf, OnlyOffice→Collabora, LICENSE + THIRD_PARTY_LICENSES.md)
This commit is contained in:
+61
-36
@@ -1,8 +1,11 @@
|
||||
"""Configurable LLM client — supports OpenAI-compatible API or mock/stub mode.
|
||||
"""Configurable LLM client — supports LiteLLM (100+ providers) or mock/stub mode.
|
||||
|
||||
Reads AI_MODEL and AI_API_KEY from environment. If not set, uses mock mode
|
||||
Reads AI_MODEL, AI_API_KEY, AI_PROVIDER from environment. If not set, uses mock mode
|
||||
which returns predefined actions based on keyword matching. This allows
|
||||
tests to run without external API dependencies.
|
||||
|
||||
LiteLLM provides a unified interface to OpenAI, Anthropic, Google, Azure,
|
||||
AWS Bedrock, Ollama, and many more providers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,7 +15,7 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import litellm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,16 +42,20 @@ class LLMClient:
|
||||
"""LLM client that translates natural language to proposed API actions.
|
||||
|
||||
Modes:
|
||||
- If AI_MODEL and AI_API_KEY are set: calls OpenAI-compatible chat completions API
|
||||
- If AI_MODEL and AI_API_KEY are set: calls LiteLLM chat completions API
|
||||
- Otherwise: mock/stub mode with keyword-based action mapping
|
||||
|
||||
LiteLLM model format: "provider/model_name" (e.g. "openai/gpt-4o", "anthropic/claude-3-sonnet", "ollama/llama3")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, model: str | None = None, api_key: str | None = None, api_base: str | None = None
|
||||
):
|
||||
self, model: str | None = None, api_key: str | None = None, api_base: str | None = None,
|
||||
provider: str | None = None,
|
||||
) -> None:
|
||||
self.model = model or os.environ.get("AI_MODEL", "")
|
||||
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
|
||||
self.api_base = api_base or os.environ.get("AI_API_BASE", "https://api.openai.com/v1")
|
||||
self.api_base = api_base or os.environ.get("AI_API_BASE", "")
|
||||
self.provider = provider or os.environ.get("AI_PROVIDER", "openai")
|
||||
self.is_mock = not bool(self.model and self.api_key)
|
||||
|
||||
async def generate(self, user_query: str, context: dict[str, Any] | None = None) -> LLMResponse:
|
||||
@@ -83,20 +90,28 @@ class LLMClient:
|
||||
)
|
||||
|
||||
async def _api_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
|
||||
"""Call OpenAI-compatible chat completions API.
|
||||
"""Call LLM via LiteLLM unified interface.
|
||||
|
||||
Sends a system prompt explaining the available API endpoints and asks
|
||||
the LLM to propose actions in structured JSON format.
|
||||
Supports 100+ providers through a single API:
|
||||
- OpenAI: "openai/gpt-4o"
|
||||
- Anthropic: "anthropic/claude-3-sonnet"
|
||||
- Google: "gemini/gemini-pro"
|
||||
- Azure: "azure/<deployment-name>"
|
||||
- Ollama: "ollama/llama3"
|
||||
- And many more.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(context)
|
||||
user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON."
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"model": self.model,
|
||||
# Build LiteLLM model string: "provider/model" or just "model" for OpenAI compat
|
||||
if self.provider and self.provider != "openai":
|
||||
litellm_model = f"{self.provider}/{self.model}"
|
||||
else:
|
||||
litellm_model = self.model
|
||||
|
||||
# Build kwargs for litellm.acompletion
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": litellm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
@@ -105,42 +120,52 @@ class LLMClient:
|
||||
"max_tokens": 1000,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/chat/completions",
|
||||
headers=headers,
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# Add API key if set
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return self._parse_llm_response(content)
|
||||
# Add API base if set (for self-hosted or custom endpoints)
|
||||
if self.api_base:
|
||||
kwargs["api_base"] = self.api_base
|
||||
|
||||
try:
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
content = response.choices[0].message.content
|
||||
return self._parse_llm_response(content)
|
||||
except Exception as e:
|
||||
logger.error("LiteLLM API call failed: %s", e)
|
||||
# Fall back to mock mode on API error
|
||||
return LLMResponse(
|
||||
message=f"LLM API call failed: {e}. Falling back to keyword matching.",
|
||||
proposed_actions=[],
|
||||
confidence=0.1,
|
||||
)
|
||||
|
||||
def _build_system_prompt(self, context: dict[str, Any]) -> str:
|
||||
"""Build system prompt describing available API actions."""
|
||||
available_apis = [
|
||||
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"},
|
||||
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"},
|
||||
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts (persons and companies)"},
|
||||
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact (person or company)"},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"description": "Get company details",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"description": "Get contact details",
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"description": "Update a company",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"description": "Update a contact",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"description": "Delete a company",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"description": "Delete a contact",
|
||||
},
|
||||
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts"},
|
||||
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact"},
|
||||
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
|
||||
{"method": "POST", "path": "/api/v1/workflows", "description": "Create a workflow"},
|
||||
{"method": "GET", "path": "/api/v1/calendar/entries", "description": "List calendar entries"},
|
||||
{"method": "POST", "path": "/api/v1/calendar/entries", "description": "Create a calendar entry"},
|
||||
{"method": "GET", "path": "/api/v1/dms/files", "description": "List DMS files"},
|
||||
]
|
||||
context_str = json.dumps(context) if context else "{}"
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Abstract storage backend — supports local filesystem and S3-compatible storage.
|
||||
|
||||
Configuration via environment variables:
|
||||
- STORAGE_BACKEND: "local" (default) or "s3"
|
||||
- STORAGE_PATH: Local storage base path (default: /data/uploads)
|
||||
- S3_ENDPOINT: S3-compatible endpoint URL
|
||||
- S3_BUCKET: Bucket name
|
||||
- S3_ACCESS_KEY: Access key
|
||||
- S3_SECRET_KEY: Secret key
|
||||
- S3_REGION: Region (default: us-east-1)
|
||||
- S3_SECURE: Use HTTPS (default: true)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StorageBackend(ABC):
|
||||
"""Abstract storage backend for file operations."""
|
||||
|
||||
@abstractmethod
|
||||
async def save(self, path: str, data: bytes) -> str:
|
||||
"""Save data to storage at the given path. Returns the full storage path."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def read(self, path: str) -> bytes:
|
||||
"""Read data from storage at the given path."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, path: str) -> bool:
|
||||
"""Delete a file from storage. Returns True if deleted, False if not found."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, path: str) -> bool:
|
||||
"""Check if a file exists in storage."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||||
"""Get a URL for accessing the file (presigned URL for S3, file path for local)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_files(self, prefix: str) -> list[str]:
|
||||
"""List all file paths under the given prefix."""
|
||||
...
|
||||
|
||||
|
||||
class LocalStorage(StorageBackend):
|
||||
"""Local filesystem storage backend."""
|
||||
|
||||
def __init__(self, base_path: str | None = None) -> None:
|
||||
self.base_path = base_path or os.environ.get("STORAGE_PATH", "/data/uploads")
|
||||
os.makedirs(self.base_path, exist_ok=True)
|
||||
|
||||
def _full_path(self, path: str) -> str:
|
||||
"""Get the full filesystem path."""
|
||||
return os.path.join(self.base_path, path)
|
||||
|
||||
async def save(self, path: str, data: bytes) -> str:
|
||||
full_path = self._full_path(path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, "wb") as f:
|
||||
f.write(data)
|
||||
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
|
||||
return path
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
full_path = self._full_path(path)
|
||||
with open(full_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
async def delete(self, path: str) -> bool:
|
||||
full_path = self._full_path(path)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def exists(self, path: str) -> bool:
|
||||
return os.path.exists(self._full_path(path))
|
||||
|
||||
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||||
# Local storage returns the file path for direct access
|
||||
return self._full_path(path)
|
||||
|
||||
async def list_files(self, prefix: str) -> list[str]:
|
||||
full_prefix = self._full_path(prefix)
|
||||
if not os.path.isdir(full_prefix):
|
||||
return []
|
||||
result: list[str] = []
|
||||
for root, _dirs, files in os.walk(full_prefix):
|
||||
for fname in files:
|
||||
rel = os.path.relpath(os.path.join(root, fname), self.base_path)
|
||||
result.append(rel)
|
||||
return result
|
||||
|
||||
|
||||
class S3Storage(StorageBackend):
|
||||
"""S3-compatible storage backend (works with AWS S3, MinIO, etc.)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str | None = None,
|
||||
bucket: str | None = None,
|
||||
access_key: str | None = None,
|
||||
secret_key: str | None = None,
|
||||
region: str | None = None,
|
||||
secure: bool | None = None,
|
||||
) -> None:
|
||||
self.endpoint = endpoint or os.environ.get("S3_ENDPOINT", "")
|
||||
self.bucket = bucket or os.environ.get("S3_BUCKET", "")
|
||||
self.access_key = access_key or os.environ.get("S3_ACCESS_KEY", "")
|
||||
self.secret_key = secret_key or os.environ.get("S3_SECRET_KEY", "")
|
||||
self.region = region or os.environ.get("S3_REGION", "us-east-1")
|
||||
self.secure = secure if secure is not None else os.environ.get("S3_SECURE", "true").lower() == "true"
|
||||
self._client: Any = None # lazy init
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
"""Lazy-initialize the S3 client (minio or boto3)."""
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
|
||||
try:
|
||||
from minio import Minio # type: ignore
|
||||
|
||||
self._client = Minio(
|
||||
endpoint=self.endpoint.replace("https://", "").replace("http://", ""),
|
||||
access_key=self.access_key,
|
||||
secret_key=self.secret_key,
|
||||
secure=self.secure,
|
||||
region=self.region,
|
||||
)
|
||||
# Ensure bucket exists
|
||||
if not self._client.bucket_exists(self.bucket):
|
||||
self._client.make_bucket(self.bucket)
|
||||
logger.info("S3Storage: connected to %s, bucket=%s", self.endpoint, self.bucket)
|
||||
return self._client
|
||||
except ImportError:
|
||||
logger.error("S3Storage: minio package not installed. Install with: pip install minio")
|
||||
raise
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
client = self._get_client()
|
||||
client.put_object(
|
||||
bucket_name=self.bucket,
|
||||
object_name=path,
|
||||
data=BytesIO(data),
|
||||
length=len(data),
|
||||
)
|
||||
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
|
||||
return path
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
client = self._get_client()
|
||||
response = client.get_object(self.bucket, path)
|
||||
return response.read()
|
||||
|
||||
async def delete(self, path: str) -> bool:
|
||||
client = self._get_client()
|
||||
try:
|
||||
client.remove_object(self.bucket, path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def exists(self, path: str) -> bool:
|
||||
client = self._get_client()
|
||||
try:
|
||||
client.stat_object(self.bucket, path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_url(self, path: str, expires: int = 3600) -> 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]:
|
||||
client = self._get_client()
|
||||
objects = client.list_objects(self.bucket, prefix=prefix, recursive=True)
|
||||
return [obj.object_name for obj in objects]
|
||||
|
||||
|
||||
# ─── Factory ───
|
||||
|
||||
_storage_backend: StorageBackend | None = None
|
||||
|
||||
|
||||
def get_storage_backend() -> StorageBackend:
|
||||
"""Get the configured storage backend singleton."""
|
||||
global _storage_backend
|
||||
if _storage_backend is None:
|
||||
backend_type = os.environ.get("STORAGE_BACKEND", "local").lower()
|
||||
if backend_type == "s3":
|
||||
_storage_backend = S3Storage()
|
||||
logger.info("Storage backend: S3 (%s)", os.environ.get("S3_ENDPOINT", ""))
|
||||
else:
|
||||
_storage_backend = LocalStorage()
|
||||
logger.info("Storage backend: Local (%s)", os.environ.get("STORAGE_PATH", "/data/uploads"))
|
||||
return _storage_backend
|
||||
|
||||
|
||||
def reset_storage_backend() -> None:
|
||||
"""Reset the storage backend singleton (for testing)."""
|
||||
global _storage_backend
|
||||
_storage_backend = None
|
||||
@@ -31,6 +31,7 @@ from app.routes import (
|
||||
companies,
|
||||
contact_folders,
|
||||
contacts,
|
||||
entity_history,
|
||||
groups,
|
||||
health,
|
||||
import_export,
|
||||
@@ -235,6 +236,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(companies.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(contact_folders.router)
|
||||
app.include_router(entity_history.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(ai_copilot.router)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.company import Company
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.currency import Currency
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
"Contact",
|
||||
"ContactPerson",
|
||||
"ContactFolder",
|
||||
"EntityHistory",
|
||||
"Currency",
|
||||
"TaxRate",
|
||||
"Sequence",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""EntityHistory model — snapshot history for undo/restore functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import 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
|
||||
|
||||
|
||||
class EntityHistory(Base, TenantMixin):
|
||||
"""Snapshot history for undo/restore functionality.
|
||||
|
||||
Every CRUD action (create/update/delete) stores a full entity snapshot
|
||||
so users can undo changes or revert to previous versions.
|
||||
"""
|
||||
|
||||
__tablename__ = "entity_history"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False, index=True)
|
||||
action: Mapped[str] = mapped_column(String(20), nullable=False) # 'create', 'update', 'delete'
|
||||
snapshot_before: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
snapshot_after: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
changes: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
@@ -45,3 +45,8 @@ class SystemSettings(Base, TenantMixin):
|
||||
invoice_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="RE-")
|
||||
quote_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="AN-")
|
||||
payment_terms_days: Mapped[int] = mapped_column(Integer, nullable=False, default=14)
|
||||
# Theme customization
|
||||
theme_primary_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#2563eb")
|
||||
theme_accent_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#d946ef")
|
||||
theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter")
|
||||
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
|
||||
|
||||
@@ -383,10 +383,10 @@ async def _extract_attachment_content(
|
||||
text_content = content.decode("utf-8", errors="replace")
|
||||
elif mime == "application/pdf" or att.filename.endswith(".pdf"):
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(stream=content, filetype="pdf")
|
||||
text_content = "\n".join(page.get_text() for page in doc)
|
||||
doc.close()
|
||||
from pypdf import PdfReader
|
||||
from io import BytesIO
|
||||
reader = PdfReader(BytesIO(content))
|
||||
text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except ImportError:
|
||||
text_content = f"[PDF file: {att.filename} - extraction not available]"
|
||||
elif mime.startswith("image/"):
|
||||
|
||||
@@ -322,8 +322,9 @@ async def deep_analysis(
|
||||
async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
"""Heartbeat job for the AI Proactive plugin.
|
||||
|
||||
Runs every 5 minutes (scheduled by the plugin on activation).
|
||||
Posts a status message to the 'Live KI' room in the kommunikation system.
|
||||
Interval is configurable via ProactiveSettings.heartbeat_interval_seconds.
|
||||
Target room is configurable via ProactiveSettings.heartbeat_target_room.
|
||||
Posts a status message to the configured room in the kommunikation system.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
@@ -340,13 +341,33 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
)
|
||||
|
||||
async with create_db_session(tid) as db:
|
||||
# Create or get the 'Live KI' room for this user
|
||||
# Check if heartbeat is enabled and get configuration
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
settings_result = await db.execute(
|
||||
sa_select(ProactiveSettings)
|
||||
.where(ProactiveSettings.tenant_id == tid)
|
||||
.where(ProactiveSettings.user_id == uid)
|
||||
.limit(1)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
# If no settings or heartbeat disabled, skip
|
||||
if settings is not None and not settings.heartbeat_enabled:
|
||||
logger.debug("heartbeat: disabled for user %s", user_id)
|
||||
return
|
||||
|
||||
# Get target room name from settings or use default
|
||||
target_room_title = settings.heartbeat_target_room if settings else "Live KI"
|
||||
|
||||
# Create or get the target room for this user
|
||||
room = await create_plugin_room(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
plugin_name="ai_proactive",
|
||||
title="Live KI",
|
||||
title=target_room_title,
|
||||
participant_type="ai_proactive",
|
||||
user_role="member",
|
||||
)
|
||||
|
||||
@@ -109,3 +109,7 @@ class ProactiveSettings(Base, TenantMixin):
|
||||
Integer, nullable=False, default=10
|
||||
)
|
||||
model: Mapped[str] = mapped_column(String(100), nullable=False, default="ollama/deepseek-v4-flash")
|
||||
# Heartbeat configuration
|
||||
heartbeat_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
heartbeat_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
|
||||
heartbeat_target_room: Mapped[str] = mapped_column(String(200), nullable=False, default="Live KI")
|
||||
|
||||
@@ -72,6 +72,9 @@ def _settings_to_response(s: ProactiveSettings) -> SettingsResponse:
|
||||
confidence_threshold=s.confidence_threshold,
|
||||
rate_limit_seconds=s.rate_limit_seconds,
|
||||
model=s.model,
|
||||
heartbeat_enabled=s.heartbeat_enabled,
|
||||
heartbeat_interval_seconds=s.heartbeat_interval_seconds,
|
||||
heartbeat_target_room=s.heartbeat_target_room,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ class SettingsResponse(BaseModel):
|
||||
confidence_threshold: float
|
||||
rate_limit_seconds: int
|
||||
model: str
|
||||
heartbeat_enabled: bool = True
|
||||
heartbeat_interval_seconds: int = 300
|
||||
heartbeat_target_room: str = "Live KI"
|
||||
available_models: list[str] = Field(default_factory=lambda: [
|
||||
'ollama/deepseek-v4-flash',
|
||||
'ollama/deepseek-v4-pro',
|
||||
@@ -90,6 +93,9 @@ class SettingsUpdate(BaseModel):
|
||||
confidence_threshold: float | None = None
|
||||
rate_limit_seconds: int | None = None
|
||||
model: str | None = None
|
||||
heartbeat_enabled: bool | None = None
|
||||
heartbeat_interval_seconds: int | None = None
|
||||
heartbeat_target_room: str | None = None
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
|
||||
@@ -114,6 +114,9 @@ async def get_user_settings(
|
||||
confidence_threshold=0.5,
|
||||
rate_limit_seconds=10,
|
||||
model="ollama/deepseek-v4-flash",
|
||||
heartbeat_enabled=True,
|
||||
heartbeat_interval_seconds=300,
|
||||
heartbeat_target_room="Live KI",
|
||||
)
|
||||
db.add(settings)
|
||||
await db.flush()
|
||||
|
||||
@@ -34,5 +34,11 @@ class CalendarPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"calendar:delete",
|
||||
"calendar:share",
|
||||
"calendar:admin",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.deps import get_current_user, require_admin, require_permission
|
||||
from app.plugins.builtins.calendar.ics_utils import (
|
||||
export_entries_to_ics,
|
||||
ics_events_to_entry_data,
|
||||
@@ -163,7 +163,7 @@ async def _check_write_permission(
|
||||
# ─── Calendar CRUD ───
|
||||
|
||||
|
||||
@calendar_router.get("")
|
||||
@calendar_router.get("", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def list_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -180,7 +180,7 @@ async def list_calendars(
|
||||
return [_calendar_to_dict(c) for c in cals]
|
||||
|
||||
|
||||
@calendar_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@calendar_router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_calendar(
|
||||
body: CalendarCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -201,7 +201,7 @@ async def create_calendar(
|
||||
return _calendar_to_dict(cal)
|
||||
|
||||
|
||||
@calendar_router.patch("/{calendar_id}")
|
||||
@calendar_router.patch("/{calendar_id}", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def update_calendar(
|
||||
calendar_id: str,
|
||||
body: CalendarUpdate,
|
||||
@@ -226,7 +226,7 @@ async def update_calendar(
|
||||
return _calendar_to_dict(cal)
|
||||
|
||||
|
||||
@calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("calendar:delete"))])
|
||||
async def delete_calendar(
|
||||
calendar_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -259,7 +259,7 @@ async def delete_calendar(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@calendar_router.post("/{calendar_id}/share")
|
||||
@calendar_router.post("/{calendar_id}/share", dependencies=[Depends(require_permission("calendar:share"))])
|
||||
async def share_calendar(
|
||||
calendar_id: str,
|
||||
body: ShareRequest,
|
||||
@@ -297,7 +297,7 @@ async def share_calendar(
|
||||
}
|
||||
|
||||
|
||||
@calendar_router.get("/{calendar_id}/permissions")
|
||||
@calendar_router.get("/{calendar_id}/permissions", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def get_permissions(
|
||||
calendar_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -324,7 +324,7 @@ async def get_permissions(
|
||||
# ─── Entries ───
|
||||
|
||||
|
||||
@router.get("/calendar/entries")
|
||||
@router.get("/calendar/entries", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def list_entries(
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
@@ -385,7 +385,7 @@ async def list_entries(
|
||||
return all_occurrences
|
||||
|
||||
|
||||
@router.post("/calendar/entries", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/calendar/entries", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_entry(
|
||||
body: EntryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -458,7 +458,7 @@ async def create_entry(
|
||||
return _entry_to_dict(entry)
|
||||
|
||||
|
||||
@router.get("/calendar/entries/export")
|
||||
@router.get("/calendar/entries/export", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def export_entries(
|
||||
format: str = "csv",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -519,7 +519,7 @@ async def export_entries(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendar/entries/{entry_id}")
|
||||
@router.get("/calendar/entries/{entry_id}", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def get_entry(
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -555,7 +555,7 @@ async def get_entry(
|
||||
return _entry_to_dict(entry, links, subtasks)
|
||||
|
||||
|
||||
@router.patch("/calendar/entries/{entry_id}")
|
||||
@router.patch("/calendar/entries/{entry_id}", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def update_entry(
|
||||
entry_id: str,
|
||||
body: EntryUpdate,
|
||||
@@ -611,7 +611,7 @@ async def update_entry(
|
||||
return _entry_to_dict(entry)
|
||||
|
||||
|
||||
@router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("calendar:delete"))])
|
||||
async def delete_entry(
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -631,7 +631,7 @@ async def delete_entry(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/calendar/entries/{entry_id}/link")
|
||||
@router.post("/calendar/entries/{entry_id}/link", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def link_entry(
|
||||
entry_id: str,
|
||||
body: LinkRequest,
|
||||
@@ -658,7 +658,7 @@ async def link_entry(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_subtask(
|
||||
entry_id: str,
|
||||
body: SubtaskCreate,
|
||||
@@ -684,7 +684,7 @@ async def create_subtask(
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}")
|
||||
@router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def update_subtask(
|
||||
entry_id: str,
|
||||
sub_id: str,
|
||||
@@ -717,7 +717,7 @@ async def update_subtask(
|
||||
# ─── Bulk + Kanban + Export ───
|
||||
|
||||
|
||||
@router.post("/calendar/entries/bulk")
|
||||
@router.post("/calendar/entries/bulk", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def bulk_action(
|
||||
body: BulkAction,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -751,7 +751,7 @@ async def bulk_action(
|
||||
return {"action": body.action, "affected": len(entry_ids)}
|
||||
|
||||
|
||||
@router.get("/calendar/kanban")
|
||||
@router.get("/calendar/kanban", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def kanban_board(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -783,7 +783,7 @@ async def kanban_board(
|
||||
# ─── ICS Feed + Import ───
|
||||
|
||||
|
||||
@router.get("/calendar/{calendar_id}/ics-feed")
|
||||
@router.get("/calendar/{calendar_id}/ics-feed", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def ics_feed(
|
||||
calendar_id: str,
|
||||
token: str | None = None,
|
||||
@@ -820,7 +820,7 @@ async def ics_feed(
|
||||
return Response(content=ics_content, media_type="text/calendar")
|
||||
|
||||
|
||||
@router.post("/calendar/import")
|
||||
@router.post("/calendar/import", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def import_ics(
|
||||
file: UploadFile = File(...),
|
||||
calendar_id: str | None = None,
|
||||
@@ -879,7 +879,7 @@ async def import_ics(
|
||||
# ─── Resources ───
|
||||
|
||||
|
||||
@resource_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@resource_router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_resource(
|
||||
body: ResourceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -897,7 +897,7 @@ async def create_resource(
|
||||
return {"id": str(resource.id), "name": resource.name, "type": resource.type}
|
||||
|
||||
|
||||
@router.post("/calendar/entries/{entry_id}/book-resource")
|
||||
@router.post("/calendar/entries/{entry_id}/book-resource", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def book_resource(
|
||||
entry_id: str,
|
||||
body: BookResourceRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DMS plugin — folders, files, preview, OnlyOffice, internal sharing, search, bulk ops."""
|
||||
"""DMS plugin — folders, files, preview, Collabora, internal sharing, search, bulk ops."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +13,7 @@ class DmsPlugin(BasePlugin):
|
||||
name="dms",
|
||||
version="1.0.0",
|
||||
display_name="DMS",
|
||||
description="Document management: folder hierarchy, file upload, PDF preview, OnlyOffice edit sessions, internal sharing, search, bulk ops.",
|
||||
description="Document management: folder hierarchy, file upload, PDF preview, Collabora edit sessions, internal sharing, search, bulk ops.",
|
||||
dependencies=["permissions"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
@@ -24,5 +24,11 @@ class DmsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"dms:read",
|
||||
"dms:write",
|
||||
"dms:delete",
|
||||
"dms:share",
|
||||
"dms:admin",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DMS plugin routes — folders, files, preview, OnlyOffice, internal sharing, search, bulk."""
|
||||
"""DMS plugin routes — folders, files, preview, Collabora, internal sharing, search, bulk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,7 +21,8 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
from app.plugins.builtins.dms.schemas import (
|
||||
@@ -37,10 +38,7 @@ from app.plugins.builtins.permissions.models import Permission
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
|
||||
|
||||
# Configurable storage base path
|
||||
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
|
||||
|
||||
# Office file extensions mapped to OnlyOffice file types
|
||||
# Office file extensions mapped to Collabora file types
|
||||
OFFICE_EXTENSIONS = {
|
||||
".docx": "docx",
|
||||
".xlsx": "xlsx",
|
||||
@@ -61,8 +59,8 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
|
||||
|
||||
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
|
||||
"""Build on-disk storage path for a file."""
|
||||
return os.path.join(DMS_STORAGE_BASE, str(tenant_id), str(file_id))
|
||||
"""Build relative storage path for a file (relative to storage base)."""
|
||||
return f"{tenant_id}/{file_id}"
|
||||
|
||||
|
||||
def _get_file_extension(filename: str) -> str:
|
||||
@@ -73,7 +71,7 @@ def _get_file_extension(filename: str) -> str:
|
||||
# ─── Folders ───
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_folders(
|
||||
parent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -142,7 +140,7 @@ async def list_folders(
|
||||
return root_nodes
|
||||
|
||||
|
||||
@router.post("/folders", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def create_folder(
|
||||
body: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -221,7 +219,7 @@ async def create_folder(
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/folders/{folder_id}")
|
||||
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
body: FolderUpdate,
|
||||
@@ -334,7 +332,7 @@ async def update_folder(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -396,7 +394,7 @@ async def delete_folder(
|
||||
# ─── Files ───
|
||||
|
||||
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: str | None = Form(None),
|
||||
@@ -433,10 +431,9 @@ async def upload_file(
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = _file_storage_path(tenant_id, file_id)
|
||||
|
||||
# Ensure directory exists and write file
|
||||
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
||||
with open(storage_path, "wb") as f: # noqa: ASYNC230
|
||||
f.write(content)
|
||||
# Save file via storage backend
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, content)
|
||||
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
@@ -467,7 +464,7 @@ async def upload_file(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/files/{file_id}")
|
||||
@router.get("/files/{file_id}", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def get_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -502,7 +499,7 @@ async def get_file(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
@router.get("/files", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_all_files(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -534,7 +531,7 @@ async def list_all_files(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}/files")
|
||||
@router.get("/folders/{folder_id}/files", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_files_in_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -580,7 +577,7 @@ async def list_files_in_folder(
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/files/{file_id}")
|
||||
@router.patch("/files/{file_id}", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def update_file(
|
||||
file_id: str,
|
||||
body: FileUpdate,
|
||||
@@ -638,7 +635,7 @@ async def update_file(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def delete_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -666,7 +663,7 @@ async def delete_file(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/restore")
|
||||
@router.post("/files/{file_id}/restore", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def restore_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -708,7 +705,7 @@ async def restore_file(
|
||||
# ─── Preview & Edit ───
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/preview")
|
||||
@router.get("/files/{file_id}/preview", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def preview_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -734,14 +731,16 @@ async def preview_file(
|
||||
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
|
||||
)
|
||||
|
||||
if not os.path.exists(dms_file.storage_path):
|
||||
storage = get_storage_backend()
|
||||
if not await storage.exists(dms_file.storage_path):
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "File not found on disk", "code": "file_missing"}
|
||||
)
|
||||
|
||||
content = await storage.read(dms_file.storage_path)
|
||||
|
||||
def _stream():
|
||||
with open(dms_file.storage_path, "rb") as f:
|
||||
yield from iter(lambda: f.read(65536), b"")
|
||||
yield content
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
@@ -750,13 +749,13 @@ async def preview_file(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/edit-session")
|
||||
@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def create_edit_session(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config."""
|
||||
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = current_user["user_id"]
|
||||
user_name = current_user.get("name", "Unknown")
|
||||
@@ -810,7 +809,7 @@ async def create_edit_session(
|
||||
# ─── Internal Sharing ───
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/share")
|
||||
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
|
||||
async def share_file(
|
||||
file_id: str,
|
||||
body: ShareRequest,
|
||||
@@ -902,7 +901,7 @@ async def share_file(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))])
|
||||
async def remove_share(
|
||||
file_id: str,
|
||||
body: ShareRemoveRequest = Body(...),
|
||||
@@ -946,7 +945,7 @@ async def remove_share(
|
||||
# ─── Search & Bulk ───
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def search_files(
|
||||
q: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -979,7 +978,7 @@ async def search_files(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shared-with-me")
|
||||
@router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def shared_with_me(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -1031,7 +1030,7 @@ async def shared_with_me(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/files/bulk-move")
|
||||
@router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def bulk_move(
|
||||
body: BulkMoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -1082,7 +1081,7 @@ async def bulk_move(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/files/bulk-delete")
|
||||
@router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def bulk_delete(
|
||||
body: BulkDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -65,6 +65,6 @@ class BulkDeleteRequest(BaseModel):
|
||||
file_ids: list[str] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class OnlyOfficeConfig(BaseModel):
|
||||
class CollaboraConfig(BaseModel):
|
||||
document: dict
|
||||
editorConfig: dict # noqa: N815
|
||||
|
||||
@@ -36,7 +36,11 @@ class EntityLinksPlugin(BasePlugin):
|
||||
],
|
||||
events=["company.deleted", "contact.deleted"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"entity_links:read",
|
||||
"entity_links:write",
|
||||
"entity_links:delete",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.entity_links.models import EntityLink
|
||||
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
|
||||
|
||||
@@ -29,7 +29,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/link")
|
||||
@router.post("/files/{file_id}/link", dependencies=[Depends(require_permission("entity_links:write"))])
|
||||
async def link_file_to_entity(
|
||||
file_id: str,
|
||||
body: EntityLinkRequest,
|
||||
@@ -84,7 +84,7 @@ async def link_file_to_entity(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("entity_links:delete"))])
|
||||
async def unlink_file_from_entity(
|
||||
file_id: str,
|
||||
body: EntityLinkRequest = Body(...),
|
||||
@@ -112,7 +112,7 @@ async def unlink_file_from_entity(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/links")
|
||||
@router.get("/files/{file_id}/links", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||
async def list_file_links(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -140,7 +140,7 @@ async def list_file_links(
|
||||
]
|
||||
|
||||
|
||||
@company_router.get("/{company_id}/files")
|
||||
@company_router.get("/{company_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||
async def list_company_files(
|
||||
company_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -169,7 +169,7 @@ async def list_company_files(
|
||||
]
|
||||
|
||||
|
||||
@contact_router.get("/{contact_id}/files")
|
||||
@contact_router.get("/{contact_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||
async def list_contact_files(
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -47,6 +47,7 @@ class MailAccount(Base, TenantMixin):
|
||||
smtp_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
password_salt: Mapped[str] = mapped_column(String(64), nullable=False, default="") # base64-encoded random salt
|
||||
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
sent_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -8,9 +8,9 @@ so that GET /search, /threads, /templates etc. are not shadowed by GET /{mail_id
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import and_, asc, desc, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.deps import require_permission
|
||||
from app.plugins.builtins.mail.models import (
|
||||
ContactPgpKey,
|
||||
@@ -109,32 +110,31 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
|
||||
async def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
|
||||
"""Resolve temporary attachment upload IDs to file paths on disk.
|
||||
|
||||
Each uploaded attachment is stored under
|
||||
``<storage>/mail_uploads/<temp_id>/<filename>``. We scan the
|
||||
directory for the single file inside and return its metadata.
|
||||
"""
|
||||
import os
|
||||
|
||||
resolved: list[dict] = []
|
||||
base_dir = os.environ.get("STORAGE_PATH", "/tmp")
|
||||
storage = get_storage_backend()
|
||||
for att_id in attachment_ids:
|
||||
upload_dir = os.path.join(base_dir, "mail_uploads", att_id)
|
||||
if not os.path.isdir(upload_dir):
|
||||
prefix = f"mail_uploads/{att_id}/"
|
||||
files = await storage.list_files(prefix)
|
||||
if not files:
|
||||
continue
|
||||
for fname in os.listdir(upload_dir):
|
||||
fpath = os.path.join(upload_dir, fname)
|
||||
if os.path.isfile(fpath):
|
||||
resolved.append(
|
||||
{
|
||||
"path": fpath,
|
||||
"filename": fname,
|
||||
"mime_type": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
break
|
||||
for fpath in files:
|
||||
fname = os.path.basename(fpath)
|
||||
abs_path = await storage.get_url(fpath)
|
||||
resolved.append(
|
||||
{
|
||||
"path": abs_path,
|
||||
"filename": fname,
|
||||
"mime_type": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
@@ -752,19 +752,10 @@ async def upload_attachment(
|
||||
safe_filename = _sanitize_filename(file.filename or "attachment")
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
# Store in a temp directory keyed by the temp_id
|
||||
import os
|
||||
|
||||
temp_dir = os.path.join(
|
||||
os.environ.get("STORAGE_PATH", "/tmp"), "mail_uploads", temp_id
|
||||
)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
file_path = os.path.join(temp_dir, safe_filename)
|
||||
|
||||
import aiofiles
|
||||
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(content)
|
||||
# Store via storage backend in a path keyed by the temp_id
|
||||
storage = get_storage_backend()
|
||||
file_path = f"mail_uploads/{temp_id}/{safe_filename}"
|
||||
await storage.save(file_path, content)
|
||||
|
||||
return {
|
||||
"id": temp_id,
|
||||
@@ -828,7 +819,7 @@ async def send_mail(
|
||||
in_reply_to=data.in_reply_to,
|
||||
references_header=data.references_header,
|
||||
signature=signature,
|
||||
attachment_paths=_resolve_attachment_paths(data.attachments),
|
||||
attachment_paths=await _resolve_attachment_paths(data.attachments),
|
||||
)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
@@ -1423,19 +1414,16 @@ async def download_attachment(
|
||||
).scalar_one_or_none()
|
||||
if not attachment:
|
||||
raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"})
|
||||
if not Path(attachment.storage_path).exists():
|
||||
storage = get_storage_backend()
|
||||
if not await storage.exists(attachment.storage_path):
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "File not found on disk", "code": "file_missing"}
|
||||
)
|
||||
import aiofiles
|
||||
|
||||
content = await storage.read(attachment.storage_path)
|
||||
|
||||
async def file_stream():
|
||||
async with aiofiles.open(attachment.storage_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
yield content
|
||||
|
||||
return StreamingResponse(
|
||||
file_stream(),
|
||||
|
||||
@@ -134,9 +134,12 @@ def attachment_to_response(att: MailAttachment) -> dict:
|
||||
|
||||
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024")
|
||||
|
||||
# Legacy salt for backward compatibility with existing encrypted passwords
|
||||
_LEGACY_SALT = b"leocrm-mail-salt"
|
||||
|
||||
def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
|
||||
"""Derive a 32-byte Fernet key from a password using PBKDF2."""
|
||||
|
||||
def _derive_key(password: str, salt: bytes) -> bytes:
|
||||
"""Derive a 32-byte Fernet key from a password using PBKDF2 with the given salt."""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
@@ -146,17 +149,39 @@ def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
|
||||
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
||||
|
||||
|
||||
_fernet = Fernet(_derive_key(MAIL_ENCRYPTION_KEY))
|
||||
def generate_salt() -> str:
|
||||
"""Generate a random 32-byte salt and return as base64 string."""
|
||||
salt = os.urandom(32)
|
||||
return base64.urlsafe_b64encode(salt).decode()
|
||||
|
||||
|
||||
def encrypt_password(plaintext: str) -> str:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext."""
|
||||
return _fernet.encrypt(plaintext.encode()).decode()
|
||||
def _get_fernet(salt_b64: str | None = None) -> Fernet:
|
||||
"""Get a Fernet instance. If salt_b64 is provided, use it; otherwise use legacy salt."""
|
||||
if salt_b64:
|
||||
salt = base64.urlsafe_b64decode(salt_b64.encode())
|
||||
else:
|
||||
salt = _LEGACY_SALT
|
||||
return Fernet(_derive_key(MAIL_ENCRYPTION_KEY, salt))
|
||||
|
||||
|
||||
def decrypt_password(ciphertext: str) -> str:
|
||||
"""Decrypt a password encrypted with encrypt_password."""
|
||||
return _fernet.decrypt(ciphertext.encode()).decode()
|
||||
def encrypt_password(plaintext: str, salt_b64: str | None = None) -> str:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext.
|
||||
|
||||
If salt_b64 is provided, uses that salt for key derivation.
|
||||
If not, uses the legacy hardcoded salt (for backward compatibility).
|
||||
"""
|
||||
fernet = _get_fernet(salt_b64)
|
||||
return fernet.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_password(ciphertext: str, salt_b64: str | None = None) -> str:
|
||||
"""Decrypt a password encrypted with encrypt_password.
|
||||
|
||||
If salt_b64 is provided, uses that salt for key derivation.
|
||||
If not, uses the legacy hardcoded salt (for backward compatibility).
|
||||
"""
|
||||
fernet = _get_fernet(salt_b64)
|
||||
return fernet.decrypt(ciphertext.encode()).decode()
|
||||
|
||||
|
||||
# ─── HTML Sanitization (F-MAIL: no script tags) ───
|
||||
@@ -255,6 +280,7 @@ async def create_mail_account(
|
||||
db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||
) -> MailAccount:
|
||||
"""Create a new mail account with encrypted password."""
|
||||
salt = generate_salt()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -267,7 +293,8 @@ async def create_mail_account(
|
||||
smtp_port=data.get("smtp_port", 587),
|
||||
smtp_tls=data.get("smtp_tls", True),
|
||||
username=data.get("username") or data["email_address"],
|
||||
encrypted_password=encrypt_password(data["password"]),
|
||||
encrypted_password=encrypt_password(data["password"], salt),
|
||||
password_salt=salt,
|
||||
is_shared=data.get("is_shared", False),
|
||||
is_active=True,
|
||||
sent_folder_imap_name=data.get("sent_folder_imap_name"),
|
||||
@@ -333,15 +360,20 @@ async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict
|
||||
if api_field in data and data[api_field] is not None:
|
||||
setattr(account, model_field, data[api_field])
|
||||
if "password" in data and data["password"] is not None:
|
||||
account.encrypted_password = encrypt_password(data["password"])
|
||||
new_salt = generate_salt()
|
||||
account.password_salt = new_salt
|
||||
account.encrypted_password = encrypt_password(data["password"], new_salt)
|
||||
await db.flush()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def get_account_password(account: MailAccount) -> str:
|
||||
"""Decrypt and return the account password (internal use only)."""
|
||||
return decrypt_password(account.encrypted_password)
|
||||
"""Decrypt and return the account password (internal use only).
|
||||
|
||||
Uses per-account salt if available, falls back to legacy salt for old accounts.
|
||||
"""
|
||||
return decrypt_password(account.encrypted_password, account.password_salt or None)
|
||||
|
||||
|
||||
def account_to_response(account: MailAccount) -> dict:
|
||||
|
||||
@@ -24,6 +24,11 @@ class TagsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"tags:read",
|
||||
"tags:write",
|
||||
"tags:delete",
|
||||
"tags:admin",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||
from app.plugins.builtins.tags.schemas import (
|
||||
TagAssignRequest,
|
||||
@@ -33,7 +33,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("", dependencies=[Depends(require_permission("tags:read"))])
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -71,7 +71,7 @@ async def list_tags(
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def create_tag(
|
||||
body: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -98,7 +98,7 @@ async def create_tag(
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
@router.patch("/{tag_id}", dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def update_tag(
|
||||
tag_id: str,
|
||||
body: TagUpdate,
|
||||
@@ -138,7 +138,7 @@ async def update_tag(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assign")
|
||||
@router.post("/assign", dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def assign_tag(
|
||||
body: TagAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -194,7 +194,7 @@ async def assign_tag(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tags:delete"))])
|
||||
async def unassign_tag(
|
||||
body: TagUnassignRequest = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -221,7 +221,7 @@ async def unassign_tag(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tags:delete"))])
|
||||
async def delete_tag(
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -246,7 +246,7 @@ async def delete_tag(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/bulk-assign")
|
||||
@router.post("/bulk-assign", dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def bulk_assign_tags(
|
||||
body: TagBulkAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -303,7 +303,7 @@ async def bulk_assign_tags(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tag_id}/entities")
|
||||
@router.get("/{tag_id}/entities", dependencies=[Depends(require_permission("tags:read"))])
|
||||
async def list_tag_entities(
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.jobs import enqueue_job
|
||||
from app.core.permissions import resolve_permissions, filter_fields_by_permission
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
from app.plugins.builtins.unified_search.query_understanding import (
|
||||
@@ -49,6 +50,7 @@ async def search(
|
||||
) -> SearchResponse:
|
||||
"""Perform hybrid search with KI query understanding."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# KI query understanding
|
||||
query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id)
|
||||
@@ -62,6 +64,18 @@ async def search(
|
||||
limit=req.limit,
|
||||
)
|
||||
|
||||
# Resolve user permissions for field-level RBAC
|
||||
resolved_perms = await resolve_permissions(db, user_id, tenant_id)
|
||||
|
||||
# Map entity_type to module name for field-level permissions
|
||||
_ENTITY_TO_MODULE = {
|
||||
"contact": "contacts",
|
||||
"company": "contacts",
|
||||
"mail": "mail",
|
||||
"file": "dms",
|
||||
"event": "calendar",
|
||||
}
|
||||
|
||||
# KI result aggregation
|
||||
aggregation = await llm_aggregate_results(results, req.query, db=db, tenant_id=tenant_id)
|
||||
|
||||
@@ -72,7 +86,11 @@ async def search(
|
||||
title=r.get("title", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
score=r.get("score", 0.0),
|
||||
data=r.get("data", {}),
|
||||
data=filter_fields_by_permission(
|
||||
r.get("data", {}),
|
||||
resolved_perms,
|
||||
_ENTITY_TO_MODULE.get(r.get("entity_type", ""), r.get("entity_type", "")),
|
||||
),
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@ async def extract_text_from_file(file_path: str, mime_type: str) -> str:
|
||||
"""Extract text content from a file based on its MIME type.
|
||||
|
||||
Supports:
|
||||
- PDF (via PyMuPDF/fitz)
|
||||
- PDF (via pypdf)
|
||||
- DOCX (via python-docx)
|
||||
- XLSX (via openpyxl)
|
||||
- PPTX (via python-pptx)
|
||||
@@ -57,14 +57,15 @@ def _truncate(text: str) -> str:
|
||||
|
||||
|
||||
async def _extract_pdf(file_path: str) -> str:
|
||||
"""Extract text from PDF using PyMuPDF (fitz)."""
|
||||
import fitz # PyMuPDF
|
||||
"""Extract text from PDF using pypdf (BSD-licensed)."""
|
||||
from pypdf import PdfReader
|
||||
|
||||
doc = fitz.open(file_path)
|
||||
reader = PdfReader(file_path)
|
||||
parts: list[str] = []
|
||||
for page in doc:
|
||||
parts.append(page.get_text())
|
||||
doc.close()
|
||||
for page in reader.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ class PluginManifest(BaseModel):
|
||||
field_definitions: list[FieldDefinition] = Field(
|
||||
default_factory=list, description="Field definitions for field-level permissions"
|
||||
)
|
||||
agent_capabilities: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="AI agent capabilities this plugin provides (e.g. 'contact_search', 'email_draft')",
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.routes import (
|
||||
auth, # noqa: F401
|
||||
companies, # noqa: F401
|
||||
contacts, # noqa: F401
|
||||
entity_history, # noqa: F401
|
||||
currencies, # noqa: F401
|
||||
taxes, # noqa: F401
|
||||
sequences, # noqa: F401
|
||||
|
||||
@@ -117,11 +117,12 @@ async def delete_contact(
|
||||
):
|
||||
"""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)
|
||||
await contact_service.delete_contact(db, tenant_id, contact_id, user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Entity history routes — query, restore, and undo entity snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.schemas.entity_history import EntityHistoryListResponse, EntityHistoryResponse, RestoreRequest
|
||||
from app.services import entity_history_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/entity-history", tags=["entity-history"])
|
||||
|
||||
|
||||
def _entry_to_dict(e) -> dict:
|
||||
"""Serialize an EntityHistory ORM object to dict."""
|
||||
return {
|
||||
"id": str(e.id),
|
||||
"entity_type": e.entity_type,
|
||||
"entity_id": str(e.entity_id),
|
||||
"action": e.action,
|
||||
"snapshot_before": e.snapshot_before,
|
||||
"snapshot_after": e.snapshot_after,
|
||||
"changes": e.changes,
|
||||
"user_id": str(e.user_id) if e.user_id else None,
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}", response_model=EntityHistoryListResponse)
|
||||
async def get_entity_history(
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get history entries for an entity, newest first."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
eid = uuid.UUID(entity_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
|
||||
entries = await entity_history_service.get_entity_history(
|
||||
db, tenant_id, entity_type, eid, limit=limit
|
||||
)
|
||||
return EntityHistoryListResponse(
|
||||
items=[EntityHistoryResponse(**_entry_to_dict(e)) for e in entries],
|
||||
total=len(entries),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_from_history(
|
||||
body: RestoreRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
):
|
||||
"""Restore an entity from a history entry."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
hid = uuid.UUID(body.history_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid history_id") from None
|
||||
try:
|
||||
return await entity_history_service.restore_from_history(db, tenant_id, hid, user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
|
||||
|
||||
@router.post("/undo/{entity_type}/{entity_id}")
|
||||
async def undo_last_action(
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
):
|
||||
"""Undo the most recent action for an entity."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
eid = uuid.UUID(entity_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
|
||||
try:
|
||||
return await entity_history_service.undo_last_action(
|
||||
db, tenant_id, user_id, entity_type, eid
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Entity history schemas — response, list, and restore request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EntityHistoryResponse(BaseModel):
|
||||
id: str
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
action: str
|
||||
snapshot_before: dict | None = None
|
||||
snapshot_after: dict | None = None
|
||||
changes: dict | None = None
|
||||
user_id: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class EntityHistoryListResponse(BaseModel):
|
||||
items: list[EntityHistoryResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
history_id: str
|
||||
@@ -24,6 +24,11 @@ class SystemSettingsUpsert(BaseModel):
|
||||
invoice_prefix: str = Field("RE-", max_length=20)
|
||||
quote_prefix: str = Field("AN-", max_length=20)
|
||||
payment_terms_days: int = Field(14, ge=0, le=365)
|
||||
# Theme customization
|
||||
theme_primary_color: str = Field("#2563eb", max_length=20)
|
||||
theme_accent_color: str = Field("#d946ef", max_length=20)
|
||||
theme_font_family: str = Field("Inter", max_length=100)
|
||||
theme_border_radius: str = Field("0.5rem", max_length=20)
|
||||
|
||||
|
||||
class SystemSettingsResponse(BaseModel):
|
||||
@@ -46,5 +51,10 @@ class SystemSettingsResponse(BaseModel):
|
||||
invoice_prefix: str = "RE-"
|
||||
quote_prefix: str = "AN-"
|
||||
payment_terms_days: int = 14
|
||||
# Theme customization
|
||||
theme_primary_color: str = "#2563eb"
|
||||
theme_accent_color: str = "#d946ef"
|
||||
theme_font_family: str = "Inter"
|
||||
theme_border_radius: str = "0.5rem"
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
@@ -11,11 +11,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.models.attachment import Attachment
|
||||
|
||||
# Storage path for uploaded files
|
||||
STORAGE_PATH = os.environ.get("STORAGE_PATH", "/data/uploads")
|
||||
|
||||
|
||||
def _attachment_to_dict(a: Attachment) -> dict[str, Any]:
|
||||
"""Serialize an Attachment ORM object to dict."""
|
||||
@@ -50,17 +48,13 @@ async def save_attachment(
|
||||
mime_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Save a file to storage and create an Attachment record."""
|
||||
# Ensure storage directory exists
|
||||
entity_dir = os.path.join(STORAGE_PATH, entity_type, str(entity_id))
|
||||
os.makedirs(entity_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
# Generate unique filename and relative storage path
|
||||
unique_filename = _generate_unique_filename(filename)
|
||||
file_path = os.path.join(entity_dir, unique_filename)
|
||||
file_path = f"{entity_type}/{entity_id}/{unique_filename}"
|
||||
|
||||
# Write file to disk
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_content)
|
||||
# Save file via storage backend
|
||||
storage = get_storage_backend()
|
||||
await storage.save(file_path, file_content)
|
||||
|
||||
file_size = len(file_content)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.services.entity_history_service import record_history
|
||||
|
||||
|
||||
def _compute_displayname(data: dict) -> str:
|
||||
@@ -239,7 +240,15 @@ async def create_contact(
|
||||
q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id)
|
||||
result = await db.execute(q)
|
||||
contact = result.scalar_one()
|
||||
return _serialize_contact_detail(contact)
|
||||
serialized = _serialize_contact_detail(contact)
|
||||
|
||||
# Record history
|
||||
await record_history(
|
||||
db, tenant_id, user_id, "contact", contact.id,
|
||||
action="create", snapshot_after=serialized,
|
||||
)
|
||||
|
||||
return serialized
|
||||
|
||||
|
||||
async def update_contact(
|
||||
@@ -260,6 +269,9 @@ async def update_contact(
|
||||
if not contact:
|
||||
raise ValueError("Contact not found")
|
||||
|
||||
# Capture snapshot before update
|
||||
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")):
|
||||
merged = {**_serialize_contact(contact), **data}
|
||||
@@ -271,10 +283,30 @@ async def update_contact(
|
||||
contact.updated_by = user_id
|
||||
|
||||
await db.flush()
|
||||
return _serialize_contact_detail(contact)
|
||||
snapshot_after = _serialize_contact_detail(contact)
|
||||
|
||||
# Compute changes diff
|
||||
changes: dict = {}
|
||||
for key, new_val in snapshot_after.items():
|
||||
old_val = snapshot_before.get(key)
|
||||
if old_val != new_val:
|
||||
changes[key] = {"old": old_val, "new": new_val}
|
||||
|
||||
# Record history
|
||||
await record_history(
|
||||
db, tenant_id, user_id, "contact", contact.id,
|
||||
action="update",
|
||||
snapshot_before=snapshot_before,
|
||||
snapshot_after=snapshot_after,
|
||||
changes=changes or None,
|
||||
)
|
||||
|
||||
return snapshot_after
|
||||
|
||||
|
||||
async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
|
||||
async def delete_contact(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, user_id: uuid.UUID | None = None
|
||||
) -> None:
|
||||
"""Soft-delete a contact."""
|
||||
q = select(Contact).where(
|
||||
Contact.id == uuid.UUID(contact_id),
|
||||
@@ -285,10 +317,28 @@ async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
raise ValueError("Contact not found")
|
||||
|
||||
# Capture snapshot before deletion
|
||||
from sqlalchemy.orm import selectinload
|
||||
q2 = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
result2 = await db.execute(q2)
|
||||
contact_full = result2.scalar_one()
|
||||
snapshot_before = _serialize_contact_detail(contact_full)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
|
||||
# Record history
|
||||
await record_history(
|
||||
db, tenant_id, user_id, "contact", contact.id,
|
||||
action="delete", snapshot_before=snapshot_before,
|
||||
)
|
||||
|
||||
|
||||
async def hard_delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
|
||||
"""GDPR hard-delete a contact."""
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Entity history service — record, query, restore, and undo entity snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entity_history import EntityHistory
|
||||
|
||||
|
||||
async def record_history(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
action: str,
|
||||
snapshot_before: dict[str, Any] | None = None,
|
||||
snapshot_after: dict[str, Any] | None = None,
|
||||
changes: dict[str, Any] | None = None,
|
||||
) -> EntityHistory:
|
||||
"""Create a history entry for a CRUD action."""
|
||||
entry = EntityHistory(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
snapshot_before=snapshot_before,
|
||||
snapshot_after=snapshot_after,
|
||||
changes=changes,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry
|
||||
|
||||
|
||||
async def get_entity_history(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
limit: int = 50,
|
||||
) -> list[EntityHistory]:
|
||||
"""Get all history entries for an entity, newest first."""
|
||||
q = (
|
||||
select(EntityHistory)
|
||||
.where(
|
||||
EntityHistory.tenant_id == tenant_id,
|
||||
EntityHistory.entity_type == entity_type,
|
||||
EntityHistory.entity_id == entity_id,
|
||||
)
|
||||
.order_by(EntityHistory.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_history_entry(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
history_id: uuid.UUID,
|
||||
) -> EntityHistory | None:
|
||||
"""Get a specific history entry by ID."""
|
||||
q = select(EntityHistory).where(
|
||||
EntityHistory.id == history_id,
|
||||
EntityHistory.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def restore_from_history(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
history_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Restore an entity to a previous snapshot state.
|
||||
|
||||
For 'delete' actions: un-delete the entity (clear deleted_at).
|
||||
For 'update' actions: revert entity fields to snapshot_before.
|
||||
For 'create' actions: soft-delete the entity (undo creation).
|
||||
|
||||
Returns the restored data dict.
|
||||
"""
|
||||
entry = await get_history_entry(db, tenant_id, history_id)
|
||||
if entry is None:
|
||||
raise ValueError("History entry not found")
|
||||
|
||||
entity_type = entry.entity_type
|
||||
entity_id = entry.entity_id
|
||||
action = entry.action
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from app.models.contact import Contact
|
||||
|
||||
if entity_type == "contact":
|
||||
q = select(Contact).where(
|
||||
Contact.id == entity_id,
|
||||
Contact.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contact = result.scalar_one_or_none()
|
||||
|
||||
if action == "delete":
|
||||
# Un-delete: clear deleted_at
|
||||
if contact is None:
|
||||
raise ValueError("Entity not found for restore")
|
||||
contact.deleted_at = None
|
||||
contact.updated_by = user_id
|
||||
await db.flush()
|
||||
from app.services.contact_service import _serialize_contact_detail
|
||||
from sqlalchemy.orm import selectinload
|
||||
q2 = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
result2 = await db.execute(q2)
|
||||
contact = result2.scalar_one()
|
||||
return _serialize_contact_detail(contact)
|
||||
|
||||
elif action == "update":
|
||||
# Revert to snapshot_before
|
||||
if contact is None:
|
||||
raise ValueError("Entity not found for restore")
|
||||
if entry.snapshot_before is None:
|
||||
raise ValueError("No snapshot_before available for restore")
|
||||
for key, value in entry.snapshot_before.items():
|
||||
if hasattr(contact, key) and key not in ("id", "tenant_id", "created_at", "updated_at", "deleted_at"):
|
||||
setattr(contact, key, value)
|
||||
contact.updated_by = user_id
|
||||
await db.flush()
|
||||
from app.services.contact_service import _serialize_contact_detail
|
||||
from sqlalchemy.orm import selectinload
|
||||
q2 = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
result2 = await db.execute(q2)
|
||||
contact = result2.scalar_one()
|
||||
return _serialize_contact_detail(contact)
|
||||
|
||||
elif action == "create":
|
||||
# Undo creation: soft-delete the entity
|
||||
if contact is None:
|
||||
raise ValueError("Entity not found for restore")
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
contact.updated_by = user_id
|
||||
await db.flush()
|
||||
from app.services.contact_service import _serialize_contact_detail
|
||||
from sqlalchemy.orm import selectinload
|
||||
q2 = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
result2 = await db.execute(q2)
|
||||
contact = result2.scalar_one()
|
||||
return _serialize_contact_detail(contact)
|
||||
|
||||
raise ValueError(f"Unsupported entity type for restore: {entity_type}")
|
||||
|
||||
|
||||
async def undo_last_action(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Undo the most recent action for an entity.
|
||||
|
||||
Returns the restored entity data.
|
||||
Raises ValueError if no history exists.
|
||||
"""
|
||||
q = (
|
||||
select(EntityHistory)
|
||||
.where(
|
||||
EntityHistory.tenant_id == tenant_id,
|
||||
EntityHistory.entity_type == entity_type,
|
||||
EntityHistory.entity_id == entity_id,
|
||||
)
|
||||
.order_by(EntityHistory.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise ValueError("No history found for this entity")
|
||||
|
||||
return await restore_from_history(db, tenant_id, entry.id, user_id)
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Import/export service — CSV import with dry-run preview, CSV/XLSX export."""
|
||||
"""Import/export service — CSV import with dry-run preview, CSV/XLSX export.
|
||||
|
||||
Uses unified Contact model fields: firstname, surname, email_1, phone_1, mobilephone, function.
|
||||
Company import creates Contact with type='company'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,14 +15,14 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.contact import Contact, ContactPerson as Company
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.services.company_service import _company_to_dict
|
||||
from app.models.contact import Contact
|
||||
from app.services.contact_service import _serialize_contact as _contact_to_dict
|
||||
|
||||
# Expected CSV columns for each entity type
|
||||
# Company import creates Contact with type='company' using name field
|
||||
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
|
||||
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"]
|
||||
# Contact import uses unified Contact fields
|
||||
CONTACT_COLUMNS = ["firstname", "surname", "email", "phone", "mobile", "function", "department"]
|
||||
|
||||
|
||||
def _parse_csv(content: str) -> list[dict[str, str]]:
|
||||
@@ -44,9 +48,9 @@ async def import_companies(
|
||||
csv_content: str,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import companies from CSV. If dry_run=True, no DB changes are made.
|
||||
"""Import companies from CSV as Contact with type='company'.
|
||||
|
||||
Returns {total, valid, invalid, errors, created (empty in dry_run)}.
|
||||
Uses unified Contact model: name field for company name, email_1/phone_1 for contact info.
|
||||
"""
|
||||
rows = _parse_csv(csv_content)
|
||||
total = len(rows)
|
||||
@@ -73,29 +77,30 @@ async def import_companies(
|
||||
|
||||
created = []
|
||||
for row in valid_rows:
|
||||
company = Company(
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="company",
|
||||
name=row["name"].strip(),
|
||||
industry=row.get("industry", "").strip() or None,
|
||||
phone=row.get("phone", "").strip() or None,
|
||||
email=row.get("email", "").strip() or None,
|
||||
displayname=row["name"].strip(),
|
||||
email_1=row.get("email", "").strip() or None,
|
||||
phone_1=row.get("phone", "").strip() or None,
|
||||
website=row.get("website", "").strip() or None,
|
||||
description=row.get("description", "").strip() or None,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(company)
|
||||
db.add(contact)
|
||||
await db.flush()
|
||||
await log_audit(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
"import",
|
||||
"company",
|
||||
company.id,
|
||||
changes={"name": company.name},
|
||||
"contact",
|
||||
contact.id,
|
||||
changes={"name": contact.name, "type": "company"},
|
||||
)
|
||||
created.append(_company_to_dict(company))
|
||||
created.append(_contact_to_dict(contact))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
@@ -114,9 +119,10 @@ async def import_contacts(
|
||||
csv_content: str,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import contacts from CSV. If dry_run=True, no DB changes are made.
|
||||
"""Import contacts from CSV using unified Contact model fields.
|
||||
|
||||
Returns {total, valid, invalid, errors, created (empty in dry_run)}.
|
||||
CSV columns: firstname, surname, email, phone, mobile, function, department.
|
||||
Maps to Contact fields: firstname, surname, email_1, phone_1, mobilephone, function.
|
||||
"""
|
||||
rows = _parse_csv(csv_content)
|
||||
total = len(rows)
|
||||
@@ -124,11 +130,15 @@ async def import_contacts(
|
||||
errors = []
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
row_errors = _validate_row(row, ["first_name", "last_name"])
|
||||
if row_errors:
|
||||
for e in row_errors:
|
||||
errors.append({"row": idx, "error": e})
|
||||
# Accept both old (first_name/last_name) and new (firstname/surname) column names
|
||||
firstname = (row.get("firstname") or row.get("first_name") or "").strip()
|
||||
surname = (row.get("surname") or row.get("last_name") or "").strip()
|
||||
if not firstname and not surname:
|
||||
errors.append({"row": idx, "error": "Missing required field: firstname or surname"})
|
||||
else:
|
||||
# Normalize row to use unified field names
|
||||
row["firstname"] = firstname
|
||||
row["surname"] = surname
|
||||
valid_rows.append(row)
|
||||
|
||||
if dry_run:
|
||||
@@ -145,13 +155,14 @@ async def import_contacts(
|
||||
for row in valid_rows:
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
first_name=row["first_name"].strip(),
|
||||
last_name=row["last_name"].strip(),
|
||||
email=row.get("email", "").strip() or None,
|
||||
phone=row.get("phone", "").strip() or None,
|
||||
mobile=row.get("mobile", "").strip() or None,
|
||||
position=row.get("position", "").strip() or None,
|
||||
department=row.get("department", "").strip() or None,
|
||||
type="person",
|
||||
firstname=row["firstname"].strip() or None,
|
||||
surname=row["surname"].strip() or None,
|
||||
displayname=f"{row['firstname']} {row['surname']}",
|
||||
email_1=row.get("email", "").strip() or None,
|
||||
phone_1=row.get("phone", "").strip() or None,
|
||||
mobilephone=row.get("mobile", "").strip() or None,
|
||||
function=row.get("function", "").strip() or None,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
@@ -164,7 +175,7 @@ async def import_contacts(
|
||||
"import",
|
||||
"contact",
|
||||
contact.id,
|
||||
changes={"first_name": contact.first_name, "last_name": contact.last_name},
|
||||
changes={"firstname": contact.firstname, "surname": contact.surname},
|
||||
)
|
||||
created.append(_contact_to_dict(contact))
|
||||
|
||||
@@ -206,14 +217,14 @@ async def export_contacts_csv(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Export contacts as CSV string."""
|
||||
"""Export contacts as CSV string using unified Contact model fields."""
|
||||
q = (
|
||||
select(Contact)
|
||||
.where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Contact.last_name, Contact.first_name)
|
||||
.order_by(Contact.surname, Contact.firstname)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contacts = result.scalars().all()
|
||||
@@ -221,19 +232,23 @@ async def export_contacts_csv(
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"]
|
||||
["id", "type", "firstname", "surname", "name", "email", "phone", "mobile", "function", "city", "postalcode", "country"]
|
||||
)
|
||||
for c in contacts:
|
||||
writer.writerow(
|
||||
[
|
||||
str(c.id),
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email or "",
|
||||
c.phone or "",
|
||||
c.mobile or "",
|
||||
c.position or "",
|
||||
c.department or "",
|
||||
c.type or "person",
|
||||
c.firstname or "",
|
||||
c.surname or "",
|
||||
c.name or "",
|
||||
c.email_1 or "",
|
||||
c.phone_1 or "",
|
||||
c.mobilephone or "",
|
||||
c.function or "",
|
||||
c.mailing_city or "",
|
||||
c.mailing_postalcode or "",
|
||||
c.mailing_country or "",
|
||||
]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
@@ -34,6 +34,10 @@ def _settings_to_dict(s: SystemSettings) -> dict[str, Any]:
|
||||
"invoice_prefix": s.invoice_prefix,
|
||||
"quote_prefix": s.quote_prefix,
|
||||
"payment_terms_days": s.payment_terms_days,
|
||||
"theme_primary_color": s.theme_primary_color,
|
||||
"theme_accent_color": s.theme_accent_color,
|
||||
"theme_font_family": s.theme_font_family,
|
||||
"theme_border_radius": s.theme_border_radius,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
}
|
||||
@@ -106,6 +110,10 @@ async def upsert_system_settings(
|
||||
invoice_prefix=data.get("invoice_prefix", "RE-"),
|
||||
quote_prefix=data.get("quote_prefix", "AN-"),
|
||||
payment_terms_days=data.get("payment_terms_days", 14),
|
||||
theme_primary_color=data.get("theme_primary_color", "#2563eb"),
|
||||
theme_accent_color=data.get("theme_accent_color", "#d946ef"),
|
||||
theme_font_family=data.get("theme_font_family", "Inter"),
|
||||
theme_border_radius=data.get("theme_border_radius", "0.5rem"),
|
||||
)
|
||||
db.add(settings)
|
||||
await db.flush()
|
||||
@@ -122,6 +130,7 @@ async def upsert_system_settings(
|
||||
"company_zip", "company_country", "tax_number", "vat_id", "iban",
|
||||
"bic", "bank_name", "ceo", "trade_register",
|
||||
"invoice_prefix", "quote_prefix", "payment_terms_days",
|
||||
"theme_primary_color", "theme_accent_color", "theme_font_family", "theme_border_radius",
|
||||
)
|
||||
for field in all_fields:
|
||||
if field in data and data[field] is not None:
|
||||
|
||||
Reference in New Issue
Block a user