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:
Agent Zero
2026-07-23 08:42:26 +02:00
parent 3d06cb2353
commit ec81940178
65 changed files with 3061 additions and 277 deletions
+61 -36
View File
@@ -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 (
+224
View File
@@ -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
+2
View File
@@ -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)
+2
View File
@@ -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",
+40
View File
@@ -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
)
+5
View File
@@ -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/"):
+25 -4
View File
@@ -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()
+7 -1
View File
@@ -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 -22
View File
@@ -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,
+9 -3
View File
@@ -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",
],
)
+34 -35
View File
@@ -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),
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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,
)
+6 -6
View File
@@ -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),
+1
View File
@@ -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)
+28 -40
View File
@@ -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(),
+45 -13
View File
@@ -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:
+6 -1
View File
@@ -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 -9
View File
@@ -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),
+19 -1
View File
@@ -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))
+4
View File
@@ -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
+1
View File
@@ -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
+2 -1
View File
@@ -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))
+94
View File
@@ -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
+26
View File
@@ -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
+10
View File
@@ -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
+6 -12
View File
@@ -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)
+53 -3
View File
@@ -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."""
+199
View File
@@ -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)
+55 -40
View File
@@ -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()
+9
View File
@@ -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: