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:
@@ -1,6 +0,0 @@
|
||||
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
ENVIRONMENT=development
|
||||
LOG_LEVEL=INFO
|
||||
BCRYPT_ROUNDS=12
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
+2
-4
@@ -21,7 +21,7 @@ POSTGRES_DB=crm_db
|
||||
DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db
|
||||
|
||||
# --- AUTH_SECRET (REQUIRED, min 32 chars) ------------------------------------
|
||||
# JWT signing secret. MUST be at least 32 characters.
|
||||
# Session signing secret. MUST be at least 32 characters.
|
||||
# Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AUTH_SECRET=MIN_32_CHARS_GENERATE_WITH_secrets_token_urlsafe_32_xxxxxxxxxxxx
|
||||
@@ -33,7 +33,5 @@ CORS_ORIGINS=http://localhost:8000,http://localhost:5173
|
||||
ENVIRONMENT=production
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# --- JWT / bcrypt tuning (keep aligned with .env.example) ---------------------
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRY_HOURS=24
|
||||
# --- bcrypt tuning (keep aligned with .env.example) --------------------------
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
@@ -36,6 +36,15 @@ SECRET_KEY=change-me-in-production-use-a-secure-random-string
|
||||
|
||||
# Storage (file uploads, DMS)
|
||||
STORAGE_PATH=/tmp
|
||||
# Storage backend: local (default) or s3
|
||||
STORAGE_BACKEND=local
|
||||
# S3-compatible storage (when STORAGE_BACKEND=s3)
|
||||
S3_ENDPOINT=
|
||||
S3_BUCKET=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_SECURE=true
|
||||
|
||||
# SMTP / Email
|
||||
SMTP_HOST=localhost
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@ logs/
|
||||
# Alembic (autogenerated migrations excluded, but keep 0001)
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
# Frontend build artifacts (Phase 4c)
|
||||
webui/node_modules/
|
||||
webui/dist/
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Docker
|
||||
.docker-data/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 LeoCRM
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+16
-16
@@ -13,22 +13,22 @@
|
||||
| 0.2 | ✅ done | 2026-07-23 | lucide-react installieren + Icons migrieren |
|
||||
| 0.3 | ✅ done | 2026-07-23 | date-fns installieren + Datum-Formatierung |
|
||||
| 0.4 | ✅ done | 2026-07-23 | hooks.ts aufteilen — 1298 Zeilen → 12 Module + Re-Export-Hub |
|
||||
| 0.5 | ⬜ pending | | Store-Verzeichnis konsolidieren |
|
||||
| 0.6 | ⬜ pending | | Frontend-Bestandsanalyse als Dokument speichern |
|
||||
| 0.7 | ⬜ pending | | UI-Design-Richtlinien erstellen |
|
||||
| 0.8 | ⬜ pending | | Theme-Customization Backend |
|
||||
| 0.9 | ⬜ pending | | Theme-Customization Frontend |
|
||||
| 0.10 | ⬜ pending | | RBAC-Audit & Plugin-Permissions nachrüsten |
|
||||
| 0.11 | ⬜ pending | | LiteLLM-Cleanup & alte llm_client.py migrieren |
|
||||
| 0.12 | ⬜ pending | | KI-Agent-Framework in Plugin-Richtlinien dokumentieren |
|
||||
| 0.13 | ⬜ pending | | Heartbeat konfigurierbar machen |
|
||||
| 0.14 | ⬜ pending | | Unified Search: Field-Level RBAC nachrüsten |
|
||||
| 0.15 | ⬜ pending | | Undo/History-System für CRUD-Operationen |
|
||||
| 0.16 | ⬜ pending | | Storage Backend implementieren (S3-Support) |
|
||||
| 0.17 | ⬜ pending | | Import/Export an unified Contact Model anpassen |
|
||||
| 0.18 | ⬜ pending | | .gitignore & Config-Cleanup |
|
||||
| 0.19 | ⬜ pending | | Mail-Salt Security-Fix |
|
||||
| 0.20 | ⬜ pending | | AGPL-Lizenzen durch pypdf + Collabora ersetzen |
|
||||
| 0.5 | ✅ done | 2026-07-23 | calendarStore.ts nach store/ verschoben, stores/ entfernt |
|
||||
| 0.6 | ✅ done | 2026-07-23 | frontend-gap-analysis.md gespeichert (176 Zeilen) |
|
||||
| 0.7 | ✅ done | 2026-07-23 | UI-Design-Richtlinien erstellt (docs/ui-design-guidelines.md, 535 Zeilen) |
|
||||
| 0.8 | ✅ done | 2026-07-23 | Theme-Customization Backend: 4 Felder (primary_color, accent_color, font_family, border_radius) zu model/schema/service, Migration 0023 | Theme-Customization Backend |
|
||||
| 0.9 | ✅ done | 2026-07-23 | Theme-Customization Frontend: SettingsTheme.tsx, themeStore.ts, Route + Nav, i18n keys, Live-Preview, Dark-Mode-Toggle | Theme-Customization Frontend |
|
||||
| 0.10 | ✅ done | 2026-07-23 | RBAC-Audit: 4 Plugins (calendar, dms, entity_links, tags) mit Permissions versehen, 53 Routes mit require_permission abgesichert | RBAC-Audit & Plugin-Permissions nachrüsten |
|
||||
| 0.11 | ✅ done | 2026-07-23 | LiteLLM-Cleanup: llm_client.py von httpx auf litellm.acompletion migriert, AI_PROVIDER env var, System-Prompt companies→contacts | LiteLLM-Cleanup & alte llm_client.py migrieren |
|
||||
| 0.12 | ✅ done | 2026-07-23 | KI-Agent-Framework: docs/plugin-development-guide.md (348 Zeilen), agent_capabilities Feld im PluginManifest | KI-Agent-Framework in Plugin-Richtlinien dokumentieren |
|
||||
| 0.13 | ✅ done | 2026-07-23 | Heartbeat konfigurierbar: ProactiveSettings um heartbeat_enabled/interval/target_room erweitert, Migration 0024, Schema+Service+Routes, Frontend-UI, Jobs.py nutzt Settings | Heartbeat konfigurierbar machen |
|
||||
| 0.14 | ✅ done | 2026-07-23 | Unified Search Field-Level RBAC: resolve_permissions + filter_fields_by_permission in search route, entity-to-module mapping | Unified Search: Field-Level RBAC nachrüsten |
|
||||
| 0.15 | ✅ done | 2026-07-23 | Undo/History-System: EntityHistory model+service+routes, Migration 0025, contact_service Integration, HistoryViewer Komponente, ContactDetail Integration, i18n | Undo/History-System für CRUD-Operationen |
|
||||
| 0.16 | ✅ done | 2026-07-23 | Storage Backend: app/core/storage.py (LocalStorage + S3Storage), DMS + Attachments + Mail auf Storage Backend umgestellt, minio zu requirements, S3 env vars | Storage Backend implementieren (S3-Support) |
|
||||
| 0.17 | ✅ done | 2026-07-23 | Import/Export: unified Contact Fields (firstname, surname, email_1, phone_1, mobilephone, function), Company-Import als Contact type=company, Export mit unified Fields, Backward-compat für alte CSV-Spalten | Import/Export an unified Contact Model anpassen |
|
||||
| 0.18 | ✅ done | 2026-07-23 | .gitignore: webui→frontend, python-jose entfernt, pyproject.toml Python 3.12, .env aus Git entfernt, dump.rdb+test.txt gelöscht, JWT-Vars aus .env.docker.example entfernt | .gitignore & Config-Cleanup |
|
||||
| 0.19 | ✅ done | 2026-07-23 | Mail-Salt Security-Fix: per-account random salt (generate_salt), encrypt/decrypt mit salt_b64, backward-compat mit Legacy-Salt, Migration 0026 | Mail-Salt Security-Fix |
|
||||
| 0.20 | ✅ done | 2026-07-23 | AGPL ersetzt: PyMuPDF→pypdf (BSD), OnlyOffice→Collabora (LGPL/MPL), requirements.txt, LICENSE (MIT), THIRD_PARTY_LICENSES.md | AGPL-Lizenzen durch pypdf + Collabora ersetzen |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
This file lists all third-party software components used by LeoCRM,
|
||||
along with their respective licenses.
|
||||
|
||||
## Backend Dependencies (Python)
|
||||
|
||||
| Package | License | Usage |
|
||||
|---|---|---|
|
||||
| FastAPI | MIT | Web framework |
|
||||
| SQLAlchemy | MIT | ORM / database toolkit |
|
||||
| Alembic | MIT | Database migrations |
|
||||
| Pydantic | MIT | Data validation |
|
||||
| Pydantic Settings | MIT | Settings management |
|
||||
| asyncpg | Apache 2.0 | PostgreSQL async driver |
|
||||
| Redis (redis-py) | MIT | Redis client |
|
||||
| httpx | BSD-3-Clause | HTTP client |
|
||||
| LiteLLM | MIT | Unified LLM interface |
|
||||
| PydanticAI | MIT | AI agent framework |
|
||||
| pypdf | BSD-3-Clause | PDF text extraction |
|
||||
| python-docx | MIT | DOCX text extraction |
|
||||
| openpyxl | MIT | XLSX text extraction |
|
||||
| python-pptx | MIT | PPTX text extraction |
|
||||
| aiofiles | Apache 2.0 | Async file I/O |
|
||||
| minio | Apache 2.0 | S3-compatible storage client |
|
||||
| cryptography | Apache 2.0 | Encryption (Fernet, PBKDF2) |
|
||||
| bcrypt | Apache 2.0 | Password hashing |
|
||||
| nh3 | MIT | HTML sanitization |
|
||||
| python-multipart | Apache 2.0 | Multipart form parsing |
|
||||
| pgvector | PostgreSQL License | Vector similarity search |
|
||||
| APScheduler | MIT | Job scheduling |
|
||||
| websockets | BSD-3-Clause | WebSocket support |
|
||||
|
||||
## Frontend Dependencies (Node.js)
|
||||
|
||||
| Package | License | Usage |
|
||||
|---|---|---|
|
||||
| React | MIT | UI framework |
|
||||
| React Router | MIT | Client-side routing |
|
||||
| TanStack Query | MIT | Server state management |
|
||||
| TanStack Table | MIT | Table/data grid |
|
||||
| Zustand | MIT | State management |
|
||||
| Tailwind CSS | MIT | CSS framework |
|
||||
| lucide-react | ISC | Icon library |
|
||||
| date-fns | MIT | Date utilities |
|
||||
| react-i18next | MIT | Internationalization |
|
||||
| i18next | MIT | Internationalization core |
|
||||
| react-hook-form | MIT | Form management |
|
||||
| zod | MIT | Schema validation |
|
||||
| clsx | MIT | Class name utility |
|
||||
| Vite | MIT | Build tool |
|
||||
| Vitest | MIT | Test framework |
|
||||
|
||||
## External Services
|
||||
|
||||
| Service | License | Usage |
|
||||
|---|---|---|
|
||||
| Collabora Online | LGPL/MPL | Document editing (DMS) |
|
||||
| PostgreSQL | PostgreSQL License | Database |
|
||||
| Redis | BSD-3-Clause | Cache / sessions |
|
||||
|
||||
## Replaced AGPL Components
|
||||
|
||||
The following AGPL-licensed components have been replaced with permissively
|
||||
licensed alternatives to allow commercial use without copyleft obligations:
|
||||
|
||||
| Original | License | Replacement | License |
|
||||
|---|---|---|---|
|
||||
| PyMuPDF (fitz) | AGPL-3.0 | pypdf | BSD-3-Clause |
|
||||
| OnlyOffice | AGPL-3.0 | Collabora Online | LGPL/MPL |
|
||||
|
||||
---
|
||||
|
||||
*This file is maintained manually and should be updated when dependencies change.*
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Theme customization — add theme fields to system_settings.
|
||||
|
||||
Revision ID: 0023
|
||||
Revises: 0022_contact_folders
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0023_theme_customization"
|
||||
down_revision = "0022_contact_folders"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("system_settings", sa.Column("theme_primary_color", sa.String(20), nullable=False, server_default="#2563eb"))
|
||||
op.add_column("system_settings", sa.Column("theme_accent_color", sa.String(20), nullable=False, server_default="#d946ef"))
|
||||
op.add_column("system_settings", sa.Column("theme_font_family", sa.String(100), nullable=False, server_default="Inter"))
|
||||
op.add_column("system_settings", sa.Column("theme_border_radius", sa.String(20), nullable=False, server_default="0.5rem"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("system_settings", "theme_border_radius")
|
||||
op.drop_column("system_settings", "theme_font_family")
|
||||
op.drop_column("system_settings", "theme_accent_color")
|
||||
op.drop_column("system_settings", "theme_primary_color")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Heartbeat configuration — add heartbeat fields to ai_proactive_settings.
|
||||
|
||||
Revision ID: 0024
|
||||
Revises: 0023_theme_customization
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0024_heartbeat_config"
|
||||
down_revision = "0023_theme_customization"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_interval_seconds", sa.Integer(), nullable=False, server_default=sa.text("300")))
|
||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_target_room", sa.String(200), nullable=False, server_default="Live KI"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("ai_proactive_settings", "heartbeat_target_room")
|
||||
op.drop_column("ai_proactive_settings", "heartbeat_interval_seconds")
|
||||
op.drop_column("ai_proactive_settings", "heartbeat_enabled")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Entity history table for undo/restore functionality.
|
||||
|
||||
Revision ID: 0025_entity_history
|
||||
Revises: 0024_heartbeat_config
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0025_entity_history"
|
||||
down_revision: Union[str, None] = "0024_heartbeat_config"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"entity_history",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("action", sa.String(20), nullable=False),
|
||||
sa.Column("snapshot_before", postgresql.JSONB, nullable=True),
|
||||
sa.Column("snapshot_after", postgresql.JSONB, nullable=True),
|
||||
sa.Column("changes", postgresql.JSONB, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_entity_history_tenant_id", "entity_history", ["tenant_id"])
|
||||
op.create_index("ix_entity_history_entity_type", "entity_history", ["entity_type"])
|
||||
op.create_index("ix_entity_history_entity_id", "entity_history", ["entity_id"])
|
||||
op.create_index("ix_entity_history_user_id", "entity_history", ["user_id"])
|
||||
op.create_index("ix_entity_history_created_at", "entity_history", ["created_at"])
|
||||
op.create_index(
|
||||
"ix_entity_history_tenant_entity",
|
||||
"entity_history",
|
||||
["tenant_id", "entity_type", "entity_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_entity_history_tenant_entity", table_name="entity_history")
|
||||
op.drop_index("ix_entity_history_created_at", table_name="entity_history")
|
||||
op.drop_index("ix_entity_history_user_id", table_name="entity_history")
|
||||
op.drop_index("ix_entity_history_entity_id", table_name="entity_history")
|
||||
op.drop_index("ix_entity_history_entity_type", table_name="entity_history")
|
||||
op.drop_index("ix_entity_history_tenant_id", table_name="entity_history")
|
||||
op.drop_table("entity_history")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Mail salt security fix — add password_salt column to mail_accounts.
|
||||
|
||||
Revision ID: 0026
|
||||
Revises: 0025_entity_history
|
||||
Create Date: 2026-07-23
|
||||
|
||||
Existing accounts get an empty salt and will use the legacy hardcoded salt
|
||||
for backward compatibility. New accounts and password changes will use
|
||||
per-account random salts.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0026_mail_salt_security"
|
||||
down_revision = "0025_entity_history"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("mail_accounts", sa.Column("password_salt", sa.String(64), nullable=False, server_default=""))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mail_accounts", "password_salt")
|
||||
+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:
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# LeoCRM Plugin Development Guide
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Datum:** 2026-07-23
|
||||
> **Gültig für:** Alle Plugin-Entwickler
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin-Struktur
|
||||
|
||||
Jedes Plugin liegt unter `app/plugins/builtins/<plugin_name>/`:
|
||||
|
||||
```
|
||||
app/plugins/builtins/my_plugin/
|
||||
├── __init__.py
|
||||
├── plugin.py # Plugin-Klasse mit Manifest
|
||||
├── routes.py # API-Routes
|
||||
├── models.py # SQLAlchemy-Modelle (optional)
|
||||
├── schemas.py # Pydantic-Schemas (optional)
|
||||
├── services.py # Business-Logik (optional)
|
||||
├── migrations/ # SQL-Migrationen
|
||||
│ └── 0001_initial.sql
|
||||
└── tests/ # Plugin-Tests (optional)
|
||||
```
|
||||
|
||||
## 2. Plugin-Manifest
|
||||
|
||||
Das Manifest definiert Metadaten, Abhängigkeiten, Routes, Events und Permissions:
|
||||
|
||||
```python
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
version="1.0.0",
|
||||
display_name="My Plugin",
|
||||
description="Description of what the plugin does.",
|
||||
dependencies=["permissions"], # Other plugins this depends on
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/my-plugin",
|
||||
module="app.plugins.builtins.my_plugin.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=["contact.created", "contact.updated"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[
|
||||
"my_plugin:read",
|
||||
"my_plugin:write",
|
||||
"my_plugin:delete",
|
||||
],
|
||||
agent_capabilities=[
|
||||
"my_plugin:search",
|
||||
"my_plugin:analyze",
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### Manifest-Felder
|
||||
|
||||
| Feld | Typ | Pflicht | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `name` | `str` | Ja | Eindeutiger Plugin-Name (snake_case) |
|
||||
| `version` | `str` | Ja | Semantic Version |
|
||||
| `display_name` | `str` | Ja | Anzeigename |
|
||||
| `description` | `str` | Nein | Kurzbeschreibung |
|
||||
| `dependencies` | `list[str]` | Nein | Andere Plugins, die geladen sein müssen |
|
||||
| `routes` | `list[PluginRouteDef]` | Nein | API-Routen-Definitionen |
|
||||
| `events` | `list[str]` | Nein | Events, die das Plugin abonniert |
|
||||
| `migrations` | `list[str]` | Nein | SQL-Migrationsdateien |
|
||||
| `permissions` | `list[str]` | Nein | RBAC-Permissions, die das Plugin definiert |
|
||||
| `field_definitions` | `list[FieldDefinition]` | Nein | Feld-Level-Permissions |
|
||||
| `agent_capabilities` | `list[str]` | Nein | KI-Agent-Fähigkeiten, die das Plugin bietet |
|
||||
| `is_core` | `bool` | Nein | Core-Plugin (kann nicht deaktiviert werden) |
|
||||
|
||||
## 3. RBAC-Permissions
|
||||
|
||||
### Permissions definieren
|
||||
|
||||
Im Manifest werden alle Permissions des Plugins aufgelistet:
|
||||
|
||||
```python
|
||||
permissions=[
|
||||
"my_plugin:read",
|
||||
"my_plugin:write",
|
||||
"my_plugin:delete",
|
||||
"my_plugin:admin",
|
||||
],
|
||||
```
|
||||
|
||||
### Routes absichern
|
||||
|
||||
Jede Route muss mit `require_permission` abgesichert werden:
|
||||
|
||||
```python
|
||||
from app.deps import get_current_user, require_permission
|
||||
from fastapi import Depends
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("my_plugin:read"))])
|
||||
async def list_items(current_user: dict = Depends(get_current_user)):
|
||||
...
|
||||
|
||||
@router.post("", status_code=201, dependencies=[Depends(require_permission("my_plugin:write"))])
|
||||
async def create_item(data: ItemCreate, current_user: dict = Depends(get_current_user)):
|
||||
...
|
||||
|
||||
@router.delete("/{item_id}", dependencies=[Depends(require_permission("my_plugin:delete"))])
|
||||
async def delete_item(item_id: str, current_user: dict = Depends(get_current_user)):
|
||||
...
|
||||
```
|
||||
|
||||
### Permission-Namenskonvention
|
||||
|
||||
- Format: `<plugin_name>:<action>`
|
||||
- Standard-Actions: `read`, `write`, `delete`, `share`, `admin`
|
||||
- Beispiele: `calendar:read`, `dms:write`, `tags:delete`
|
||||
|
||||
## 4. KI-Agent-Framework
|
||||
|
||||
LeoCRM bietet ein integriertes KI-Agent-Framework basierend auf **LiteLLM** und **PydanticAI**.
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
Plugin (ai_assistant, ai_proactive, zukünftige)
|
||||
↓
|
||||
LiteLLM (unified LLM interface — 100+ Provider)
|
||||
↓
|
||||
Provider (OpenAI, Anthropic, Google, Ollama, ...)
|
||||
↑
|
||||
Tool Registry (Plugin-Tools für KI-Agenten)
|
||||
```
|
||||
|
||||
### LiteLLM — Unified LLM Interface
|
||||
|
||||
LiteLLM bietet eine einheitliche API für über 100 LLM-Provider. Alle KI-Funktionen
|
||||
in LeoCRM nutzen `litellm.acompletion()`:
|
||||
|
||||
```python
|
||||
import litellm
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model="openai/gpt-4o", # oder anthropic/claude-3-sonnet, ollama/llama3
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_query},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
api_key=os.environ.get("AI_API_KEY"),
|
||||
api_base=os.environ.get("AI_API_BASE"), # optional für self-hosted
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
```
|
||||
|
||||
### Konfiguration
|
||||
|
||||
| Env-Var | Beschreibung | Standard |
|
||||
|---|---|---|
|
||||
| `AI_MODEL` | Modell-Name (z.B. `gpt-4o`, `claude-3-sonnet`, `llama3`) | — |
|
||||
| `AI_API_KEY` | API-Key für den Provider | — |
|
||||
| `AI_API_BASE` | Custom API-Base-URL (optional) | Provider-Standard |
|
||||
| `AI_PROVIDER` | Provider-Präfix (`openai`, `anthropic`, `google`, `ollama`) | `openai` |
|
||||
|
||||
Wenn `AI_MODEL` und `AI_API_KEY` nicht gesetzt sind, läuft der LLM-Client im Mock-Modus
|
||||
(keyword-basierte Action-Mapping für Tests).
|
||||
|
||||
### Tool Registry — KI-Tools registrieren
|
||||
|
||||
Plugins können Tools registrieren, die KI-Agenten während Chat-Sessions aufrufen können.
|
||||
Jedes Tool deklariert Name, Beschreibung, JSON-Schema für Parameter und einen async Handler.
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
|
||||
registry.register(
|
||||
name="search_contacts",
|
||||
description="Search contacts by name, email, or phone number",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {"type": "integer", "description": "Max results", "default": 10},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
handler=my_search_handler,
|
||||
plugin_name="my_plugin",
|
||||
required_permission="contacts:read",
|
||||
category="search",
|
||||
)
|
||||
```
|
||||
|
||||
### Tool Handler
|
||||
|
||||
Der Handler ist eine async Funktion, die Argumente und Kontext empfängt:
|
||||
|
||||
```python
|
||||
async def my_search_handler(arguments: dict, context: dict) -> str:
|
||||
query = arguments.get("query", "")
|
||||
limit = arguments.get("limit", 10)
|
||||
# ... perform search ...
|
||||
return json.dumps({"results": results})
|
||||
```
|
||||
|
||||
### Tools bei Plugin-Deaktivierung abmelden
|
||||
|
||||
```python
|
||||
def on_deactivate(self):
|
||||
registry = get_tool_registry()
|
||||
registry.unregister_plugin("my_plugin")
|
||||
```
|
||||
|
||||
### Agent Capabilities im Manifest
|
||||
|
||||
Das `agent_capabilities` Feld im Manifest deklariert, welche KI-Fähigkeiten ein Plugin bietet:
|
||||
|
||||
```python
|
||||
agent_capabilities=[
|
||||
"contact_search", # Kontakt-Suche
|
||||
"email_draft", # E-Mail-Entwürfe generieren
|
||||
"calendar_scheduling", # Terminvorschläge
|
||||
],
|
||||
```
|
||||
|
||||
Diese Informationen werden vom AI Assistant verwendet, um Nutzern zu zeigen,
|
||||
welche KI-Funktionen verfügbar sind.
|
||||
|
||||
## 5. Events
|
||||
|
||||
Plugins können Events abonnieren und auslösen:
|
||||
|
||||
```python
|
||||
# Im Manifest:
|
||||
events=["contact.created", "contact.updated", "contact.deleted"]
|
||||
|
||||
# Event-Handler im Plugin:
|
||||
async def on_contact_created(self, event_data: dict):
|
||||
# Reagiere auf neues Kontakt-Event
|
||||
pass
|
||||
```
|
||||
|
||||
Events werden vom Event-Publisher im Contact-Service ausgelöst:
|
||||
|
||||
```python
|
||||
from app.core.events import publish_event
|
||||
await publish_event(db, "contact.created", {"contact_id": str(contact.id)})
|
||||
```
|
||||
|
||||
## 6. Datenbank-Migrationen
|
||||
|
||||
SQL-Migrationen liegen unter `migrations/` im Plugin-Verzeichnis:
|
||||
|
||||
```sql
|
||||
-- migrations/0001_initial.sql
|
||||
CREATE TABLE my_plugin_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
Im Manifest referenzieren:
|
||||
```python
|
||||
migrations=["0001_initial.sql"]
|
||||
```
|
||||
|
||||
## 7. UI-Integration
|
||||
|
||||
Siehe `docs/ui-design-guidelines.md` für Frontend-Konventionen.
|
||||
|
||||
- Plugin-Seiten verwenden das 3-Spalten-Explorer-Layout
|
||||
- PluginToolbar für Aktionen
|
||||
- Plugin-Settings als eigene Settings-Sub-Seite
|
||||
- i18n-Keys mit Plugin-Präfix
|
||||
|
||||
## 8. Testing
|
||||
|
||||
Tests liegen unter `tests/` im Plugin-Verzeichnis oder im zentralen `tests/` Ordner:
|
||||
|
||||
```python
|
||||
# tests/test_my_plugin.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_requires_permission(client: AsyncClient, auth_headers):
|
||||
response = await client.get("/api/v1/my-plugin", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_without_permission_returns_403(client: AsyncClient, no_perm_headers):
|
||||
response = await client.get("/api/v1/my-plugin", headers=no_perm_headers)
|
||||
assert response.status_code == 403
|
||||
```
|
||||
|
||||
## 9. Plugin-Beispiel
|
||||
|
||||
Minimal-Beispiel für ein neues Plugin:
|
||||
|
||||
```python
|
||||
# app/plugins/builtins/my_plugin/plugin.py
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
version="1.0.0",
|
||||
display_name="My Plugin",
|
||||
description="A minimal example plugin.",
|
||||
dependencies=[],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/my-plugin",
|
||||
module="app.plugins.builtins.my_plugin.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
migrations=[],
|
||||
permissions=["my_plugin:read", "my_plugin:write"],
|
||||
agent_capabilities=[],
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# app/plugins/builtins/my_plugin/routes.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.deps import get_current_user, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("my_plugin:read"))])
|
||||
async def list_items(current_user: dict = Depends(get_current_user)):
|
||||
return {"items": []}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Dieses Dokument ist verbindlich für alle Plugin-Entwicklung an LeoCRM.*
|
||||
@@ -0,0 +1,535 @@
|
||||
# LeoCRM UI-Design-Richtlinien
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Datum:** 2026-07-23
|
||||
> **Gültig für:** Alle Frontend-Komponenten, Plugin-Seiten und zukünftige Entwicklungen
|
||||
|
||||
---
|
||||
|
||||
## 1. Farbsystem
|
||||
|
||||
Alle Farben sind als Tailwind Design Tokens in `tailwind.config.js` definiert. Jede Farbe hat Schattierungen von 50 (hell) bis 900 (dunkel) plus einen `DEFAULT`-Wert.
|
||||
|
||||
| Token | Hex (DEFAULT) | Verwendung |
|
||||
|---|---|---|
|
||||
| `primary` | `#2563eb` (Blau) | Hauptaktionen, aktive Zustände, Links, Fokus-Ringe |
|
||||
| `secondary` | `#64748b` (Slate) | Text, Borders, Hintergründe, inaktive Zustände |
|
||||
| `accent` | `#d946ef` (Fuchsia) | Hervorhebungen, Info-Badges, KI-Features |
|
||||
| `danger` | `#dc2626` (Rot) | Löschen, Fehler, destruktive Aktionen |
|
||||
| `warning` | `#f59e0b` (Amber) | Warnungen, ausstehende Aktionen |
|
||||
| `success` | `#16a34a` (Grün) | Erfolg, Bestätigungen, aktive Status |
|
||||
|
||||
### Verwendungsregeln
|
||||
|
||||
- **Primary** nur für die wichtigste Aktion pro View. Nicht mehr als eine Primary-Button pro Formular.
|
||||
- **Secondary** für Text, Borders und inaktive UI-Elemente. `secondary-50` für Card-Footer, `secondary-100` für Hover-Zustände.
|
||||
- **Accent** sparsam für KI-Features und Hervorhebungen. Nicht für Standard-Aktionen.
|
||||
- **Danger** ausschließlich für destruktive Aktionen (Löschen, Entfernen). Immer mit `ConfirmDialog` kombinieren.
|
||||
- **Warning** für Status-Badges und Warnhinweise. Nicht als Button-Farbe.
|
||||
- **Success** für Erfolgsmeldungen und Status-Indikatoren. Nicht als Standard-Button.
|
||||
|
||||
### Dark Mode
|
||||
|
||||
- Aktiviert via `darkMode: 'class'` in Tailwind Config.
|
||||
- CSS-Variablen in `:root` (Light) und `.dark` (Dark) definiert.
|
||||
- Dark Mode-Toggle in Settings.
|
||||
- Beim Dark Mode werden `secondary-900` als Hintergrund und `secondary-50` als Text verwendet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Typografie
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|---|---|
|
||||
| Font Family | `Inter` (system-ui fallback) |
|
||||
| Mono Font | `JetBrains Mono` für Code/Daten |
|
||||
| Rendering | `antialiased` |
|
||||
|
||||
### Schriftgrößen-Hierarchie
|
||||
|
||||
| Token | Größe | Zeilenhöhe | Verwendung |
|
||||
|---|---|---|---|
|
||||
| `text-xs` | 0.75rem | 1rem | Badges, Tooltips, Metadaten |
|
||||
| `text-sm` | 0.875rem | 1.25rem | Labels, Helper-Text, Tabellen-Spalten |
|
||||
| `text-base` | 1rem | 1.5rem | Body-Text, Input-Felder |
|
||||
| `text-lg` | 1.125rem | 1.75rem | Card-Titel, Section-Header |
|
||||
| `text-xl` | 1.25rem | 1.75rem | Seiten-Titel |
|
||||
| `text-2xl` | 1.5rem | 2rem | Dashboard-Überschriften |
|
||||
| `text-3xl` | 1.875rem | 2.25rem | Große Überschriften |
|
||||
| `text-4xl` | 2.25rem | 2.5rem | Hero-Text, Login-Titel |
|
||||
|
||||
### Font-Weight
|
||||
|
||||
- `font-medium` (500) — Buttons, Labels, Tab-Header
|
||||
- `font-semibold` (600) — Card-Titel, Seiten-Titel
|
||||
- `font-bold` (700) — Nur für Hervorhebungen, sparsam
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout-Patterns
|
||||
|
||||
### 3-Spalten-Explorer-Layout
|
||||
|
||||
Standard-Layout für Explorer-Plugins (Calendar, Mail, DMS, Contacts):
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────┬──────────────────────┐
|
||||
│ Tree │ Liste/Explorer │ Detail │
|
||||
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
|
||||
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
|
||||
└─────────────┴──────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- Linke Spalte: `ResizablePanel` mit `initialWidth=224`, `minWidth=150`, `maxWidth=600`
|
||||
- Mittlere Spalte: `ResizablePanel` mit `resizable=false` (flex-1)
|
||||
- Rechte Spalte: `ResizablePanel` mit `resizable=false` oder `handleSide="left"`
|
||||
- Drag-Handle auf der rechten Kante der linken Spalte
|
||||
|
||||
```tsx
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
|
||||
<div className="flex h-full">
|
||||
<ResizablePanel initialWidth={224} minWidth={150} maxWidth={600}>
|
||||
<TreeView />
|
||||
</ResizablePanel>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ListView />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DetailView />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### PluginToolbar
|
||||
|
||||
Jede Plugin-Seite registriert Aktionen über den `usePluginToolbarStore`:
|
||||
|
||||
```tsx
|
||||
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
|
||||
|
||||
const { setItems, setActivePlugin } = usePluginToolbarStore();
|
||||
|
||||
useEffect(() => {
|
||||
setActivePlugin('calendar');
|
||||
setItems([
|
||||
{ id: 'create', plugin: 'calendar', type: 'button', label: 'Neu', icon: <Plus />, onClick: handleCreate, group: 'actions' },
|
||||
{ id: 'search', plugin: 'calendar', type: 'search', searchPlaceholder: 'Suchen...', onSearch: handleSearch, group: 'search' },
|
||||
]);
|
||||
}, []);
|
||||
```
|
||||
|
||||
- Toolbar-Items werden nach `group` gruppiert mit Trennern zwischen Gruppen.
|
||||
- Button-Labels sind auf Mobile (`hidden sm:inline`) ausgeblendet, Icons bleiben sichtbar.
|
||||
- Toolbar-Höhe: `min-h-[43px]`, Hintergrund `bg-white`, Border-Bottom `border-secondary-200`.
|
||||
|
||||
### Modal-Dialoge
|
||||
|
||||
Für Formulare, Bestätigungen und Dialoge:
|
||||
|
||||
```tsx
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
<Modal open={open} onClose={onClose} title="Kontakt bearbeiten" size="lg">
|
||||
<ContactForm />
|
||||
</Modal>
|
||||
```
|
||||
|
||||
| Size | max-width | Verwendung |
|
||||
|---|---|---|
|
||||
| `sm` | max-w-md | Bestätigungsdialoge |
|
||||
| `md` | max-w-lg | Einfache Formulare |
|
||||
| `lg` | max-w-2xl | Komplexe Formulare, Edit-Dialoge |
|
||||
| `xl` | max-w-4xl | Große Formulare, Multi-Step |
|
||||
|
||||
- `ConfirmDialog` für destruktive Aktionen (Löschen, Entfernen).
|
||||
- Focus-Trap: Fokus wird beim Öffnen auf erstes fokussierbares Element gesetzt, beim Schließen auf ursprüngliches Element zurückgegeben.
|
||||
- Escape-Taste schließt Modal (sofern `closeOnEscape=true`).
|
||||
- Backdrop-Klick schließt Modal (sofern `closeOnBackdrop=true`).
|
||||
|
||||
### Settings-Layout
|
||||
|
||||
Settings-Seiten verwenden einen Tree-Navigator links und das Formular rechts:
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────────────────────┐
|
||||
│ Settings │ Settings-Formular │
|
||||
│ Tree │ (Cards mit Sections) │
|
||||
│ (224px) │ (flex-1, scrollable) │
|
||||
└─────────────┴──────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Komponenten-Referenz
|
||||
|
||||
### Button
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
<Button variant="primary" size="md" onClick={handleSave} isLoading={saving}>
|
||||
Speichern
|
||||
</Button>
|
||||
```
|
||||
|
||||
| Prop | Typ | Default | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'ghost'` | `'primary'` | Visuelle Variante |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Größe |
|
||||
| `isLoading` | `boolean` | `false` | Zeigt Spinner, deaktiviert Button |
|
||||
| `icon` | `ReactNode` | — | Icon links vom Text |
|
||||
| `fullWidth` | `boolean` | `false` | `width: 100%` |
|
||||
|
||||
- Alle Buttons haben `min-h-touch` (44px) für Touch-Accessibility.
|
||||
- `focus-visible:ring-2` für Tastatur-Navigation.
|
||||
- `motion-safe:duration-200` für Übergänge (respektiert `prefers-reduced-motion`).
|
||||
|
||||
### Card
|
||||
|
||||
```tsx
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
<Card title="Kontaktdaten" description="Stammdaten" actions={<Button>Edit</Button>}>
|
||||
<CardContent />
|
||||
</Card>
|
||||
```
|
||||
|
||||
| Prop | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `title` | `string` | Card-Header-Titel |
|
||||
| `description` | `string` | Subtitel im Header |
|
||||
| `actions` | `ReactNode` | Aktionen rechts im Header |
|
||||
| `footer` | `ReactNode` | Footer-Bereich (bg-secondary-50) |
|
||||
|
||||
- Hintergrund: `bg-white`, Border: `border-secondary-200`, Radius: `rounded-lg`, Shadow: `shadow-sm`.
|
||||
- Body-Padding: `px-6 py-4`, Footer-Padding: `px-6 py-3`.
|
||||
|
||||
### Badge
|
||||
|
||||
```tsx
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
<Badge variant="success" dot>Aktiv</Badge>
|
||||
```
|
||||
|
||||
| Variant | Verwendung |
|
||||
|---|---|
|
||||
| `default` | Neutrale Tags |
|
||||
| `primary` | Primäre Zustände |
|
||||
| `success` | Aktiv, bestätigt, online |
|
||||
| `warning` | Ausstehend, Warnung |
|
||||
| `danger` | Fehler, inaktiv, abgelaufen |
|
||||
| `info` | Info, KI-Vorschläge |
|
||||
| `secondary` | Sekundäre Tags |
|
||||
|
||||
- `dot` prop zeigt einen farbigen Punkt links an.
|
||||
- Größe: `text-xs`, `px-2.5 py-0.5`, `rounded-full`.
|
||||
|
||||
### Input / Select
|
||||
|
||||
```tsx
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
<Input label="Name" error={errors.name} helperText="Vollständiger Name" required />
|
||||
```
|
||||
|
||||
- `focus:ring-2 focus:ring-primary-500` bei Fokus.
|
||||
- Error-State: `border-danger-500`, `text-danger-900`.
|
||||
- `aria-invalid`, `aria-describedby` für Accessibility.
|
||||
- `min-h-touch` (44px) für Touch-Targets.
|
||||
- Label: `text-sm font-medium text-secondary-700`.
|
||||
|
||||
### Table / DataGrid
|
||||
|
||||
- Verwendet TanStack Table für Sortierung, Filterung, Pagination.
|
||||
- `aria-label` auf sortierbare Headers.
|
||||
- Zebra-Stiping optional: `even:bg-secondary-50`.
|
||||
|
||||
### Toast
|
||||
|
||||
```tsx
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
const { toast } = useToast();
|
||||
toast({ title: 'Gespeichert', description: 'Kontakt wurde gespeichert', variant: 'success' });
|
||||
```
|
||||
|
||||
- Wird nach jeder CRUD-Aktion verwendet (Erfolg/Fehler).
|
||||
- Auto-Dismiss nach 5 Sekunden.
|
||||
- Position: Top-Right (Desktop), Top (Mobile).
|
||||
|
||||
### EmptyState
|
||||
|
||||
```tsx
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
<EmptyState icon={<Users />} title="Keine Kontakte" description="Erstellen Sie einen neuen Kontakt" action={<Button>Neu</Button>} />
|
||||
```
|
||||
|
||||
- Verwendet wenn Liste leer ist.
|
||||
- Icon groß zentriert, Titel + Beschreibung, optional Aktion.
|
||||
|
||||
### Skeleton
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
<Skeleton className="h-8 w-full" />
|
||||
```
|
||||
|
||||
- Verwendet während Daten laden.
|
||||
- `animate-pulse` Animation.
|
||||
- Respektiert `prefers-reduced-motion`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Spacing & Sizing
|
||||
|
||||
### Padding
|
||||
|
||||
| Element | Padding |
|
||||
|---|---|
|
||||
| Card Body | `px-6 py-4` |
|
||||
| Card Footer | `px-6 py-3` |
|
||||
| Panel | `p-4` |
|
||||
| Modal Body | `p-6` |
|
||||
| Input | `px-3 py-2` |
|
||||
|
||||
### Gap
|
||||
|
||||
| Verwendung | Gap |
|
||||
|---|---|
|
||||
| Button-Gruppen | `gap-2` |
|
||||
| Form-Sections | `gap-4` |
|
||||
| Spalten / Panels | `gap-6` |
|
||||
| Toolbar-Items | `gap-1` |
|
||||
|
||||
### Border-Radius
|
||||
|
||||
| Token | Wert | Verwendung |
|
||||
|---|---|---|
|
||||
| `rounded-sm` | 0.375rem | Badges, kleine Elemente |
|
||||
| `rounded-md` | 0.5rem | Inputs, Buttons, Panels (Standard) |
|
||||
| `rounded-lg` | 0.75rem | Cards, Modals |
|
||||
| `rounded-xl` | 1rem | Große Container |
|
||||
| `rounded-full` | 9999px | Badges, Avatars |
|
||||
|
||||
### Shadow
|
||||
|
||||
| Token | Verwendung |
|
||||
|---|---|
|
||||
| `shadow-sm` | Cards, Panels |
|
||||
| `shadow-md` | Dropdowns, Popovers |
|
||||
| `shadow-lg` | Modals, Dialoge |
|
||||
|
||||
---
|
||||
|
||||
## 6. Accessibility
|
||||
|
||||
### Pflicht-Regeln
|
||||
|
||||
1. **Focus-Ring**: Alle interaktiven Elemente haben `focus-visible:ring-2 focus-visible:ring-primary-500`.
|
||||
2. **Touch-Targets**: Mindestens 44×44px (`min-h-touch min-w-touch`).
|
||||
3. **ARIA-Labels**: Dekorative SVGs erhalten `aria-hidden="true"`. Interaktive Elemente ohne sichtbaren Text erhalten `aria-label`.
|
||||
4. **Screen Reader**: `sr-only` Klasse für Text nur für Screen Reader. `sr-only-focusable` für Skip-Links.
|
||||
5. **Reduced Motion**: `motion-safe:` und `motion-reduce:` Präfixe verwenden. `prefers-reduced-motion` Media Query wird respektiert.
|
||||
6. **Tastatur-Navigation**: Tab-Reihenfolge folgt visueller Reihenfolge. Escape schließt Modals/Dropdowns.
|
||||
7. **Farbkontrast**: Mindestens 4.5:1 für Body-Text, 3:1 für große Texte und UI-Komponenten.
|
||||
|
||||
### Implementierte Patterns
|
||||
|
||||
- `focus-ring` Klasse: `focus-visible:ring-2 focus-visible:ring-primary-500`
|
||||
- `btn-touch` Klasse: `min-h-touch min-w-touch` (44px)
|
||||
- `sr-only` und `sr-only-focusable` Klassen
|
||||
- `prefers-reduced-motion` Media Query
|
||||
- `aria-hidden="true"` auf dekorativen Icons
|
||||
- `aria-label` auf Icon-Only-Buttons
|
||||
- `aria-busy="true"` auf ladenden Buttons
|
||||
- `aria-invalid` und `aria-describedby` auf Inputs mit Fehlern
|
||||
|
||||
---
|
||||
|
||||
## 7. Plugin-UI-Patterns
|
||||
|
||||
### Neue Plugin-Seite — Checkliste
|
||||
|
||||
1. **3-Spalten-Layout** verwenden (wenn anwendbar): Tree | Liste | Detail
|
||||
2. **PluginToolbar** registrieren: Create, Import, Export, Search als Toolbar-Items
|
||||
3. **Plugin-Settings** als eigene Settings-Sub-Seite (Settings-Tree-Navigation)
|
||||
4. **Detail-Tabs** für Entity-Detail (z.B. "Dateien", "Verlauf", "Notizen")
|
||||
5. **EmptyState** wenn keine Daten vorhanden
|
||||
6. **Skeleton/LoadingState** während Daten laden
|
||||
7. **Toast** nach jeder CRUD-Aktion (Erfolg/Fehler)
|
||||
8. **ConfirmDialog** vor destruktiven Aktionen
|
||||
9. **i18n** — alle Texte über `useTranslation()` (DE/EN)
|
||||
10. **Dark Mode** — alle Komponenten müssen in Light und Dark funktionieren
|
||||
|
||||
### Plugin-Toolbar Registrierung
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
setActivePlugin('myplugin');
|
||||
setItems([
|
||||
{ id: 'create', plugin: 'myplugin', type: 'button', label: t('actions.create'), icon: <Plus size={16} />, onClick: handleCreate, group: 'actions' },
|
||||
{ id: 'import', plugin: 'myplugin', type: 'button', label: t('actions.import'), icon: <Upload size={16} />, onClick: handleImport, group: 'actions' },
|
||||
{ id: 'search', plugin: 'myplugin', type: 'search', searchPlaceholder: t('search'), onSearch: handleSearch, group: 'search' },
|
||||
]);
|
||||
return () => setItems([]);
|
||||
}, []);
|
||||
```
|
||||
|
||||
### i18n
|
||||
|
||||
- Alle Texte über `useTranslation()` Hook.
|
||||
- Übersetzungen in `src/i18n/locales/de.json` und `src/i18n/locales/en.json`.
|
||||
- Keys nach Plugin-Präfix: `myplugin.actions.create`, `myplugin.search`, etc.
|
||||
- Ca. 750 Keys pro Sprache aktuell.
|
||||
|
||||
---
|
||||
|
||||
## 8. Do's & Don'ts
|
||||
|
||||
### Do's
|
||||
|
||||
- ✅ Bestehende UI-Komponenten aus `components/ui/` verwenden
|
||||
- ✅ `clsx` für bedingte Klassen verwenden
|
||||
- ✅ `lucide-react` Icons verwenden (keine inline SVGs)
|
||||
- ✅ `date-fns` für Datumsformatierung verwenden
|
||||
- ✅ Zustand-Stores für State Management verwenden
|
||||
- ✅ TanStack Query für API-Calls verwenden
|
||||
- ✅ `min-h-touch` (44px) für alle interaktiven Elemente
|
||||
- ✅ `focus-visible:ring-2` für Tastatur-Accessibility
|
||||
- ✅ `motion-safe:` / `motion-reduce:` für Animationen
|
||||
- ✅ Toast nach jeder CRUD-Aktion anzeigen
|
||||
- ✅ ConfirmDialog vor jeder destruktiven Aktion
|
||||
- ✅ EmptyState für leere Listen
|
||||
- ✅ Skeleton für Lade-Zustände
|
||||
|
||||
### Don'ts
|
||||
|
||||
- ❌ Keine inline SVGs — immer `lucide-react` verwenden
|
||||
- ❌ Keine `Date.parse()` oder `new Date()` Formatierung — `date-fns` verwenden
|
||||
- ❌ Keine hardcoded Farben — Tailwind Design Tokens verwenden
|
||||
- ❌ Keine `alert()` oder `confirm()` — Toast und ConfirmDialog verwenden
|
||||
- ❌ Keine CSS-Module oder styled-components — Tailwind-Klassen verwenden
|
||||
- ❌ Keine `useEffect` für State-Management — Zustand-Stores verwenden
|
||||
- ❌ Keine direkten `fetch()` Calls — TanStack Query Hooks verwenden
|
||||
- ❌ Keine `any` Types — TypeScript-Interfaces definieren
|
||||
- ❌ Keine deutschen Strings im Code — i18n-Keys verwenden
|
||||
- ❌ Keine `px-` Werte für Touch-Targets unter 44px
|
||||
- ❌ Keine `display: none` für Accessibility-relevante Elemente — `sr-only` verwenden
|
||||
|
||||
---
|
||||
|
||||
## 9. Code-Beispiele
|
||||
|
||||
### Beispiel: Plugin-Seite mit 3-Spalten-Layout
|
||||
|
||||
```tsx
|
||||
import { useEffect } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||
|
||||
export function MyPluginPage() {
|
||||
const { t } = useTranslation();
|
||||
const { setItems, setActivePlugin } = usePluginToolbarStore();
|
||||
|
||||
useEffect(() => {
|
||||
setActivePlugin('myplugin');
|
||||
setItems([
|
||||
{ id: 'create', plugin: 'myplugin', type: 'button', label: t('actions.create'), icon: <Plus size={16} />, onClick: handleCreate, group: 'actions' },
|
||||
{ id: 'search', plugin: 'myplugin', type: 'search', searchPlaceholder: t('search'), onSearch: handleSearch, group: 'search' },
|
||||
]);
|
||||
return () => setItems([]);
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => { /* ... */ };
|
||||
const handleSearch = (q: string) => { /* ... */ };
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<ResizablePanel initialWidth={224} minWidth={150} maxWidth={600}>
|
||||
<TreeView />
|
||||
</ResizablePanel>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState icon={<FileIcon />} title={t('empty.title')} description={t('empty.description')} action={<Button onClick={handleCreate}>{t('actions.create')}</Button>} />
|
||||
) : (
|
||||
<ListView items={items} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DetailView />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Beispiel: Formular mit Validation
|
||||
|
||||
```tsx
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
function ContactForm({ open, onClose }) {
|
||||
const { toast } = useToast();
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await saveContact(formData);
|
||||
toast({ title: 'Gespeichert', variant: 'success' });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({ title: 'Fehler', description: err.message, variant: 'danger' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Kontakt bearbeiten" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input label="Vorname" error={errors.firstname} required />
|
||||
<Input label="Nachname" error={errors.surname} required />
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button type="submit" variant="primary">Speichern</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Datei-Struktur
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ ├── ui/ # Basis-Komponenten (Button, Card, Modal, etc.)
|
||||
│ ├── layout/ # Layout-Komponenten (PluginToolbar, etc.)
|
||||
│ └── [plugin]/ # Plugin-spezifische Komponenten
|
||||
├── pages/ # Seiten-Komponenten (Routes)
|
||||
├── store/ # Zustand-Stores
|
||||
├── hooks/ # Custom Hooks (aufgeteilt nach Domain)
|
||||
├── utils/ # Utilities (date.ts, api.ts, etc.)
|
||||
├── i18n/ # Übersetzungen
|
||||
│ └── locales/
|
||||
│ ├── de.json
|
||||
│ └── en.json
|
||||
└── routes/ # React Router Konfiguration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Diese Richtlinien sind verbindlich für alle Frontend-Entwicklung an LeoCRM.*
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AppRouter } from '@/routes';
|
||||
import { setUnauthorizedHandler } from '@/api/client';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -16,6 +17,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
export default function App() {
|
||||
const { logout } = useAuthStore();
|
||||
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
@@ -24,6 +26,11 @@ export default function App() {
|
||||
});
|
||||
}, [logout]);
|
||||
|
||||
// Load theme from localStorage on app start
|
||||
React.useEffect(() => {
|
||||
loadThemeFromStorage();
|
||||
}, [loadThemeFromStorage]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppRouter />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Entity History hooks — undo/restore functionality.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiClient } from './client';
|
||||
|
||||
export interface EntityHistoryEntry {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
action: 'create' | 'update' | 'delete';
|
||||
snapshot_before: Record<string, any> | null;
|
||||
snapshot_after: Record<string, any> | null;
|
||||
changes: Record<string, { old: any; new: any }> | null;
|
||||
user_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EntityHistoryList {
|
||||
items: EntityHistoryEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function useEntityHistory(entityType?: string, entityId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['entityHistory', entityType, entityId],
|
||||
queryFn: () =>
|
||||
apiGet<EntityHistoryList>(`/entity-history/${entityType}/${entityId}`),
|
||||
enabled: !!entityType && !!entityId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreFromHistory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (historyId: string) =>
|
||||
apiClient.post('/entity-history/restore', { history_id: historyId }).then(r => r.data),
|
||||
onSuccess: (_data, _variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contact'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUndoLastAction() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ entityType, entityId }: { entityType: string; entityId: string }) =>
|
||||
apiPost(`/entity-history/undo/${entityType}/${entityId}`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contact'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,11 @@ export interface SystemSettings {
|
||||
invoice_prefix: string;
|
||||
quote_prefix: string;
|
||||
payment_terms_days: number;
|
||||
// Theme customization
|
||||
theme_primary_color?: string;
|
||||
theme_accent_color?: string;
|
||||
theme_font_family?: string;
|
||||
theme_border_radius?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { History, RotateCcw, Undo, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useEntityHistory, useRestoreFromHistory, useUndoLastAction } from '@/api/entityHistory';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
|
||||
interface HistoryViewerProps {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
const actionConfig = {
|
||||
create: { variant: 'success' as const, label: 'Erstellt' },
|
||||
update: { variant: 'primary' as const, label: 'Aktualisiert' },
|
||||
delete: { variant: 'danger' as const, label: 'Gelöscht' },
|
||||
};
|
||||
|
||||
export function HistoryViewer({ entityType, entityId }: HistoryViewerProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: history, isLoading } = useEntityHistory(entityType, entityId);
|
||||
const restoreMutation = useRestoreFromHistory();
|
||||
const undoMutation = useUndoLastAction();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const dateLocale = i18n.language === 'de' ? de : enUS;
|
||||
|
||||
const handleUndo = async () => {
|
||||
try {
|
||||
await undoMutation.mutateAsync({ entityType, entityId });
|
||||
toast.success(t('history.undone', 'Aktion rückgängig gemacht'));
|
||||
} catch {
|
||||
toast.error(t('history.undoError', 'Rückgängig machen fehlgeschlagen'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (historyId: string) => {
|
||||
try {
|
||||
await restoreMutation.mutateAsync(historyId);
|
||||
toast.success(t('history.restored', 'Version wiederhergestellt'));
|
||||
} catch {
|
||||
toast.error(t('history.restoreError', 'Wiederherstellung fehlgeschlagen'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = history?.items ?? [];
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-secondary-400">
|
||||
<History className="w-8 h-8 mx-auto mb-2 opacity-50" aria-hidden="true" />
|
||||
<p className="text-sm">{t('history.empty', 'Keine Änderungshistorie vorhanden')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="history-viewer">
|
||||
{/* Undo button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 flex items-center gap-2">
|
||||
<History className="w-5 h-5" aria-hidden="true" />
|
||||
{t('history.title', 'Änderungshistorie')}
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleUndo}
|
||||
isLoading={undoMutation.isPending}
|
||||
icon={<Undo className="w-4 h-4" />}
|
||||
>
|
||||
{t('history.undo', 'Rückgängig')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* History entries */}
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => {
|
||||
const config = actionConfig[entry.action] || actionConfig.update;
|
||||
const isExpanded = expandedId === entry.id;
|
||||
const changes = entry.changes || {};
|
||||
const changeKeys = Object.keys(changes);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="border border-secondary-200 rounded-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-secondary-50 transition-colors text-left"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-secondary-400" aria-hidden="true" />
|
||||
)}
|
||||
<Badge variant={config.variant}>{config.label}</Badge>
|
||||
<span className="text-sm text-secondary-500">
|
||||
{formatDistanceToNow(new Date(entry.created_at), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
{changeKeys.length > 0 && (
|
||||
<span className="text-xs text-secondary-400">
|
||||
{changeKeys.length} {t('history.fieldsChanged', 'Felder geändert')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3 border-t border-secondary-100">
|
||||
{/* Changes diff */}
|
||||
{changeKeys.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs font-medium text-secondary-500 mb-2">{t('history.changes', 'Änderungen')}:</p>
|
||||
{changeKeys.map((key) => (
|
||||
<div key={key} className="flex items-start gap-2 text-sm">
|
||||
<span className="font-mono text-secondary-600 min-w-[120px]">{key}:</span>
|
||||
<span className="text-danger-600 line-through">
|
||||
{String(changes[key].old ?? '—')}
|
||||
</span>
|
||||
<span className="text-secondary-400">→</span>
|
||||
<span className="text-success-600">
|
||||
{String(changes[key].new ?? '—')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore button */}
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRestore(entry.id)}
|
||||
isLoading={restoreMutation.isPending}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t('history.restore', 'Diese Version wiederherstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { HistoryViewer } from '@/components/HistoryViewer';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
type ContactPerson,
|
||||
@@ -358,6 +359,13 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{contact.id && (
|
||||
<Section title={t('history.title', 'Änderungshistorie')}>
|
||||
<HistoryViewer entityType="contact" entityId={contact.id} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContactPersonModal
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"theme": "Design",
|
||||
"theme": "Theme",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System",
|
||||
@@ -261,7 +261,29 @@
|
||||
"pluginUninstalledSuccess": "Plugin erfolgreich deinstalliert.",
|
||||
"pluginUninstallConfirm": "Plugin wirklich deinstallieren",
|
||||
"pluginRemoveData": "Plugindaten (Tabellen) ebenfalls entfernen",
|
||||
"roleDeleted": "Rolle erfolgreich gelöscht."
|
||||
"roleDeleted": "Rolle erfolgreich gelöscht.",
|
||||
"themeTitle": "Theme-Anpassung",
|
||||
"themeDescription": "Passen Sie Farben, Schriftart und Erscheinungsbild an.",
|
||||
"themeSaved": "Theme gespeichert",
|
||||
"themeSaveError": "Theme konnte nicht gespeichert werden",
|
||||
"unsavedChanges": "Ungespeicherte Änderungen",
|
||||
"presets": "Vorlagen",
|
||||
"presetsDescription": "Schnell eine Farbpalette wählen",
|
||||
"colors": "Farben",
|
||||
"colorsDescription": "Hauptfarbe und Akzentfarbe anpassen",
|
||||
"primaryColor": "Hauptfarbe",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"typography": "Typografie & Layout",
|
||||
"fontFamily": "Schriftart",
|
||||
"borderRadius": "Border-Radius",
|
||||
"darkMode": "Dark Mode",
|
||||
"darkModeDescription": "Zwischen hellem und dunklem Theme wechseln",
|
||||
"darkModeOn": "Dunkles Theme aktiv",
|
||||
"darkModeOff": "Helles Theme aktiv",
|
||||
"livePreview": "Live-Vorschau",
|
||||
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
|
||||
"resetTheme": "Zurücksetzen",
|
||||
"saveTheme": "Theme speichern"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Audit-Log",
|
||||
@@ -857,5 +879,17 @@
|
||||
"addTag": "Tag hinzufuegen",
|
||||
"create": "Neues Tag",
|
||||
"selectTags": "{{count}} Tags auswaehlen"
|
||||
},
|
||||
"history": {
|
||||
"title": "Änderungshistorie",
|
||||
"undo": "Rückgängig",
|
||||
"undone": "Aktion rückgängig gemacht",
|
||||
"undoError": "Rückgängig machen fehlgeschlagen",
|
||||
"restore": "Diese Version wiederherstellen",
|
||||
"restored": "Version wiederhergestellt",
|
||||
"restoreError": "Wiederherstellung fehlgeschlagen",
|
||||
"empty": "Keine Änderungshistorie vorhanden",
|
||||
"changes": "Änderungen",
|
||||
"fieldsChanged": "Felder geändert"
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,29 @@
|
||||
"pluginUninstalledSuccess": "Plugin uninstalled successfully.",
|
||||
"pluginUninstallConfirm": "Really uninstall plugin",
|
||||
"pluginRemoveData": "Also remove plugin data (tables)",
|
||||
"roleDeleted": "Role deleted successfully."
|
||||
"roleDeleted": "Role deleted successfully.",
|
||||
"themeTitle": "Theme Customization",
|
||||
"themeDescription": "Customize colors, font, and appearance.",
|
||||
"themeSaved": "Theme saved",
|
||||
"themeSaveError": "Failed to save theme",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"presets": "Presets",
|
||||
"presetsDescription": "Quickly choose a color palette",
|
||||
"colors": "Colors",
|
||||
"colorsDescription": "Customize primary and accent colors",
|
||||
"primaryColor": "Primary color",
|
||||
"accentColor": "Accent color",
|
||||
"typography": "Typography & Layout",
|
||||
"fontFamily": "Font family",
|
||||
"borderRadius": "Border radius",
|
||||
"darkMode": "Dark Mode",
|
||||
"darkModeDescription": "Switch between light and dark theme",
|
||||
"darkModeOn": "Dark theme active",
|
||||
"darkModeOff": "Light theme active",
|
||||
"livePreview": "Live Preview",
|
||||
"livePreviewDescription": "This is how the app looks with the current theme",
|
||||
"resetTheme": "Reset",
|
||||
"saveTheme": "Save theme"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Audit Log",
|
||||
@@ -857,5 +879,17 @@
|
||||
"addTag": "Add tag",
|
||||
"create": "New tag",
|
||||
"selectTags": "Select {{count}} tags"
|
||||
},
|
||||
"history": {
|
||||
"title": "Change History",
|
||||
"undo": "Undo",
|
||||
"undone": "Action undone",
|
||||
"undoError": "Undo failed",
|
||||
"restore": "Restore this version",
|
||||
"restored": "Version restored",
|
||||
"restoreError": "Restore failed",
|
||||
"empty": "No change history available",
|
||||
"changes": "Changes",
|
||||
"fieldsChanged": "fields changed"
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,67 @@ export function ProactiveAISettings() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Configuration */}
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800">Heartbeat</h3>
|
||||
<p className="text-sm text-gray-500">Regelmäßige Status-Meldung an KI-Raum</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update({ heartbeat_enabled: !settings.heartbeat_enabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.heartbeat_enabled ? 'bg-blue-500' : 'bg-gray-300'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={settings.heartbeat_enabled}
|
||||
aria-label="Heartbeat aktivieren"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.heartbeat_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{settings.heartbeat_enabled && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Intervall</span>
|
||||
<span className="text-sm font-mono text-blue-600">
|
||||
{settings.heartbeat_interval_seconds || 300}s
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="60"
|
||||
max="1800"
|
||||
step="60"
|
||||
value={settings.heartbeat_interval_seconds || 300}
|
||||
onChange={(e) => update({ heartbeat_interval_seconds: parseInt(e.target.value) })}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>1min</span>
|
||||
<span>30min</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ziel-Raum</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.heartbeat_target_room || 'Live KI'}
|
||||
onChange={(e) => update({ heartbeat_target_room: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 outline-none"
|
||||
placeholder="Live KI"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function SettingsPage() {
|
||||
{ to: '/settings/ai', label: t('nav.aiAssistant'), icon: '\ud83e\udde0' },
|
||||
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
|
||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Palette, Type, Moon, Sun, RotateCcw, Check } from 'lucide-react';
|
||||
import { useSystemSettings, useUpdateSystemSettings } from '@/api/settings';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ value: 'Inter', label: 'Inter (Standard)' },
|
||||
{ value: 'Roboto', label: 'Roboto' },
|
||||
{ value: 'Open Sans', label: 'Open Sans' },
|
||||
{ value: 'Lato', label: 'Lato' },
|
||||
{ value: 'Montserrat', label: 'Montserrat' },
|
||||
{ value: 'Source Sans Pro', label: 'Source Sans Pro' },
|
||||
{ value: 'Nunito', label: 'Nunito' },
|
||||
{ value: 'system-ui', label: 'System UI' },
|
||||
];
|
||||
|
||||
const RADIUS_OPTIONS = [
|
||||
{ value: '0.25rem', label: 'Sharp (0.25rem)' },
|
||||
{ value: '0.375rem', label: 'Small (0.375rem)' },
|
||||
{ value: '0.5rem', label: 'Medium (0.5rem) — Standard' },
|
||||
{ value: '0.75rem', label: 'Large (0.75rem)' },
|
||||
{ value: '1rem', label: 'Extra Large (1rem)' },
|
||||
{ value: '1.5rem', label: 'Round (1.5rem)' },
|
||||
];
|
||||
|
||||
const PRESET_THEMES = [
|
||||
{ name: 'Classic Blue', primary: '#2563eb', accent: '#d946ef' },
|
||||
{ name: 'Ocean', primary: '#0ea5e9', accent: '#6366f1' },
|
||||
{ name: 'Forest', primary: '#16a34a', accent: '#84cc16' },
|
||||
{ name: 'Sunset', primary: '#ea580c', accent: '#f59e0b' },
|
||||
{ name: 'Purple', primary: '#7c3aed', accent: '#ec4899' },
|
||||
{ name: 'Slate', primary: '#475569', accent: '#64748b' },
|
||||
];
|
||||
|
||||
export function SettingsThemePage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: settings, isLoading } = useSystemSettings();
|
||||
const updateMutation = useUpdateSystemSettings();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const [primaryColor, setPrimaryColor] = useState('#2563eb');
|
||||
const [accentColor, setAccentColor] = useState('#d946ef');
|
||||
const [fontFamily, setFontFamily] = useState('Inter');
|
||||
const [borderRadius, setBorderRadius] = useState('0.5rem');
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load theme from system settings on mount
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const pc = settings.theme_primary_color || '#2563eb';
|
||||
const ac = settings.theme_accent_color || '#d946ef';
|
||||
const ff = settings.theme_font_family || 'Inter';
|
||||
const br = settings.theme_border_radius || '0.5rem';
|
||||
setPrimaryColor(pc);
|
||||
setAccentColor(ac);
|
||||
setFontFamily(ff);
|
||||
setBorderRadius(br);
|
||||
// Apply to store for live preview
|
||||
themeStore.setTheme({
|
||||
primaryColor: pc,
|
||||
accentColor: ac,
|
||||
fontFamily: ff,
|
||||
borderRadius: br,
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Load dark mode from localStorage on mount
|
||||
useEffect(() => {
|
||||
themeStore.loadFromStorage();
|
||||
setDarkMode(themeStore.darkMode);
|
||||
}, []);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
const changed =
|
||||
primaryColor !== (settings.theme_primary_color || '#2563eb') ||
|
||||
accentColor !== (settings.theme_accent_color || '#d946ef') ||
|
||||
fontFamily !== (settings.theme_font_family || 'Inter') ||
|
||||
borderRadius !== (settings.theme_border_radius || '0.5rem');
|
||||
setHasChanges(changed);
|
||||
}, [primaryColor, accentColor, fontFamily, borderRadius, settings]);
|
||||
|
||||
// Live preview: apply to theme store immediately
|
||||
const handlePrimaryChange = (val: string) => {
|
||||
setPrimaryColor(val);
|
||||
themeStore.setTheme({ primaryColor: val });
|
||||
};
|
||||
|
||||
const handleAccentChange = (val: string) => {
|
||||
setAccentColor(val);
|
||||
themeStore.setTheme({ accentColor: val });
|
||||
};
|
||||
|
||||
const handleFontChange = (val: string) => {
|
||||
setFontFamily(val);
|
||||
themeStore.setTheme({ fontFamily: val });
|
||||
};
|
||||
|
||||
const handleRadiusChange = (val: string) => {
|
||||
setBorderRadius(val);
|
||||
themeStore.setTheme({ borderRadius: val });
|
||||
};
|
||||
|
||||
const handleDarkModeToggle = () => {
|
||||
const newMode = !darkMode;
|
||||
setDarkMode(newMode);
|
||||
themeStore.toggleDarkMode();
|
||||
};
|
||||
|
||||
const handlePreset = (preset: typeof PRESET_THEMES[0]) => {
|
||||
setPrimaryColor(preset.primary);
|
||||
setAccentColor(preset.accent);
|
||||
themeStore.setTheme({
|
||||
primaryColor: preset.primary,
|
||||
accentColor: preset.accent,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
...settings,
|
||||
theme_primary_color: primaryColor,
|
||||
theme_accent_color: accentColor,
|
||||
theme_font_family: fontFamily,
|
||||
theme_border_radius: borderRadius,
|
||||
});
|
||||
toast.success(t('settings.themeSaved', 'Theme gespeichert'));
|
||||
setHasChanges(false);
|
||||
} catch {
|
||||
toast.error(t('settings.themeSaveError', 'Theme konnte nicht gespeichert werden'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
handlePreset(PRESET_THEMES[0]);
|
||||
setFontFamily('Inter');
|
||||
setBorderRadius('0.5rem');
|
||||
themeStore.setTheme({
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#d946ef',
|
||||
fontFamily: 'Inter',
|
||||
borderRadius: '0.5rem',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl" data-testid="settings-theme-page">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-secondary-900">{t('settings.themeTitle', 'Theme-Anpassung')}</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('settings.themeDescription', 'Passen Sie Farben, Schriftart und Erscheinungsbild an.')}</p>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Badge variant="warning" dot>{t('settings.unsavedChanges', 'Ungespeicherte Änderungen')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preset Themes */}
|
||||
<Card title={t('settings.presets', 'Vorlagen')} description={t('settings.presetsDescription', 'Schnell eine Farbpalette wählen')}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{PRESET_THEMES.map((preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
onClick={() => handlePreset(preset)}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-secondary-200 hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||
aria-label={preset.name}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-8 h-8 rounded-full" style={{ backgroundColor: preset.primary }} aria-hidden="true" />
|
||||
<div className="w-8 h-8 rounded-full" style={{ backgroundColor: preset.accent }} aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-secondary-700">{preset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Color Customization */}
|
||||
<Card title={t('settings.colors', 'Farben')} description={t('settings.colorsDescription', 'Hauptfarbe und Akzentfarbe anpassen')}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
<Palette className="inline w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{t('settings.primaryColor', 'Hauptfarbe')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor}
|
||||
onChange={(e) => handlePrimaryChange(e.target.value)}
|
||||
className="w-12 h-12 rounded-md border border-secondary-200 cursor-pointer"
|
||||
aria-label={t('settings.primaryColor', 'Hauptfarbe')}
|
||||
/>
|
||||
<Input
|
||||
value={primaryColor}
|
||||
onChange={(e) => handlePrimaryChange(e.target.value)}
|
||||
className="flex-1"
|
||||
placeholder="#2563eb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
<Palette className="inline w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{t('settings.accentColor', 'Akzentfarbe')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => handleAccentChange(e.target.value)}
|
||||
className="w-12 h-12 rounded-md border border-secondary-200 cursor-pointer"
|
||||
aria-label={t('settings.accentColor', 'Akzentfarbe')}
|
||||
/>
|
||||
<Input
|
||||
value={accentColor}
|
||||
onChange={(e) => handleAccentChange(e.target.value)}
|
||||
className="flex-1"
|
||||
placeholder="#d946ef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Typography & Layout */}
|
||||
<Card title={t('settings.typography', 'Typografie & Layout')}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
<Type className="inline w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{t('settings.fontFamily', 'Schriftart')}
|
||||
</label>
|
||||
<Select value={fontFamily} onChange={(e) => handleFontChange(e.target.value)} options={FONT_OPTIONS} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
{t('settings.borderRadius', 'Border-Radius')}
|
||||
</label>
|
||||
<Select value={borderRadius} onChange={(e) => handleRadiusChange(e.target.value)} options={RADIUS_OPTIONS} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Dark Mode */}
|
||||
<Card title={t('settings.darkMode', 'Dark Mode')} description={t('settings.darkModeDescription', 'Zwischen hellem und dunklem Theme wechseln')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{darkMode ? (
|
||||
<Moon className="w-5 h-5 text-secondary-600" aria-hidden="true" />
|
||||
) : (
|
||||
<Sun className="w-5 h-5 text-secondary-600" aria-hidden="true" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-secondary-700">
|
||||
{darkMode ? t('settings.darkModeOn', 'Dunkles Theme aktiv') : t('settings.darkModeOff', 'Helles Theme aktiv')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDarkModeToggle}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
darkMode ? 'bg-primary-600' : 'bg-secondary-300'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={darkMode}
|
||||
aria-label={t('settings.darkMode', 'Dark Mode')}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
darkMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Live Preview */}
|
||||
<Card title={t('settings.livePreview', 'Live-Vorschau')} description={t('settings.livePreviewDescription', 'So sieht die Anwendung mit dem aktuellen Theme aus')}>
|
||||
<div className="space-y-4">
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" size="sm">Primary Button</Button>
|
||||
<Button variant="secondary" size="sm">Secondary Button</Button>
|
||||
<Button variant="danger" size="sm">Danger Button</Button>
|
||||
<Button variant="ghost" size="sm">Ghost Button</Button>
|
||||
</div>
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="primary">Primary</Badge>
|
||||
<Badge variant="success" dot>Success</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="danger">Danger</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
</div>
|
||||
{/* Input preview */}
|
||||
<div className="max-w-xs">
|
||||
<Input label="Beispiel-Input" placeholder="Text eingeben..." />
|
||||
</div>
|
||||
{/* Card preview */}
|
||||
<div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm">
|
||||
<p className="text-sm text-secondary-700">Dies ist eine Beispiel-Karte mit dem aktuellen Theme.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button variant="secondary" onClick={handleReset} icon={<RotateCcw className="w-4 h-4" />}>
|
||||
{t('settings.resetTheme', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
isLoading={updateMutation.isPending}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings.saveTheme', 'Theme speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { SettingsNotificationsPage } from '@/pages/SettingsNotifications';
|
||||
import { AIAssistantPage } from '@/pages/AIAssistant';
|
||||
import { AISettingsPage } from '@/pages/AISettings';
|
||||
import { ProactiveAISettings } from '@/pages/ProactiveAISettings';
|
||||
import { SettingsThemePage } from '@/pages/SettingsTheme';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -79,6 +80,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'notifications', element: <SettingsNotificationsPage /> },
|
||||
{ path: 'ai', element: <AISettingsPage /> },
|
||||
{ path: 'ai-proactive', element: <ProactiveAISettings /> },
|
||||
{ path: 'theme', element: <SettingsThemePage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Theme store — manages dynamic CSS variables for theme customization.
|
||||
* Applies primary_color, accent_color, font_family, border_radius
|
||||
* from system settings to CSS custom properties on :root.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface ThemeConfig {
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
fontFamily: string;
|
||||
borderRadius: string;
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
interface ThemeState extends ThemeConfig {
|
||||
setTheme: (config: Partial<ThemeConfig>) => void;
|
||||
toggleDarkMode: () => void;
|
||||
applyTheme: () => void;
|
||||
loadFromStorage: () => void;
|
||||
saveToStorage: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: ThemeConfig = {
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#d946ef',
|
||||
fontFamily: 'Inter',
|
||||
borderRadius: '0.5rem',
|
||||
darkMode: false,
|
||||
};
|
||||
|
||||
/** Convert hex color to RGB values for Tailwind CSS variable injection */
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Generate a Tailwind-style color scale (50-900) from a base hex color */
|
||||
function generateColorScale(hex: string): Record<string, string> {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return {};
|
||||
|
||||
// Mix with white for lighter shades, with black for darker
|
||||
const mix = (base: number, amount: number, target: number) =>
|
||||
Math.round(base + (target - base) * amount);
|
||||
|
||||
const scales: Record<number, [number, number]> = {
|
||||
50: [0.95, 0], // 95% white
|
||||
100: [0.90, 0],
|
||||
200: [0.80, 0],
|
||||
300: [0.60, 0],
|
||||
400: [0.30, 0],
|
||||
500: [0, 0], // base color
|
||||
600: [0, 0.10], // 10% black
|
||||
700: [0, 0.20],
|
||||
800: [0, 0.30],
|
||||
900: [0, 0.40],
|
||||
};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [scale, [whiteAmt, blackAmt]] of Object.entries(scales)) {
|
||||
const r = whiteAmt > 0 ? mix(rgb.r, whiteAmt, 255) : mix(rgb.r, blackAmt, 0);
|
||||
const g = whiteAmt > 0 ? mix(rgb.g, whiteAmt, 255) : mix(rgb.g, blackAmt, 0);
|
||||
const b = whiteAmt > 0 ? mix(rgb.b, whiteAmt, 255) : mix(rgb.b, blackAmt, 0);
|
||||
result[scale] = `rgb(${r} ${g} ${b})`;
|
||||
}
|
||||
result.DEFAULT = hex;
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyCSSVariables(config: ThemeConfig) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply primary color scale
|
||||
const primaryScale = generateColorScale(config.primaryColor);
|
||||
for (const [key, val] of Object.entries(primaryScale)) {
|
||||
root.style.setProperty(`--color-primary-${key.toLowerCase()}`, val);
|
||||
}
|
||||
|
||||
// Apply accent color scale
|
||||
const accentScale = generateColorScale(config.accentColor);
|
||||
for (const [key, val] of Object.entries(accentScale)) {
|
||||
root.style.setProperty(`--color-accent-${key.toLowerCase()}`, val);
|
||||
}
|
||||
|
||||
// Apply font family
|
||||
root.style.setProperty('--font-sans', `'${config.fontFamily}', system-ui, sans-serif`);
|
||||
|
||||
// Apply border radius
|
||||
root.style.setProperty('--radius-md', config.borderRadius);
|
||||
|
||||
// Apply dark mode
|
||||
if (config.darkMode) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'leocrm-theme';
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||
...DEFAULT_THEME,
|
||||
|
||||
setTheme: (config) => {
|
||||
set((state) => ({ ...state, ...config }));
|
||||
get().applyTheme();
|
||||
get().saveToStorage();
|
||||
},
|
||||
|
||||
toggleDarkMode: () => {
|
||||
set((state) => ({ ...state, darkMode: !state.darkMode }));
|
||||
get().applyTheme();
|
||||
get().saveToStorage();
|
||||
},
|
||||
|
||||
applyTheme: () => {
|
||||
const state = get();
|
||||
applyCSSVariables({
|
||||
primaryColor: state.primaryColor,
|
||||
accentColor: state.accentColor,
|
||||
fontFamily: state.fontFamily,
|
||||
borderRadius: state.borderRadius,
|
||||
darkMode: state.darkMode,
|
||||
});
|
||||
},
|
||||
|
||||
loadFromStorage: () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const config = JSON.parse(stored) as Partial<ThemeConfig>;
|
||||
set((state) => ({ ...state, ...config }));
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
get().applyTheme();
|
||||
},
|
||||
|
||||
saveToStorage: () => {
|
||||
const state = get();
|
||||
const { setTheme, toggleDarkMode, applyTheme, loadFromStorage, saveToStorage, ...config } = state;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
},
|
||||
}));
|
||||
+2
-2
@@ -19,7 +19,6 @@ email-validator>=2.0
|
||||
# Auth
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-jose[cryptography]>=3.3
|
||||
cryptography>=42.0
|
||||
|
||||
# Redis / Job Queue
|
||||
@@ -49,6 +48,7 @@ litellm
|
||||
|
||||
# AI / Search
|
||||
pgvector>=0.3.0
|
||||
PyMuPDF>=1.24.0
|
||||
pypdf>=1.24.0
|
||||
python-docx>=1.1.0
|
||||
python-pptx>=0.6.23
|
||||
minio
|
||||
|
||||
Reference in New Issue
Block a user