5 Commits

21 changed files with 2727 additions and 28 deletions
+46
View File
@@ -249,3 +249,49 @@ Siehe `MASTER-PLAN.md` für alle Tasks.
### Modifizierte Dateien Phase 5 Batch 2
- `frontend/package.json` — @playwright/test devDependency + e2e scripts
---
## Phase 5 Batch 3 (Tasks 5.12-5.15) — ✅ Complete
| Task | Status | Datum | Beschreibung |
|------|--------|-------|--------------|
| 5.12 | ✅ done | 2026-07-23 | API-Health-Check-Script (scripts/ai_health_check.py) — enumeriert 295 API-Routen, probt GET-Endpoints mit Auth-Token, JSON/CSV-Report, Exit-Codes |
| 5.13 | ✅ done | 2026-07-23 | CI/CD-Pipeline-Script (scripts/ai_deploy.py) — Build (docker/npm), Tests (pytest+vitest), Deploy (Coolify API), Rollback bei Fehler, --dry-run/--skip-tests/--skip-build |
| 5.14 | ✅ done | 2026-07-23 | API-Dokumentation vervollständigt — OpenAPI tags mit Beschreibungen (36 Tags), response_model für auth/health/users/notifications/system-settings, Pydantic examples, docs/api-documentation.md (500 Zeilen, 295 Endpoints) |
| 5.15 | ✅ done | 2026-07-23 | Automatisiertes Backup-System — scripts/backup.py (pg_dump+file backup, retention policy, notification), scripts/restore.py, system_notif plugin erweitert (backup.completed/failed events), backup config in system_settings |
### Verifikation Phase 5 Batch 3
- TSC: 0 neue errors (nur 2 pre-existing Dms.tsx onRangeSelect errors) ✅
- 42 neue Tests alle passing ✅
- Alle Scripts ausführbar (chmod +x) ✅
- argparse für CLI-Argumente ✅
- httpx für HTTP-Calls ✅
- Keine .env committet ✅
- Bestehende Patterns verwendet (APIRouter, Pydantic, sys.path.insert) ✅
### Neue Dateien Phase 5 Batch 3
- `scripts/ai_health_check.py` — API Health Check Script (ausführbar)
- `scripts/ai_deploy.py` — CI/CD Deploy Script (ausführbar)
- `scripts/backup.py` — Automated Backup Script (ausführbar)
- `scripts/restore.py` — Restore Script (ausführbar)
- `docs/api-documentation.md` — Vollständige API-Dokumentation (295 Endpoints, 30 Tag-Gruppen)
- `tests/test_ai_health_check.py` — 7 Tests für Health Check
- `tests/test_ai_deploy.py` — 11 Tests für Deploy Script
- `tests/test_api_documentation.py` — 8 Tests für API-Dokumentation
- `tests/test_backup_restore.py` — 16 Tests für Backup/Restore
### Modifizierte Dateien Phase 5 Batch 3
- `app/main.py` — OpenAPI tags (36 Tags mit Beschreibungen), app description
- `app/routes/auth.py` — response_model für login/logout/me (AuthResponse, MessageResponse)
- `app/routes/health.py` — response_model HealthResponse
- `app/routes/notifications.py` — response_model UnreadCountResponse für unread-count
- `app/routes/users.py` — response_model für list/create/get (PaginatedUsers, UserResponse)
- `app/routes/system_settings.py` — response_model SystemSettingsResponse für GET/PUT
- `app/schemas/auth.py` — Field examples für LoginRequest, AuthResponse
- `app/schemas/user.py` — Field examples für UserCreate
- `app/schemas/system_settings.py` — Backup config fields (backup_interval, backup_retention_days, backup_destination)
- `app/plugins/builtins/ai_ui_control/routes.py` — tags=["ai-ui-control"] hinzugefügt
- `app/plugins/builtins/system_notif/plugin.py` — backup.completed/failed events + handler methods
**Phase 5 Batch 3 Gesamt: ✅ Complete**
+57 -1
View File
@@ -212,7 +212,63 @@ async def lifespan(app: FastAPI):
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
settings = get_settings()
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
app = FastAPI(
title="LeoCRM",
version="1.0.0",
lifespan=lifespan,
description=(
"LeoCRM — Self-hosted CRM system for small sales teams.\n\n"
"## Authentication\n"
"All endpoints (except `/api/v1/auth/login` and `/api/v1/health`) require "
"a valid session cookie. Obtain a session by calling `POST /api/v1/auth/login`.\n\n"
"## Multi-Tenancy\n"
"All data is tenant-scoped. The tenant context is set automatically from the "
"authenticated session.\n\n"
"## Plugins\n"
"LeoCRM uses a plugin architecture. Core routes are always available. "
"Plugin routes (DMS, Mail, Calendar, Automation, etc.) are registered when "
"the corresponding plugin is active."
),
openapi_tags=[
{"name": "health", "description": "Health check endpoints — no auth required."},
{"name": "metrics", "description": "Prometheus metrics endpoint."},
{"name": "auth", "description": "Authentication: login, logout, session, password reset."},
{"name": "users", "description": "User management: CRUD, user-tenant assignments."},
{"name": "roles", "description": "Role management and permission definitions."},
{"name": "groups", "description": "User groups for contact assignment and filtering."},
{"name": "tenants", "description": "Tenant management and tenant switching."},
{"name": "notifications", "description": "User notifications and notification preferences."},
{"name": "contacts", "description": "Contact CRUD, contact persons, FTS search, export, soft-delete."},
{"name": "contact-folders", "description": "Contact folder management for organization."},
{"name": "entity-history", "description": "Audit trail and entity change history."},
{"name": "import-export", "description": "Bulk import and export of contacts and data."},
{"name": "plugins", "description": "Plugin management: list, install, activate, deactivate."},
{"name": "ai-copilot", "description": "AI copilot: chat, suggestions, conversation history."},
{"name": "workflows", "description": "Workflow definitions, instances, and execution."},
{"name": "user-preferences", "description": "Per-user preference settings."},
{"name": "currencies", "description": "Currency management for multi-currency support."},
{"name": "taxes", "description": "Tax rate management (VAT, sales tax)."},
{"name": "sequences", "description": "Number sequence management for invoices, quotes, etc."},
{"name": "system-settings", "description": "Tenant-level system settings (company info, theme)."},
{"name": "attachments", "description": "File attachments for contacts, companies, and entities."},
{"name": "addresses", "description": "Address management for contacts and companies."},
{"name": "audit", "description": "Audit log queries and compliance reporting."},
{"name": "automation", "description": "Automation engine: agents, automations, cron jobs, execution logs."},
{"name": "agents", "description": "AI agent definitions and agent runner endpoints."},
{"name": "dms", "description": "Document Management System: files, folders, sources, sharing."},
{"name": "mail", "description": "Email integration: IMAP accounts, folders, messages, send."},
{"name": "calendar", "description": "Calendar management: appointments, resources, recurrence."},
{"name": "search", "description": "Unified search across contacts, documents, emails, etc."},
{"name": "reports", "description": "Report generator: templates, rendering, scheduled reports."},
{"name": "entity-links", "description": "Entity linking: connect contacts to DMS documents and other entities."},
{"name": "kommunikation", "description": "Unified messaging: conversations, participants, messages."},
{"name": "ai-proactive", "description": "Proactive AI: insights, alerts, and recommendations."},
{"name": "ai-assistant", "description": "AI assistant: chat completions, tool calling, context awareness."},
{"name": "tags", "description": "Tag management: create, assign, search tags across entities."},
{"name": "permissions", "description": "Permission management: roles, field-level permissions, sharing."},
{"name": "public-share", "description": "Public sharing endpoints — no auth required, token-based access."},
],
)
app.add_middleware(
CORSMiddleware,
+1 -1
View File
@@ -26,7 +26,7 @@ from app.plugins.builtins.ai_ui_control.schemas import (
logger = logging.getLogger(__name__)
router = APIRouter()
router = APIRouter(tags=["ai-ui-control"])
# ─── REST endpoints (for AI agents) ───
@@ -34,6 +34,8 @@ class SystemNotifPlugin(BasePlugin):
"user.created",
"workflow.completed",
"notification.created",
"backup.completed",
"backup.failed",
],
migrations=[],
permissions=["system_notif:read"],
@@ -112,6 +114,14 @@ class SystemNotifPlugin(BasePlugin):
"""
await self._create_system_notification(payload, event_type="notification.created")
async def on_backup_completed(self, payload: dict[str, Any]) -> None:
"""Handle backup.completed event → system message."""
await self._create_system_notification(payload, event_type="backup.completed")
async def on_backup_failed(self, payload: dict[str, Any]) -> None:
"""Handle backup.failed event → system message (error)."""
await self._create_system_notification(payload, event_type="backup.failed", severity="error")
async def _create_system_notification(
self,
payload: dict[str, Any],
@@ -155,6 +165,8 @@ class SystemNotifPlugin(BasePlugin):
"user.created": "Neuer Benutzer",
"workflow.completed": "Workflow abgeschlossen",
"notification.created": "Benachrichtigung",
"backup.completed": "Backup erfolgreich",
"backup.failed": "Backup fehlgeschlagen",
}
title = event_titles.get(event_type, event_type)
+5 -3
View File
@@ -12,7 +12,9 @@ from app.core.auth import get_redis
from app.core.db import get_db
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.schemas.auth import (
AuthResponse,
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
SwitchTenantRequest,
@@ -24,7 +26,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
@router.post("/login")
@router.post("/login", response_model=AuthResponse)
async def login(
request: Request,
body: LoginRequest,
@@ -91,7 +93,7 @@ async def login(
return resp
@router.post("/logout")
@router.post("/logout", response_model=MessageResponse)
async def logout(
request: Request,
db: AsyncSession = Depends(get_db),
@@ -112,7 +114,7 @@ async def logout(
return resp
@router.get("/me")
@router.get("/me", response_model=AuthResponse)
async def me(
request: Request,
db: AsyncSession = Depends(get_db),
+2 -1
View File
@@ -5,11 +5,12 @@ from __future__ import annotations
from fastapi import APIRouter
from app.core.monitoring import get_health_status
from app.schemas.common import HealthResponse
router = APIRouter(tags=["health"])
@router.get("/api/v1/health")
@router.get("/api/v1/health", response_model=HealthResponse)
async def health():
"""Health check — no auth required.
+2 -2
View File
@@ -19,7 +19,7 @@ from app.models.notification import (
NotificationPreference,
NotificationType,
)
from app.schemas.common import NotificationPreferenceUpdate
from app.schemas.common import NotificationPreferenceUpdate, UnreadCountResponse
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
@@ -67,7 +67,7 @@ async def mark_notification_read_endpoint(
}
@router.get("/unread-count")
@router.get("/unread-count", response_model=UnreadCountResponse)
async def unread_count_endpoint(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("notifications:read")),
+3 -3
View File
@@ -9,13 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.schemas.system_settings import SystemSettingsUpsert
from app.schemas.system_settings import SystemSettingsUpsert, SystemSettingsResponse
from app.services import system_settings_service
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
@router.get("")
@router.get("", response_model=SystemSettingsResponse)
async def get_system_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:read")),
@@ -28,7 +28,7 @@ async def get_system_settings(
return result
@router.put("")
@router.put("", response_model=SystemSettingsResponse)
async def upsert_system_settings(
body: SystemSettingsUpsert,
db: AsyncSession = Depends(get_db),
+4 -4
View File
@@ -14,7 +14,7 @@ from app.core.db import get_db
from app.core.notifications import create_notification
from app.core.permissions import invalidate_permission_cache
from app.deps import require_permission
from app.schemas.user import UserCreate, UserUpdate
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PaginatedUsers
from app.services.user_service import user_service, _UNSET
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@@ -36,7 +36,7 @@ def _parse_role_id(raw: str | None) -> uuid.UUID | None:
) from None
@router.get("")
@router.get("", response_model=PaginatedUsers)
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
@@ -49,7 +49,7 @@ async def list_users(
return await user_service.list_users(db, tenant_id, page, page_size, search)
@router.post("", status_code=status.HTTP_201_CREATED)
@router.post("", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
@@ -103,7 +103,7 @@ async def create_user(
}
@router.get("/{user_id}")
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
+8 -8
View File
@@ -6,8 +6,8 @@ from pydantic import BaseModel, EmailStr, Field
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=1)
email: EmailStr = Field(..., examples=["admin@leocrm.local"])
password: str = Field(..., min_length=1, examples=["secure-password"])
class PasswordResetRequest(BaseModel):
@@ -24,12 +24,12 @@ class SwitchTenantRequest(BaseModel):
class AuthResponse(BaseModel):
user_id: str
email: str
name: str
role: str
tenant_id: str
tenant_name: str | None = None
user_id: str = Field(..., examples=["550e8400-e29b-41d4-a716-446655440000"])
email: str = Field(..., examples=["admin@leocrm.local"])
name: str = Field(..., examples=["Admin User"])
role: str = Field(..., examples=["admin"])
tenant_id: str = Field(..., examples=["550e8400-e29b-41d4-a716-446655440001"])
tenant_name: str | None = Field(None, examples=["Acme GmbH"])
class MessageResponse(BaseModel):
+8
View File
@@ -29,6 +29,10 @@ class SystemSettingsUpsert(BaseModel):
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)
# Backup configuration
backup_interval: str = Field("daily", max_length=20, description="Backup interval: hourly, daily, weekly, manual")
backup_retention_days: int = Field(7, ge=1, le=365, description="Days to keep backups")
backup_destination: str = Field("local", max_length=20, description="Backup destination: local, s3, nextcloud")
class SystemSettingsResponse(BaseModel):
@@ -56,5 +60,9 @@ class SystemSettingsResponse(BaseModel):
theme_accent_color: str = "#d946ef"
theme_font_family: str = "Inter"
theme_border_radius: str = "0.5rem"
# Backup configuration
backup_interval: str = "daily"
backup_retention_days: int = 7
backup_destination: str = "local"
created_at: str | None = None
updated_at: str | None = None
+5 -5
View File
@@ -6,11 +6,11 @@ from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
name: str = Field(..., min_length=1, max_length=200)
password: str = Field(..., min_length=8)
role: str = Field(default="viewer")
role_id: str | None = Field(default=None, description="UUID of a custom Role")
email: EmailStr = Field(..., examples=["user@leocrm.local"])
name: str = Field(..., min_length=1, max_length=200, examples=["John Doe"])
password: str = Field(..., min_length=8, examples=["secure-password"])
role: str = Field(default="viewer", examples=["viewer"])
role_id: str | None = Field(default=None, description="UUID of a custom Role", examples=["550e8400-e29b-41d4-a716-446655440000"])
is_active: bool = True
+500
View File
@@ -0,0 +1,500 @@
# LeoCRM API Documentation
> Auto-generated from FastAPI route enumeration. **295 endpoints** across **30 tag groups**.
## Overview
LeoCRM exposes a RESTful API under `/api/v1/`. All endpoints (except `/api/v1/health` and `/api/v1/auth/login`) require authentication via session cookie.
- **Swagger UI**: `/docs`
- **ReDoc**: `/redoc`
- **OpenAPI JSON**: `/openapi.json`
### Authentication
1. Call `POST /api/v1/auth/login` with `{email, password}` → receives session cookie.
2. Include the session cookie in all subsequent requests.
3. Call `POST /api/v1/auth/logout` to invalidate the session.
### Multi-Tenancy
All data is tenant-scoped. The tenant context is derived from the authenticated session. Users with multiple tenants can switch via `POST /api/v1/auth/switch-tenant`.
### Error Format
```json
{"detail": "Error message", "code": "error_code", "fields": {"field": "error"}}
```
### Pagination
List endpoints use `page` (1-based) and `page_size` (1-100) query parameters. Responses include `total`, `page`, `page_size`, and `items`.
---
## Core Routes
### health (1 endpoint)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/health` | Health check — no auth required. Returns status + DB/Redis/storage/worker checks. **Response model**: `HealthResponse` |
### metrics (1 endpoint)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/metrics` | Prometheus metrics endpoint. |
### auth (7 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/auth/login` | Login with email+password. Sets session cookie. **Request**: `LoginRequest`, **Response**: `AuthResponse` |
| POST | `/api/v1/auth/logout` | Logout — invalidate session, clear cookie. **Response**: `MessageResponse` |
| GET | `/api/v1/auth/me` | Get current user + active tenant. **Response**: `AuthResponse` |
| GET | `/api/v1/auth/me/permissions` | Get resolved permissions for current user. |
| POST | `/api/v1/auth/switch-tenant` | Switch active tenant. **Request**: `SwitchTenantRequest` |
| POST | `/api/v1/auth/password-reset/request` | Request password reset email. **Request**: `PasswordResetRequest` |
| POST | `/api/v1/auth/password-reset/confirm` | Confirm password reset with token. **Request**: `PasswordResetConfirm` |
### users (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/users` | List users with pagination. **Response**: `PaginatedUsers` |
| POST | `/api/v1/users` | Create a new user (admin only). **Request**: `UserCreate`, **Response**: `UserResponse` |
| GET | `/api/v1/users/{user_id}` | Get a single user. **Response**: `UserResponse` |
| PATCH | `/api/v1/users/{user_id}` | Update user fields. **Request**: `UserUpdate` |
| DELETE | `/api/v1/users/{user_id}` | Delete/deactivate a user. |
### roles (5 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/roles` | List all roles. |
| POST | `/api/v1/roles` | Create a custom role. |
| GET | `/api/v1/roles/permissions` | Get all available permission definitions. |
| PATCH | `/api/v1/roles/{role_id}` | Update role permissions. |
| DELETE | `/api/v1/roles/{role_id}` | Delete a custom role. |
### groups (9 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/groups` | List all groups. |
| POST | `/api/v1/groups` | Create a new group. |
| GET | `/api/v1/groups/{group_id}` | Get a single group. |
| PATCH | `/api/v1/groups/{group_id}` | Update group. |
| DELETE | `/api/v1/groups/{group_id}` | Delete group. |
| GET | `/api/v1/groups/{group_id}/members` | List group members. |
| POST | `/api/v1/groups/{group_id}/members` | Add member to group. |
| DELETE | `/api/v1/groups/{group_id}/members/{user_id}` | Remove member from group. |
| GET | `/api/v1/groups/user/{user_id}` | Get groups for a user. |
### tenants (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/tenants` | List tenants for current user. |
| POST | `/api/v1/tenants` | Create a new tenant. |
| GET | `/api/v1/tenants/{tenant_id}/users` | List users in a tenant. |
| POST | `/api/v1/tenants/{tenant_id}/users` | Add user to tenant. |
### notifications (6 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/notifications` | List notifications with pagination. |
| PATCH | `/api/v1/notifications/{notification_id}/read` | Mark notification as read. |
| GET | `/api/v1/notifications/unread-count` | Get unread count. **Response**: `UnreadCountResponse` |
| GET | `/api/v1/notifications/types` | List notification types. |
| GET | `/api/v1/notifications/preferences` | Get notification preferences. |
| PATCH | `/api/v1/notifications/preferences/{type_key}` | Update notification preference. |
### contacts (10 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/contacts` | List contacts with pagination, FTS search, type/folder filter, sorting. |
| POST | `/api/v1/contacts` | Create a contact (company or person). |
| GET | `/api/v1/contacts/export` | Stream contacts as CSV. |
| GET | `/api/v1/contacts/{contact_id}` | Get a single contact. |
| PUT | `/api/v1/contacts/{contact_id}` | Update contact. |
| DELETE | `/api/v1/contacts/{contact_id}` | Soft-delete contact. |
| GET | `/api/v1/contacts/{contact_id}/persons` | List contact persons. |
| POST | `/api/v1/contacts/{contact_id}/persons` | Add contact person. |
| PUT | `/api/v1/contacts/{contact_id}/persons/{person_id}` | Update contact person. |
| DELETE | `/api/v1/contacts/{contact_id}/persons/{person_id}` | Delete contact person. |
### contact-folders (6 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/contact-folders` | List contact folders. |
| POST | `/api/v1/contact-folders` | Create a folder. |
| PUT | `/api/v1/contact-folders/{folder_id}` | Update folder. |
| DELETE | `/api/v1/contact-folders/{folder_id}` | Delete folder. |
| PUT | `/api/v1/contact-folders/{folder_id}/reorder` | Reorder folder. |
| PUT | `/api/v1/contact-folders/contacts/{contact_id}/move` | Move contact to folder. |
### entity-history (3 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/entity-history/{entity_type}/{entity_id}` | Get change history for an entity. |
| POST | `/api/v1/entity-history/undo/{entity_type}/{entity_id}` | Undo last change. |
| POST | `/api/v1/entity-history/restore` | Restore entity to a specific version. |
### import-export (2 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/import` | Import data from file. |
| POST | `/api/v1/import/preview` | Preview import data. |
### plugins (11 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/plugins` | List all plugins. |
| GET | `/api/v1/plugins/manifest` | Get plugin manifest schema. |
| GET | `/api/v1/plugins/active-manifests` | Get manifests of all active plugins. |
| POST | `/api/v1/plugins/install-url` | Install plugin from URL. |
| POST | `/api/v1/plugins/upload` | Upload and install plugin ZIP. |
| POST | `/api/v1/plugins/{name}/install` | Install a discovered plugin. |
| POST | `/api/v1/plugins/{name}/activate` | Activate a plugin. |
| POST | `/api/v1/plugins/{name}/deactivate` | Deactivate a plugin. |
| GET | `/api/v1/plugins/{name}/config` | Get plugin configuration. |
| PATCH | `/api/v1/plugins/{name}/config` | Update plugin configuration. |
| DELETE | `/api/v1/plugins/{name}` | Uninstall a plugin. |
### ai-copilot (3 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/ai/copilot/query` | Ask the AI copilot a question. |
| POST | `/api/v1/ai/copilot/execute` | Execute an AI copilot action. |
| GET | `/api/v1/ai/copilot/history` | Get copilot conversation history. |
### workflows (10 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/workflows` | List workflow definitions. |
| POST | `/api/v1/workflows` | Create a workflow. |
| GET | `/api/v1/workflows/{workflow_id}` | Get a workflow. |
| PATCH | `/api/v1/workflows/{workflow_id}` | Update a workflow. |
| DELETE | `/api/v1/workflows/{workflow_id}` | Delete a workflow. |
| POST | `/api/v1/workflows/{workflow_id}/instances` | Start a workflow instance. |
| GET | `/api/v1/workflows/instances` | List workflow instances. |
| GET | `/api/v1/workflows/instances/{instance_id}` | Get a workflow instance. |
| POST | `/api/v1/workflows/instances/{instance_id}/advance` | Advance workflow to next step. |
| POST | `/api/v1/workflows/instances/{instance_id}/cancel` | Cancel a workflow instance. |
### user-preferences (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/user/preferences` | Get all user preferences. |
| GET | `/api/v1/user/preferences/{key}` | Get a specific preference. |
| PUT | `/api/v1/user/preferences/{key}` | Set a preference. |
| DELETE | `/api/v1/user/preferences/{key}` | Delete a preference. |
### currencies (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/currencies` | List currencies. |
| POST | `/api/v1/currencies` | Create a currency. |
| PATCH | `/api/v1/currencies/{currency_id}` | Update a currency. |
| DELETE | `/api/v1/currencies/{currency_id}` | Delete a currency. |
### taxes (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/taxes` | List tax rates. |
| POST | `/api/v1/taxes` | Create a tax rate. |
| PATCH | `/api/v1/taxes/{tax_id}` | Update a tax rate. |
| DELETE | `/api/v1/taxes/{tax_id}` | Delete a tax rate. |
### sequences (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/sequences` | List number sequences. |
| POST | `/api/v1/sequences` | Create a sequence. |
| PATCH | `/api/v1/sequences/{sequence_id}` | Update a sequence. |
| DELETE | `/api/v1/sequences/{sequence_id}` | Delete a sequence. |
### system-settings (2 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/system-settings` | Get system settings. **Response**: `SystemSettingsResponse` |
| PUT | `/api/v1/system-settings` | Upsert system settings. **Request**: `SystemSettingsUpsert`, **Response**: `SystemSettingsResponse` |
### attachments (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/attachments` | List attachments. |
| POST | `/api/v1/attachments` | Upload an attachment. |
| GET | `/api/v1/attachments/{attachment_id}` | Download an attachment. |
| DELETE | `/api/v1/attachments/{attachment_id}` | Delete an attachment. |
### addresses (4 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/addresses` | List addresses. |
| POST | `/api/v1/addresses` | Create an address. |
| PATCH | `/api/v1/addresses/{address_id}` | Update an address. |
| DELETE | `/api/v1/addresses/{address_id}` | Delete an address. |
### audit (1 endpoint)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/audit-log` | Query audit log entries. |
---
## Plugin Routes
### automation (Automation & Agents)
Agent Builder, Automation Builder, Cron-Scheduler, Agent Runner.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/automation/*` | Automation definitions, triggers, execution logs. |
### agents (AI Agents)
| Method | Path | Description |
|--------|------|-------------|
| GET/POST/PUT/DELETE | `/api/v1/agents/*` | AI agent CRUD and runner endpoints. |
### dms (Document Management System)
19 endpoints for file and folder management.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/dms/files` | List files. |
| POST | `/api/v1/dms/files/upload` | Upload a file. |
| GET | `/api/v1/dms/files/{file_id}` | Get file metadata. |
| GET | `/api/v1/dms/files/{file_id}/preview` | Preview a file. |
| POST | `/api/v1/dms/files/{file_id}/share` | Share a file. |
| GET/POST | `/api/v1/dms/folders` | Folder CRUD. |
| GET | `/api/v1/dms/search` | Search documents. |
| GET | `/api/v1/dms/shared-with-me` | Files shared with current user. |
### mail (Email Integration)
50 endpoints for IMAP/SMTP email management.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/mail/accounts` | Email account management. |
| GET/POST | `/api/v1/mail/folders` | Folder management. |
| GET | `/api/v1/mail/{mail_id}` | Get a specific email. |
| POST | `/api/v1/mail/send` | Send an email. |
| POST | `/api/v1/mail/drafts` | Save draft. |
| GET | `/api/v1/mail/search` | Search emails. |
| GET | `/api/v1/mail/threads` | List email threads. |
| POST | `/api/v1/mail/pgp/keys` | PGP key management. |
| POST | `/api/v1/mail/rules` | Mail filter rules. |
| POST | `/api/v1/mail/vacation` | Vacation responder. |
### calendar (Calendar & Scheduling)
21 endpoints for calendar management.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/calendar/entries` | Calendar entry CRUD. |
| GET/POST | `/api/v1/calendars` | Calendar CRUD. |
| GET | `/api/v1/calendar/kanban` | Kanban board view. |
| POST | `/api/v1/calendar/import` | Import ICS. |
| GET | `/api/v1/calendar/{calendar_id}/ics-feed` | ICS feed. |
| POST | `/api/v1/resources` | Resource booking. |
### search (Unified Search)
7 endpoints for cross-entity search.
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/search` | Unified search across all entities. |
| GET | `/api/v1/search/providers` | List search providers. |
| POST | `/api/v1/search/reindex` | Rebuild search index. |
| POST | `/api/v1/search/similar` | Find similar entities. |
| GET | `/api/v1/search/suggest` | Search suggestions. |
| GET | `/api/v1/search/stats` | Search index statistics. |
### reports (Report Generator)
8 endpoints for report templates and generation.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/reports/templates` | Report template CRUD. |
| POST | `/api/v1/reports/generate` | Generate a report. |
| GET | `/api/v1/reports/{report_id}` | Get report status. |
| GET | `/api/v1/reports/{report_id}/download` | Download generated report. |
### entity-links (Entity Linking)
4 endpoints for connecting entities.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/contacts/{contact_id}/files` | Get files linked to a contact. |
| POST/DELETE | `/api/v1/dms/files/{file_id}/link` | Link/unlink a file to an entity. |
| GET | `/api/v1/dms/files/{file_id}/links` | Get all links for a file. |
### kommunikation (Unified Messaging)
23 endpoints for conversations and messages.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/comm/conversations` | Conversation CRUD. |
| GET/POST | `/api/v1/comm/conversations/{id}/messages` | Message list and send. |
| POST | `/api/v1/comm/conversations/{id}/participants` | Manage participants. |
| POST | `/api/v1/comm/messages/{id}/reactions` | Message reactions. |
| GET | `/api/v1/comm/miniapps` | List mini-apps. |
### ai-proactive (Proactive AI)
8 endpoints for AI insights and suggestions.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/ai-proactive/suggestions` | Get AI suggestions. |
| GET | `/api/v1/ai-proactive/suggestions/stream` | SSE stream of suggestions. |
| POST | `/api/v1/ai-proactive/suggestions/{id}/act` | Act on a suggestion. |
| POST | `/api/v1/ai-proactive/suggestions/{id}/dismiss` | Dismiss a suggestion. |
| GET/PUT | `/api/v1/ai-proactive/settings` | Proactive AI settings. |
### ai-assistant (AI Assistant)
30 endpoints for AI chat sessions, providers, models, and tools.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/ai/sessions` | Chat session CRUD. |
| GET | `/api/v1/ai/sessions/{id}/messages` | List messages in session. |
| POST | `/api/v1/ai/sessions/{id}/stream` | Stream chat completion (SSE). |
| GET/POST | `/api/v1/ai/providers` | AI provider CRUD. |
| GET/POST | `/api/v1/ai/models` | AI model CRUD. |
| GET/POST | `/api/v1/ai/presets` | Preset CRUD. |
| GET | `/api/v1/ai/tools` | List available AI tools. |
### ai-ui-control (AI UI Control)
WebSocket and REST endpoints for AI-driven UI control.
| Method | Path | Description |
|--------|------|-------------|
| WebSocket | `/ws/ai-ui-control` | Real-time UI command stream. |
| POST | `/api/v1/ai-ui-control/command` | Send UI command from AI agent. |
### tags (Tag Management)
8 endpoints for tag CRUD and assignment.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/tags` | Tag CRUD. |
| POST/DELETE | `/api/v1/tags/assign` | Assign/remove tag from entity. |
| POST | `/api/v1/tags/bulk-assign` | Bulk assign tags. |
| GET | `/api/v1/tags/{tag_id}/entities` | Get entities with a tag. |
### permissions (Permission Management)
5 endpoints for file-level permissions and share links.
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/dms/files/{file_id}/permissions` | File permission CRUD. |
| POST | `/api/v1/dms/files/{file_id}/share-link` | Create public share link. |
| DELETE | `/api/v1/dms/share-links/{link_id}` | Revoke share link. |
### public-share (Public Sharing)
2 endpoints — no auth required, token-based access.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/public/share/{token}` | Access shared resource via token. |
| POST | `/api/public/share/{token}` | Interact with shared resource. |
---
## Schema Examples
### LoginRequest
```json
{"email": "admin@leocrm.local", "password": "secure-password"}
```
### AuthResponse
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@leocrm.local",
"name": "Admin User",
"role": "admin",
"tenant_id": "550e8400-e29b-41d4-a716-446655440001",
"tenant_name": "Acme GmbH"
}
```
### UserCreate
```json
{
"email": "user@leocrm.local",
"name": "John Doe",
"password": "secure-password",
"role": "viewer",
"is_active": true
}
```
### UserResponse
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@leocrm.local",
"name": "John Doe",
"role": "viewer",
"is_active": true,
"tenant_id": "550e8400-e29b-41d4-a716-446655440001"
}
```
### HealthResponse
```json
{"status": "healthy", "version": "1.0.0"}
```
### UnreadCountResponse
```json
{"count": 5}
```
---
## KI Integration Notes
### For AI Agents
1. **Health Check**: Run `python scripts/ai_health_check.py --base-url <url> --token <token>` before and after updates.
2. **Deploy**: Use `python scripts/ai_deploy.py --dry-run` to preview, then `python scripts/ai_deploy.py` for real deployment.
3. **OpenAPI**: The full OpenAPI spec is available at `/openapi.json` — use it for dynamic endpoint discovery.
4. **Safe Methods**: GET endpoints are safe to probe. POST/PUT/DELETE require careful payload construction.
5. **Session Auth**: AI agents should call `POST /api/v1/auth/login` first, then use the returned cookie for all subsequent requests.
+442
View File
@@ -0,0 +1,442 @@
#!/usr/bin/env python3
"""AI Deploy Script — Build, Test, Deploy with automatic rollback.
Usage:
python scripts/ai_deploy.py
python scripts/ai_deploy.py --skip-tests
python scripts/ai_deploy.py --dry-run
python scripts/ai_deploy.py --skip-build --skip-tests
Pipeline phases:
1. Build — docker build (or npm run build for frontend-only)
2. Tests — pytest (backend) + vitest (frontend)
3. Deploy — Coolify API call to redeploy
4. Rollback — on failure, restore previous deployment
Environment variables:
COOLIFY_API_TOKEN — Coolify API token for deployment
COOLIFY_APP_UUID — Coolify application UUID
COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de)
Exit codes:
0 — deployment successful
1 — deployment failed (rollback attempted)
2 — configuration error
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, field
from typing import Any
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import httpx
# ─── Data Structures ─────────────────────────────────────────────────
@dataclass
class PhaseResult:
"""Result of a single pipeline phase."""
name: str
success: bool = False
duration_s: float = 0.0
output: str = ""
error: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"phase": self.name,
"success": self.success,
"duration_s": round(self.duration_s, 2),
"output": self.output[:500] if self.output else "",
"error": self.error[:500] if self.error else "",
"metadata": self.metadata,
}
@dataclass
class DeployReport:
"""Full deployment report."""
started_at: str = ""
finished_at: str = ""
phases: list[PhaseResult] = field(default_factory=list)
overall_success: bool = False
rollback_performed: bool = False
rollback_success: bool = False
def to_dict(self) -> dict[str, Any]:
return {
"started_at": self.started_at,
"finished_at": self.finished_at,
"overall_success": self.overall_success,
"rollback_performed": self.rollback_performed,
"rollback_success": self.rollback_success,
"phases": [p.to_dict() for p in self.phases],
}
# ─── Phase Implementations ───────────────────────────────────────────
def run_command(cmd: list[str], cwd: str | None = None, timeout: int = 600) -> tuple[bool, str, str]:
"""Run a shell command and return (success, stdout, stderr)."""
try:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode == 0, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return False, "", f"Command timed out after {timeout}s"
except FileNotFoundError as exc:
return False, "", str(exc)
except Exception as exc:
return False, "", str(exc)
def phase_build(skip_build: bool, dry_run: bool) -> PhaseResult:
"""Build phase: docker build or npm run build."""
start = time.perf_counter()
result = PhaseResult(name="build")
if skip_build:
result.success = True
result.output = "Build skipped (--skip-build)"
result.duration_s = time.perf_counter() - start
return result
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if dry_run:
result.success = True
result.output = "[DRY-RUN] Would run: docker build -t leocrm:latest ."
result.duration_s = time.perf_counter() - start
return result
# Try docker build first
docker_available = shutil.which("docker") is not None
if docker_available:
success, stdout, stderr = run_command(
["docker", "build", "-t", "leocrm:latest", "."],
cwd=project_root,
timeout=900,
)
result.success = success
result.output = stdout
result.error = stderr
result.metadata["build_tool"] = "docker"
else:
# Fallback: frontend-only build with npm
frontend_dir = os.path.join(project_root, "frontend")
npm_available = shutil.which("npm") is not None
if npm_available and os.path.isdir(frontend_dir):
success, stdout, stderr = run_command(
["npm", "run", "build"],
cwd=frontend_dir,
timeout=300,
)
result.success = success
result.output = stdout
result.error = stderr
result.metadata["build_tool"] = "npm"
else:
result.success = False
result.error = "Neither docker nor npm available for build"
result.duration_s = time.perf_counter() - start
return result
def phase_tests(skip_tests: bool, dry_run: bool) -> PhaseResult:
"""Test phase: pytest (backend) + vitest (frontend)."""
start = time.perf_counter()
result = PhaseResult(name="tests")
if skip_tests:
result.success = True
result.output = "Tests skipped (--skip-tests)"
result.duration_s = time.perf_counter() - start
return result
if dry_run:
result.success = True
result.output = "[DRY-RUN] Would run: pytest + vitest"
result.duration_s = time.perf_counter() - start
return result
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
python_bin = os.environ.get("LEOCRM_PYTHON", "/opt/venv/bin/python")
test_outputs: list[str] = []
test_errors: list[str] = []
all_success = True
# Backend tests: pytest
if os.path.isfile(os.path.join(project_root, "pyproject.toml")):
success, stdout, stderr = run_command(
[python_bin, "-m", "pytest", "tests/", "-x", "--tb=short", "-q"],
cwd=project_root,
timeout=300,
)
test_outputs.append(f"=== pytest ===\n{stdout}")
if stderr:
test_errors.append(f"=== pytest stderr ===\n{stderr}")
if not success:
all_success = False
result.metadata["pytest_passed"] = False
else:
result.metadata["pytest_passed"] = True
# Frontend tests: vitest
frontend_dir = os.path.join(project_root, "frontend")
npm_available = shutil.which("npm") is not None
if npm_available and os.path.isdir(frontend_dir):
success, stdout, stderr = run_command(
["npm", "run", "test"],
cwd=frontend_dir,
timeout=300,
)
test_outputs.append(f"=== vitest ===\n{stdout}")
if stderr:
test_errors.append(f"=== vitest stderr ===\n{stderr}")
if not success:
all_success = False
result.metadata["vitest_passed"] = False
else:
result.metadata["vitest_passed"] = True
result.success = all_success
result.output = "\n".join(test_outputs)
result.error = "\n".join(test_errors)
result.duration_s = time.perf_counter() - start
return result
def phase_deploy(dry_run: bool) -> PhaseResult:
"""Deploy phase: Coolify API call to redeploy."""
start = time.perf_counter()
result = PhaseResult(name="deploy")
api_token = os.environ.get("COOLIFY_API_TOKEN", "")
app_uuid = os.environ.get("COOLIFY_APP_UUID", "")
base_url = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de")
if not api_token or not app_uuid:
result.success = False
result.error = "Missing COOLIFY_API_TOKEN or COOLIFY_APP_UUID environment variables"
result.duration_s = time.perf_counter() - start
return result
if dry_run:
result.success = True
result.output = f"[DRY-RUN] Would call: POST {base_url}/api/v1/applications/{app_uuid}/deploy"
result.metadata["api_url"] = f"{base_url}/api/v1/applications/{app_uuid}/deploy"
result.duration_s = time.perf_counter() - start
return result
deploy_url = f"{base_url}/api/v1/applications/{app_uuid}/deploy"
headers = {"Authorization": f"Bearer {api_token}"}
try:
response = httpx.post(deploy_url, headers=headers, timeout=120.0)
if response.status_code < 400:
result.success = True
result.output = f"Deploy triggered: HTTP {response.status_code}"
try:
result.metadata["response"] = response.json()
except Exception:
result.metadata["response_text"] = response.text[:200]
else:
result.success = False
result.error = f"Deploy failed: HTTP {response.status_code} - {response.text[:300]}"
except httpx.TimeoutException:
result.success = False
result.error = "Deploy request timed out"
except httpx.ConnectError as exc:
result.success = False
result.error = f"Connection error: {exc}"
except Exception as exc:
result.success = False
result.error = str(exc)
result.duration_s = time.perf_counter() - start
return result
def phase_rollback(dry_run: bool) -> PhaseResult:
"""Rollback phase: restore previous deployment via Coolify API."""
start = time.perf_counter()
result = PhaseResult(name="rollback")
api_token = os.environ.get("COOLIFY_API_TOKEN", "")
app_uuid = os.environ.get("COOLIFY_APP_UUID", "")
base_url = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de")
if not api_token or not app_uuid:
result.success = False
result.error = "Missing COOLIFY_API_TOKEN or COOLIFY_APP_UUID for rollback"
result.duration_s = time.perf_counter() - start
return result
if dry_run:
result.success = True
result.output = f"[DRY-RUN] Would call: POST {base_url}/api/v1/applications/{app_uuid}/rollback"
result.duration_s = time.perf_counter() - start
return result
# Coolify rollback: restart with previous image
rollback_url = f"{base_url}/api/v1/applications/{app_uuid}/restart"
headers = {"Authorization": f"Bearer {api_token}"}
try:
response = httpx.post(rollback_url, headers=headers, timeout=120.0)
if response.status_code < 400:
result.success = True
result.output = f"Rollback triggered: HTTP {response.status_code}"
else:
result.success = False
result.error = f"Rollback failed: HTTP {response.status_code} - {response.text[:300]}"
except Exception as exc:
result.success = False
result.error = str(exc)
result.duration_s = time.perf_counter() - start
return result
# ─── Pipeline Orchestration ──────────────────────────────────────────
def run_pipeline(skip_build: bool, skip_tests: bool, dry_run: bool) -> DeployReport:
"""Run the full deployment pipeline."""
from datetime import datetime, timezone
report = DeployReport()
report.started_at = datetime.now(timezone.utc).isoformat()
print(f"\n{'='*60}")
print(f" LeoCRM AI Deploy Pipeline")
print(f" {'[DRY-RUN]' if dry_run else '[LIVE]'}")
print(f"{'='*60}\n")
# Phase 1: Build
print("▶ Phase 1: Build")
build_result = phase_build(skip_build, dry_run)
report.phases.append(build_result)
_print_phase_result(build_result)
if not build_result.success:
print("\n✗ Build failed — aborting pipeline.")
report.overall_success = False
report.finished_at = datetime.now(timezone.utc).isoformat()
return report
# Phase 2: Tests
print("\n▶ Phase 2: Tests")
test_result = phase_tests(skip_tests, dry_run)
report.phases.append(test_result)
_print_phase_result(test_result)
if not test_result.success:
print("\n✗ Tests failed — aborting pipeline (no deploy).")
report.overall_success = False
report.finished_at = datetime.now(timezone.utc).isoformat()
return report
# Phase 3: Deploy
print("\n▶ Phase 3: Deploy")
deploy_result = phase_deploy(dry_run)
report.phases.append(deploy_result)
_print_phase_result(deploy_result)
if not deploy_result.success:
# Phase 4: Rollback
print("\n✗ Deploy failed — initiating rollback...")
rollback_result = phase_rollback(dry_run)
report.phases.append(rollback_result)
report.rollback_performed = True
report.rollback_success = rollback_result.success
_print_phase_result(rollback_result)
report.overall_success = False
else:
report.overall_success = True
print("\n✓ Deployment successful!")
report.finished_at = datetime.now(timezone.utc).isoformat()
return report
def _print_phase_result(result: PhaseResult) -> None:
"""Print a phase result summary."""
status = "✓" if result.success else "✗"
print(f" {status} {result.name}: {'PASS' if result.success else 'FAIL'} ({result.duration_s:.1f}s)")
if result.output:
for line in result.output.strip().split("\n")[-5:]:
print(f" │ {line}")
if result.error:
for line in result.error.strip().split("\n")[-3:]:
print(f" │ ERROR: {line}", file=sys.stderr)
# ─── CLI Entry Point ─────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LeoCRM AI Deploy — Build, Test, Deploy with automatic rollback."
)
parser.add_argument(
"--skip-tests",
action="store_true",
help="Skip the test phase",
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Skip the build phase",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the pipeline without making changes",
)
parser.add_argument(
"--output",
default="-",
help="Write structured JSON report to file (default: stdout)",
)
args = parser.parse_args()
report = run_pipeline(
skip_build=args.skip_build,
skip_tests=args.skip_tests,
dry_run=args.dry_run,
)
# Output structured report
report_json = json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
if args.output == "-":
print(f"\n{'='*60}")
print(" Deployment Report")
print(f"{'='*60}")
print(report_json)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(report_json)
print(f"\nReport written to {args.output}", file=sys.stderr)
return 0 if report.overall_success else 1
if __name__ == "__main__":
sys.exit(main())
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""API Health Check — enumerates all registered routes and probes GET endpoints.
Usage:
python scripts/ai_health_check.py --base-url http://localhost:8000 --token <session-token>
python scripts/ai_health_check.py --base-url http://localhost:8000 --token <token> --output csv --report-file report.csv
Checks all GET endpoints with an auth token and reports status codes + response times.
POST/PUT/DELETE endpoints are documented but not executed (safe mode).
Exit codes:
0 — all probed endpoints healthy (2xx/3xx)
1 — one or more endpoints failed
2 — script error (misconfiguration, connection failure)
"""
from __future__ import annotations
import argparse
import csv
import io
import json
import os
import sys
import time
from typing import Any
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import httpx
# ─── Route Enumeration ────────────────────────────────────────────────
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def enumerate_routes() -> list[dict[str, Any]]:
"""Enumerate all registered API routes from the FastAPI app.
Imports the app and extracts route metadata without starting a server.
Returns a list of dicts: {path, methods, name, tags}.
"""
routes: list[dict[str, Any]] = []
try:
from app.main import app
for route in app.routes:
# Skip static mounts and SPA catch-all
if not hasattr(route, "methods") or not hasattr(route, "path"):
continue
path = route.path
# Only include API routes
if not path.startswith("/api/"):
continue
methods = sorted(route.methods - {"HEAD", "OPTIONS"}) if route.methods else []
if not methods:
continue
tags = getattr(route, "tags", []) or []
name = getattr(route, "name", "") or ""
routes.append({
"path": path,
"methods": methods,
"name": name,
"tags": tags,
})
except Exception as exc:
print(f"ERROR: Failed to enumerate routes: {exc}", file=sys.stderr)
# Return empty list — caller can still probe base_url manually
return routes
# ─── Endpoint Probing ────────────────────────────────────────────────
async def probe_all_endpoints(
base_url: str,
token: str,
routes: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Probe all GET endpoints and document non-GET ones."""
results: list[dict[str, Any]] = []
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(base_url=base_url.rstrip("/")) as client:
for route in routes:
path = route["path"]
for method in route["methods"]:
if method in SAFE_METHODS:
# Probe GET endpoints
start = time.perf_counter()
try:
response = await client.get(path, headers=headers, timeout=30.0)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
status_code = response.status_code
healthy = status_code < 400
results.append({
"endpoint": path,
"method": method,
"status": status_code,
"response_time_ms": elapsed_ms,
"healthy": healthy,
"tags": route.get("tags", []),
"note": "" if healthy else f"HTTP {status_code}",
})
except httpx.TimeoutException:
results.append({
"endpoint": path,
"method": method,
"status": "timeout",
"response_time_ms": round((time.perf_counter() - start) * 1000, 2),
"healthy": False,
"tags": route.get("tags", []),
"note": "Request timed out",
})
except httpx.ConnectError as exc:
results.append({
"endpoint": path,
"method": method,
"status": "connection_error",
"response_time_ms": round((time.perf_counter() - start) * 1000, 2),
"healthy": False,
"tags": route.get("tags", []),
"note": str(exc),
})
except Exception as exc:
results.append({
"endpoint": path,
"method": method,
"status": "error",
"response_time_ms": round((time.perf_counter() - start) * 1000, 2),
"healthy": False,
"tags": route.get("tags", []),
"note": str(exc),
})
else:
# Document POST/PUT/DELETE — do not execute
results.append({
"endpoint": path,
"method": method,
"status": "skipped",
"response_time_ms": 0,
"healthy": True,
"tags": route.get("tags", []),
"note": "Non-GET method — documented only",
})
return results
# ─── Report Formatting ───────────────────────────────────────────────
def format_json_report(results: list[dict[str, Any]]) -> str:
"""Format results as structured JSON report."""
total = len(results)
healthy = sum(1 for r in results if r["healthy"] and r["status"] != "skipped")
failed = sum(1 for r in results if not r["healthy"])
skipped = sum(1 for r in results if r["status"] == "skipped")
report = {
"summary": {
"total_endpoints": total,
"healthy": healthy,
"failed": failed,
"skipped": skipped,
"all_healthy": failed == 0,
},
"endpoints": results,
}
return json.dumps(report, indent=2, ensure_ascii=False)
def format_csv_report(results: list[dict[str, Any]]) -> str:
"""Format results as CSV."""
output = io.StringIO()
writer = csv.DictWriter(
output,
fieldnames=["endpoint", "method", "status", "response_time_ms", "healthy", "tags", "note"],
)
writer.writeheader()
for r in results:
writer.writerow({
"endpoint": r["endpoint"],
"method": r["method"],
"status": r["status"],
"response_time_ms": r["response_time_ms"],
"healthy": r["healthy"],
"tags": ",".join(r.get("tags", [])),
"note": r.get("note", ""),
})
return output.getvalue()
# ─── CLI Entry Point ─────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LeoCRM API Health Check — probes all GET endpoints and reports status."
)
parser.add_argument(
"--base-url",
default=os.environ.get("LEOCRM_BASE_URL", "http://localhost:8000"),
help="Base URL of the LeoCRM API (default: %(default)s)",
)
parser.add_argument(
"--token",
default=os.environ.get("LEOCRM_AUTH_TOKEN", ""),
help="Auth token (Bearer token) for authenticated requests",
)
parser.add_argument(
"--output",
choices=["json", "csv"],
default="json",
help="Output format (default: json)",
)
parser.add_argument(
"--report-file",
default="-",
help="Output file path (default: stdout)",
)
parser.add_argument(
"--enumerate-only",
action="store_true",
help="Only enumerate routes without probing (list all endpoints)",
)
args = parser.parse_args()
# Step 1: Enumerate routes
print(f"Enumerating routes from FastAPI app...", file=sys.stderr)
routes = enumerate_routes()
print(f"Found {len(routes)} API route(s)", file=sys.stderr)
if args.enumerate_only:
for route in routes:
methods = ", ".join(route["methods"])
tags = ", ".join(route.get("tags", []))
print(f" [{methods}] {route['path']} tags=[{tags}]")
return 0
if not args.token:
print("ERROR: --token is required for probing (or set LEOCRM_AUTH_TOKEN env)", file=sys.stderr)
return 2
# Step 2: Probe endpoints
import asyncio
print(f"Probing GET endpoints against {args.base_url}...", file=sys.stderr)
results = asyncio.run(probe_all_endpoints(args.base_url, args.token, routes))
# Step 3: Format and output report
if args.output == "json":
report = format_json_report(results)
else:
report = format_csv_report(results)
if args.report_file == "-":
print(report)
else:
with open(args.report_file, "w", encoding="utf-8") as f:
f.write(report)
print(f"Report written to {args.report_file}", file=sys.stderr)
# Step 4: Determine exit code
failed = sum(1 for r in results if not r["healthy"])
if failed > 0:
print(f"\n{failed} endpoint(s) failed!", file=sys.stderr)
return 1
print(f"\nAll endpoints healthy.", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""Automated Backup Script — pg_dump + file backup with retention policy.
Usage:
python scripts/backup.py
python scripts/backup.py --destination local
python scripts/backup.py --destination s3 --bucket my-backups
python scripts/backup.py --retention-days 30 --dry-run
Creates timestamped backups in /backups/ directory:
/backups/leocrm_backup_YYYYMMDD_HHMMSS/
├── database.sql (pg_dump output)
├── files/ (copied storage files)
└── manifest.json (backup metadata)
Environment variables:
DATABASE_URL — PostgreSQL connection string (asyncpg format, converted to psycopg2)
STORAGE_PATH — Path to files to backup (default: /data/storage)
BACKUP_DIR — Base backup directory (default: /backups)
BACKUP_RETENTION_DAYS — Days to keep backups (default: 7)
BACKUP_DESTINATION — local | s3 | nextcloud (default: local)
BACKUP_S3_BUCKET — S3 bucket name (if destination=s3)
BACKUP_NEXTCLOUD_URL — Nextcloud WebDAV URL (if destination=nextcloud)
Exit codes:
0 — backup successful
1 — backup failed
2 — configuration error
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ─── Backup Configuration ────────────────────────────────────────────
DEFAULT_BACKUP_DIR = os.environ.get("BACKUP_DIR", "/backups")
DEFAULT_STORAGE_PATH = os.environ.get("STORAGE_PATH", "/data/storage")
DEFAULT_RETENTION_DAYS = int(os.environ.get("BACKUP_RETENTION_DAYS", "7"))
def get_db_connection_params() -> dict[str, str]:
"""Extract PostgreSQL connection params from DATABASE_URL env var."""
db_url = os.environ.get("DATABASE_URL", "")
if not db_url:
raise ValueError("DATABASE_URL environment variable not set")
# Convert asyncpg URL to standard postgresql URL for pg_dump
if db_url.startswith("postgresql+asyncpg://"):
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://", 1)
# Parse the URL
from urllib.parse import urlparse
parsed = urlparse(db_url)
return {
"url": db_url,
"host": parsed.hostname or "localhost",
"port": str(parsed.port or 5432),
"database": parsed.path.lstrip("/"),
"username": parsed.username or "postgres",
"password": parsed.password or "",
}
def create_backup_manifest(
backup_dir: Path,
db_success: bool,
files_success: bool,
db_size: int = 0,
files_count: int = 0,
files_size: int = 0,
errors: list[str] | None = None,
) -> dict[str, Any]:
"""Create a manifest.json for the backup."""
return {
"backup_id": backup_dir.name,
"created_at": datetime.now(timezone.utc).isoformat(),
"database": {
"success": db_success,
"size_bytes": db_size,
},
"files": {
"success": files_success,
"count": files_count,
"size_bytes": files_size,
},
"errors": errors or [],
"version": "1.0.0",
}
# ─── Database Backup ─────────────────────────────────────────────────
def backup_database(backup_dir: Path, db_params: dict[str, str]) -> tuple[bool, int, str]:
"""Run pg_dump and save to backup directory.
Returns (success, size_bytes, error_message).
"""
db_file = backup_dir / "database.sql"
# Build pg_dump command
cmd = [
"pg_dump",
"--host", db_params["host"],
"--port", db_params["port"],
"--username", db_params["username"],
"--format", "plain",
"--no-owner",
"--no-privileges",
db_params["database"],
]
# Set PGPASSWORD environment for pg_dump
env = os.environ.copy()
if db_params["password"]:
env["PGPASSWORD"] = db_params["password"]
try:
with open(db_file, "w") as f:
result = subprocess.run(
cmd,
stdout=f,
stderr=subprocess.PIPE,
env=env,
timeout=600,
)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "pg_dump failed"
return False, 0, error_msg
size = db_file.stat().st_size
return True, size, ""
except subprocess.TimeoutExpired:
return False, 0, "pg_dump timed out after 600s"
except FileNotFoundError:
return False, 0, "pg_dump command not found"
except Exception as exc:
return False, 0, str(exc)
# ─── File Backup ─────────────────────────────────────────────────────
def backup_files(backup_dir: Path, storage_path: str) -> tuple[bool, int, int, str]:
"""Copy storage files to backup directory.
Returns (success, file_count, total_size_bytes, error_message).
"""
files_dir = backup_dir / "files"
if not os.path.isdir(storage_path):
# Storage path doesn't exist — not necessarily an error
return True, 0, 0, ""
try:
files_dir.mkdir(parents=True, exist_ok=True)
file_count = 0
total_size = 0
for root, dirs, files in os.walk(storage_path):
rel_root = os.path.relpath(root, storage_path)
dest_root = files_dir / rel_root if rel_root != "." else files_dir
dest_root.mkdir(parents=True, exist_ok=True)
for fname in files:
src_file = os.path.join(root, fname)
dest_file = dest_root / fname
shutil.copy2(src_file, dest_file)
file_count += 1
total_size += os.path.getsize(src_file)
return True, file_count, total_size, ""
except Exception as exc:
return False, 0, 0, str(exc)
# ─── Retention Policy ────────────────────────────────────────────────
def apply_retention_policy(backup_base_dir: Path, retention_days: int) -> list[str]:
"""Delete backups older than retention_days.
Returns list of deleted backup directory names.
"""
if retention_days <= 0:
return []
deleted = []
cutoff_time = time.time() - (retention_days * 86400)
if not backup_base_dir.is_dir():
return deleted
for entry in backup_base_dir.iterdir():
if not entry.is_dir():
continue
if not entry.name.startswith("leocrm_backup_"):
continue
try:
entry_mtime = entry.stat().st_mtime
if entry_mtime < cutoff_time:
shutil.rmtree(entry)
deleted.append(entry.name)
except Exception:
pass # Don't fail on retention errors
return deleted
# ─── Notification ────────────────────────────────────────────────────
def send_backup_notification(
success: bool,
backup_dir: Path,
errors: list[str],
dry_run: bool = False,
) -> None:
"""Send backup status notification via system_notif plugin event bus.
This publishes a 'notification.created' event that the system_notif
plugin picks up and converts to a chat message.
"""
if dry_run:
return
try:
import asyncio
from app.core.event_bus import get_event_bus
event_type = "backup.completed" if success else "backup.failed"
title = "Backup erfolgreich" if success else "Backup fehlgeschlagen"
body = f"Backup: {backup_dir.name}"
if errors:
body += f"\nFehler: {'; '.join(errors[:3])}"
async def _publish():
bus = get_event_bus()
await bus.publish("notification.created", {
"title": title,
"body": body,
"event_type": event_type,
"severity": "info" if success else "error",
"tenant_id": os.environ.get("BACKUP_TENANT_ID", ""),
"user_id": os.environ.get("BACKUP_USER_ID", ""),
})
asyncio.run(_publish())
except Exception:
# Notification is best-effort — don't fail backup on notification error
pass
# ─── Main Backup Pipeline ────────────────────────────────────────────
def run_backup(
destination: str = "local",
retention_days: int = DEFAULT_RETENTION_DAYS,
storage_path: str = DEFAULT_STORAGE_PATH,
backup_base_dir: str = DEFAULT_BACKUP_DIR,
dry_run: bool = False,
) -> dict[str, Any]:
"""Run the full backup pipeline and return a report."""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
backup_name = f"leocrm_backup_{timestamp}"
backup_dir = Path(backup_base_dir) / backup_name
report: dict[str, Any] = {
"backup_id": backup_name,
"backup_path": str(backup_dir),
"destination": destination,
"dry_run": dry_run,
"started_at": datetime.now(timezone.utc).isoformat(),
"database": {"success": False, "size_bytes": 0, "error": ""},
"files": {"success": False, "count": 0, "size_bytes": 0, "error": ""},
"retention": {"deleted": []},
"overall_success": False,
"errors": [],
}
if dry_run:
report["database"]["error"] = "[DRY-RUN] Would run pg_dump"
report["files"]["error"] = "[DRY-RUN] Would copy files"
report["overall_success"] = True
report["finished_at"] = datetime.now(timezone.utc).isoformat()
return report
# Create backup directory
backup_dir.mkdir(parents=True, exist_ok=True)
# Step 1: Database backup
print(f"▶ Step 1: Database backup (pg_dump)")
try:
db_params = get_db_connection_params()
db_success, db_size, db_error = backup_database(backup_dir, db_params)
report["database"] = {"success": db_success, "size_bytes": db_size, "error": db_error}
if db_success:
print(f" ✓ Database backup: {db_size} bytes")
else:
print(f" ✗ Database backup failed: {db_error}")
report["errors"].append(f"Database: {db_error}")
except Exception as exc:
report["database"]["error"] = str(exc)
report["errors"].append(f"Database config: {exc}")
print(f" ✗ Database config error: {exc}")
# Step 2: File backup
print(f"▶ Step 2: File backup ({storage_path})")
files_success, files_count, files_size, files_error = backup_files(backup_dir, storage_path)
report["files"] = {
"success": files_success,
"count": files_count,
"size_bytes": files_size,
"error": files_error,
}
if files_success:
print(f" ✓ File backup: {files_count} files, {files_size} bytes")
else:
print(f" ✗ File backup failed: {files_error}")
report["errors"].append(f"Files: {files_error}")
# Step 3: Write manifest
manifest = create_backup_manifest(
backup_dir,
db_success=report["database"]["success"],
files_success=report["files"]["success"],
db_size=report["database"]["size_bytes"],
files_count=report["files"]["count"],
files_size=report["files"]["size_bytes"],
errors=report["errors"],
)
manifest_file = backup_dir / "manifest.json"
with open(manifest_file, "w") as f:
json.dump(manifest, f, indent=2)
print(f" ✓ Manifest written: {manifest_file}")
# Step 4: Apply retention policy
print(f"▶ Step 3: Retention policy ({retention_days} days)")
deleted = apply_retention_policy(Path(backup_base_dir), retention_days)
report["retention"]["deleted"] = deleted
if deleted:
print(f" ✓ Deleted {len(deleted)} old backup(s)")
else:
print(f" ✓ No old backups to delete")
# Step 5: Determine overall success
report["overall_success"] = report["database"]["success"] and report["files"]["success"]
report["finished_at"] = datetime.now(timezone.utc).isoformat()
# Step 6: Send notification
send_backup_notification(
success=report["overall_success"],
backup_dir=backup_dir,
errors=report["errors"],
dry_run=dry_run,
)
return report
# ─── CLI Entry Point ─────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LeoCRM Automated Backup — pg_dump + file backup with retention."
)
parser.add_argument(
"--destination",
choices=["local", "s3", "nextcloud"],
default=os.environ.get("BACKUP_DESTINATION", "local"),
help="Backup destination (default: local)",
)
parser.add_argument(
"--retention-days",
type=int,
default=DEFAULT_RETENTION_DAYS,
help=f"Days to keep backups (default: {DEFAULT_RETENTION_DAYS})",
)
parser.add_argument(
"--storage-path",
default=DEFAULT_STORAGE_PATH,
help=f"Path to storage files (default: {DEFAULT_STORAGE_PATH})",
)
parser.add_argument(
"--backup-dir",
default=DEFAULT_BACKUP_DIR,
help=f"Base backup directory (default: {DEFAULT_BACKUP_DIR})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate backup without executing",
)
parser.add_argument(
"--output",
default="-",
help="Write JSON report to file (default: stdout)",
)
args = parser.parse_args()
print(f"\n{'='*60}")
print(f" LeoCRM Backup Pipeline")
print(f" {'[DRY-RUN]' if args.dry_run else '[LIVE]'}")
print(f"{'='*60}\n")
report = run_backup(
destination=args.destination,
retention_days=args.retention_days,
storage_path=args.storage_path,
backup_base_dir=args.backup_dir,
dry_run=args.dry_run,
)
report_json = json.dumps(report, indent=2, ensure_ascii=False)
if args.output == "-":
print(f"\n{'='*60}")
print(" Backup Report")
print(f"{'='*60}")
print(report_json)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(report_json)
print(f"\nReport written to {args.output}", file=sys.stderr)
return 0 if report["overall_success"] else 1
if __name__ == "__main__":
sys.exit(main())
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Restore Script — restore LeoCRM from a backup directory.
Usage:
python scripts/restore.py --backup-dir /backups/leocrm_backup_20260723_220000
python scripts/restore.py --backup-dir /backups/leocrm_backup_20260723_220000 --skip-db
python scripts/restore.py --backup-dir /backups/leocrm_backup_20260723_220000 --skip-files
python scripts/restore.py --backup-dir /backups/leocrm_backup_20260723_220000 --dry-run
Restores:
1. Database from database.sql (psql)
2. Files from files/ directory (shutil copy)
Environment variables:
DATABASE_URL — PostgreSQL connection string
STORAGE_PATH — Target path for file restoration (default: /data/storage)
Exit codes:
0 — restore successful
1 — restore failed
2 — configuration error
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DEFAULT_STORAGE_PATH = os.environ.get("STORAGE_PATH", "/data/storage")
def get_db_connection_params() -> dict[str, str]:
"""Extract PostgreSQL connection params from DATABASE_URL env var."""
db_url = os.environ.get("DATABASE_URL", "")
if not db_url:
raise ValueError("DATABASE_URL environment variable not set")
if db_url.startswith("postgresql+asyncpg://"):
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://", 1)
from urllib.parse import urlparse
parsed = urlparse(db_url)
return {
"url": db_url,
"host": parsed.hostname or "localhost",
"port": str(parsed.port or 5432),
"database": parsed.path.lstrip("/"),
"username": parsed.username or "postgres",
"password": parsed.password or "",
}
def restore_database(backup_dir: Path, db_params: dict[str, str]) -> tuple[bool, str]:
"""Restore database from database.sql using psql.
Returns (success, error_message).
"""
db_file = backup_dir / "database.sql"
if not db_file.is_file():
return False, f"Database dump not found: {db_file}"
cmd = [
"psql",
"--host", db_params["host"],
"--port", db_params["port"],
"--username", db_params["username"],
"--dbname", db_params["database"],
"--file", str(db_file),
"--quiet",
]
env = os.environ.copy()
if db_params["password"]:
env["PGPASSWORD"] = db_params["password"]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=600,
)
if result.returncode != 0:
return False, result.stderr or "psql failed"
return True, ""
except subprocess.TimeoutExpired:
return False, "psql timed out after 600s"
except FileNotFoundError:
return False, "psql command not found"
except Exception as exc:
return False, str(exc)
def restore_files(backup_dir: Path, target_path: str) -> tuple[bool, int, int, str]:
"""Restore files from backup files/ directory to target path.
Returns (success, file_count, total_size_bytes, error_message).
"""
files_dir = backup_dir / "files"
if not files_dir.is_dir():
return True, 0, 0, "" # No files in backup — not an error
try:
os.makedirs(target_path, exist_ok=True)
file_count = 0
total_size = 0
for root, dirs, files in os.walk(files_dir):
rel_root = os.path.relpath(root, files_dir)
dest_root = os.path.join(target_path, rel_root) if rel_root != "." else target_path
os.makedirs(dest_root, exist_ok=True)
for fname in files:
src_file = os.path.join(root, fname)
dest_file = os.path.join(dest_root, fname)
shutil.copy2(src_file, dest_file)
file_count += 1
total_size += os.path.getsize(src_file)
return True, file_count, total_size, ""
except Exception as exc:
return False, 0, 0, str(exc)
def validate_backup_dir(backup_dir: Path) -> dict[str, Any] | None:
"""Validate that backup directory has a manifest.json."""
manifest_file = backup_dir / "manifest.json"
if not manifest_file.is_file():
return None
try:
with open(manifest_file) as f:
return json.load(f)
except Exception:
return None
def run_restore(
backup_dir_path: str,
target_storage_path: str,
skip_db: bool = False,
skip_files: bool = False,
dry_run: bool = False,
) -> dict[str, Any]:
"""Run the full restore pipeline and return a report."""
backup_dir = Path(backup_dir_path)
report: dict[str, Any] = {
"backup_dir": str(backup_dir),
"dry_run": dry_run,
"started_at": datetime.now(timezone.utc).isoformat(),
"database": {"success": False, "error": ""},
"files": {"success": False, "count": 0, "size_bytes": 0, "error": ""},
"overall_success": False,
"errors": [],
}
# Validate backup directory
if not backup_dir.is_dir():
report["errors"].append(f"Backup directory not found: {backup_dir}")
report["finished_at"] = datetime.now(timezone.utc).isoformat()
return report
manifest = validate_backup_dir(backup_dir)
if manifest:
report["manifest"] = manifest
print(f" Backup from: {manifest.get('created_at', 'unknown')}")
else:
print(f" Warning: No manifest.json found in {backup_dir}")
if dry_run:
report["database"]["error"] = "[DRY-RUN] Would restore database"
report["files"]["error"] = "[DRY-RUN] Would restore files"
report["overall_success"] = True
report["finished_at"] = datetime.now(timezone.utc).isoformat()
return report
# Step 1: Database restore
if not skip_db:
print("▶ Step 1: Database restore (psql)")
try:
db_params = get_db_connection_params()
db_success, db_error = restore_database(backup_dir, db_params)
report["database"] = {"success": db_success, "error": db_error}
if db_success:
print(" ✓ Database restored")
else:
print(f" ✗ Database restore failed: {db_error}")
report["errors"].append(f"Database: {db_error}")
except Exception as exc:
report["database"]["error"] = str(exc)
report["errors"].append(f"Database config: {exc}")
print(f" ✗ Database config error: {exc}")
else:
print("▶ Step 1: Database restore — SKIPPED")
report["database"] = {"success": True, "error": "skipped"}
# Step 2: File restore
if not skip_files:
print(f"▶ Step 2: File restore → {target_storage_path}")
files_success, files_count, files_size, files_error = restore_files(backup_dir, target_storage_path)
report["files"] = {
"success": files_success,
"count": files_count,
"size_bytes": files_size,
"error": files_error,
}
if files_success:
print(f" ✓ Files restored: {files_count} files, {files_size} bytes")
else:
print(f" ✗ File restore failed: {files_error}")
report["errors"].append(f"Files: {files_error}")
else:
print("▶ Step 2: File restore — SKIPPED")
report["files"] = {"success": True, "count": 0, "size_bytes": 0, "error": "skipped"}
# Determine overall success
report["overall_success"] = report["database"]["success"] and report["files"]["success"]
report["finished_at"] = datetime.now(timezone.utc).isoformat()
return report
# ─── CLI Entry Point ─────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LeoCRM Restore — restore database and files from a backup directory."
)
parser.add_argument(
"--backup-dir",
required=True,
help="Path to the backup directory (e.g. /backups/leocrm_backup_20260723_220000)",
)
parser.add_argument(
"--storage-path",
default=DEFAULT_STORAGE_PATH,
help=f"Target path for file restoration (default: {DEFAULT_STORAGE_PATH})",
)
parser.add_argument(
"--skip-db",
action="store_true",
help="Skip database restoration",
)
parser.add_argument(
"--skip-files",
action="store_true",
help="Skip file restoration",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate restore without executing",
)
parser.add_argument(
"--output",
default="-",
help="Write JSON report to file (default: stdout)",
)
args = parser.parse_args()
print(f"\n{'='*60}")
print(f" LeoCRM Restore Pipeline")
print(f" {'[DRY-RUN]' if args.dry_run else '[LIVE]'}")
print(f"{'='*60}\n")
report = run_restore(
backup_dir_path=args.backup_dir,
target_storage_path=args.storage_path,
skip_db=args.skip_db,
skip_files=args.skip_files,
dry_run=args.dry_run,
)
report_json = json.dumps(report, indent=2, ensure_ascii=False)
if args.output == "-":
print(f"\n{'='*60}")
print(" Restore Report")
print(f"{'='*60}")
print(report_json)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(report_json)
print(f"\nReport written to {args.output}", file=sys.stderr)
return 0 if report["overall_success"] else 1
if __name__ == "__main__":
sys.exit(main())
+132
View File
@@ -0,0 +1,132 @@
"""Tests for the AI deploy script (scripts/ai_deploy.py)."""
from __future__ import annotations
import json
import os
import sys
from unittest.mock import patch
import pytest
# Add scripts dir to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
def test_phase_result_to_dict():
"""PhaseResult.to_dict should serialize correctly."""
from ai_deploy import PhaseResult
result = PhaseResult(name="build", success=True, duration_s=1.5, output="ok")
d = result.to_dict()
assert d["phase"] == "build"
assert d["success"] is True
assert d["duration_s"] == 1.5
assert d["output"] == "ok"
def test_deploy_report_to_dict():
"""DeployReport.to_dict should include phases and rollback info."""
from ai_deploy import DeployReport, PhaseResult
report = DeployReport(
started_at="2026-01-01T00:00:00Z",
finished_at="2026-01-01T00:05:00Z",
overall_success=True,
)
report.phases.append(PhaseResult(name="build", success=True))
d = report.to_dict()
assert d["overall_success"] is True
assert len(d["phases"]) == 1
assert d["rollback_performed"] is False
def test_phase_build_skip():
"""phase_build should skip when --skip-build is set."""
from ai_deploy import phase_build
result = phase_build(skip_build=True, dry_run=False)
assert result.success is True
assert "skipped" in result.output.lower()
def test_phase_build_dry_run():
"""phase_build should return dry-run message in dry-run mode."""
from ai_deploy import phase_build
result = phase_build(skip_build=False, dry_run=True)
assert result.success is True
assert "DRY-RUN" in result.output
def test_phase_tests_skip():
"""phase_tests should skip when --skip-tests is set."""
from ai_deploy import phase_tests
result = phase_tests(skip_tests=True, dry_run=False)
assert result.success is True
assert "skipped" in result.output.lower()
def test_phase_tests_dry_run():
"""phase_tests should return dry-run message in dry-run mode."""
from ai_deploy import phase_tests
result = phase_tests(skip_tests=False, dry_run=True)
assert result.success is True
assert "DRY-RUN" in result.output
def test_phase_deploy_missing_env():
"""phase_deploy should fail when env vars are missing."""
from ai_deploy import phase_deploy
# Ensure env vars are not set
with patch.dict(os.environ, {}, clear=True):
result = phase_deploy(dry_run=False)
assert result.success is False
assert "COOLIFY" in result.error
def test_phase_deploy_dry_run_with_env():
"""phase_deploy should succeed in dry-run mode with env vars set."""
from ai_deploy import phase_deploy
with patch.dict(os.environ, {"COOLIFY_API_TOKEN": "test", "COOLIFY_APP_UUID": "uuid-123"}):
result = phase_deploy(dry_run=True)
assert result.success is True
assert "DRY-RUN" in result.output
assert "uuid-123" in result.output
def test_phase_rollback_missing_env():
"""phase_rollback should fail when env vars are missing."""
from ai_deploy import phase_rollback
with patch.dict(os.environ, {}, clear=True):
result = phase_rollback(dry_run=False)
assert result.success is False
assert "COOLIFY" in result.error
def test_run_pipeline_dry_run_all_skip():
"""Full pipeline in dry-run with all skips should succeed with env vars."""
from ai_deploy import run_pipeline
with patch.dict(os.environ, {"COOLIFY_API_TOKEN": "test", "COOLIFY_APP_UUID": "uuid-123"}):
report = run_pipeline(skip_build=True, skip_tests=True, dry_run=True)
assert report.overall_success is True
assert len(report.phases) == 3 # build, tests, deploy
assert all(p.success for p in report.phases)
assert report.rollback_performed is False
def test_run_pipeline_deploy_fail_triggers_rollback():
"""Pipeline should trigger rollback when deploy fails."""
from ai_deploy import run_pipeline
with patch.dict(os.environ, {}, clear=True):
report = run_pipeline(skip_build=True, skip_tests=True, dry_run=False)
assert report.overall_success is False
assert report.rollback_performed is True
assert len(report.phases) == 4 # build, tests, deploy, rollback
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the AI health check script (scripts/ai_health_check.py)."""
from __future__ import annotations
import json
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Add scripts dir to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
def test_enumerate_routes_returns_api_routes():
"""enumerate_routes should return only /api/ routes with methods."""
from ai_health_check import enumerate_routes
routes = enumerate_routes()
assert isinstance(routes, list)
assert len(routes) > 0
for route in routes:
assert route["path"].startswith("/api/")
assert isinstance(route["methods"], list)
assert len(route["methods"]) > 0
def test_enumerate_routes_excludes_non_api():
"""enumerate_routes should not include non-API paths like /docs or /openapi.json."""
from ai_health_check import enumerate_routes
routes = enumerate_routes()
paths = [r["path"] for r in routes]
assert not any(p.startswith("/docs") for p in paths)
assert not any(p.startswith("/openapi") for p in paths)
def test_format_json_report_structure():
"""JSON report should have summary and endpoints keys."""
from ai_health_check import format_json_report
results = [
{"endpoint": "/api/v1/health", "method": "GET", "status": 200, "response_time_ms": 5.0, "healthy": True, "tags": ["health"], "note": ""},
{"endpoint": "/api/v1/users", "method": "POST", "status": "skipped", "response_time_ms": 0, "healthy": True, "tags": ["users"], "note": "Non-GET method — documented only"},
]
report = json.loads(format_json_report(results))
assert "summary" in report
assert "endpoints" in report
assert report["summary"]["total_endpoints"] == 2
assert report["summary"]["healthy"] == 1
assert report["summary"]["skipped"] == 1
assert report["summary"]["all_healthy"] is True
def test_format_json_report_with_failures():
"""JSON report should mark all_healthy=False when failures exist."""
from ai_health_check import format_json_report
results = [
{"endpoint": "/api/v1/health", "method": "GET", "status": 200, "response_time_ms": 5.0, "healthy": True, "tags": [], "note": ""},
{"endpoint": "/api/v1/broken", "method": "GET", "status": 500, "response_time_ms": 10.0, "healthy": False, "tags": [], "note": "HTTP 500"},
]
report = json.loads(format_json_report(results))
assert report["summary"]["failed"] == 1
assert report["summary"]["all_healthy"] is False
def test_format_csv_report_has_header():
"""CSV report should have a header row with expected columns."""
from ai_health_check import format_csv_report
results = [
{"endpoint": "/api/v1/health", "method": "GET", "status": 200, "response_time_ms": 5.0, "healthy": True, "tags": ["health"], "note": ""},
]
csv_output = format_csv_report(results)
lines = csv_output.strip().split("\n")
assert "endpoint" in lines[0]
assert "method" in lines[0]
assert "status" in lines[0]
assert "/api/v1/health" in lines[1]
@pytest.mark.asyncio
async def test_probe_all_endpoints_skips_non_get():
"""probe_all_endpoints should skip POST/PUT/DELETE methods."""
from ai_health_check import probe_all_endpoints
routes = [
{"path": "/api/v1/users", "methods": ["POST"], "tags": ["users"]},
]
results = await probe_all_endpoints("http://localhost:8000", "fake-token", routes)
assert len(results) == 1
assert results[0]["status"] == "skipped"
assert results[0]["healthy"] is True
@pytest.mark.asyncio
async def test_probe_all_endpoints_handles_connection_error():
"""probe_all_endpoints should handle connection errors gracefully."""
from ai_health_check import probe_all_endpoints
routes = [
{"path": "/api/v1/health", "methods": ["GET"], "tags": ["health"]},
]
results = await probe_all_endpoints("http://nonexistent-host:9999", "fake-token", routes)
assert len(results) == 1
assert results[0]["healthy"] is False
assert results[0]["status"] in ("connection_error", "error")
+129
View File
@@ -0,0 +1,129 @@
"""Tests for OpenAPI documentation completeness (Task 5.14)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def test_openapi_tags_configured():
"""FastAPI app should have openapi_tags configured with descriptions."""
from app.main import app
assert app.openapi_tags is not None
assert len(app.openapi_tags) > 0
tag_names = {t["name"] for t in app.openapi_tags}
# Core tags should be present
assert "health" in tag_names
assert "auth" in tag_names
assert "users" in tag_names
assert "contacts" in tag_names
assert "system-settings" in tag_names
def test_openapi_tags_have_descriptions():
"""Each openapi_tag should have a description."""
from app.main import app
for tag in app.openapi_tags:
assert "description" in tag
assert len(tag["description"]) > 0
def test_app_has_description():
"""FastAPI app should have a description for OpenAPI docs."""
from app.main import app
assert app.description is not None
assert len(app.description) > 0
assert "LeoCRM" in app.description or "CRM" in app.description
def test_all_api_routes_have_tags():
"""All API routes should have at least one tag for OpenAPI grouping."""
from app.main import app
untagged = []
for route in app.routes:
if not hasattr(route, "methods") or not hasattr(route, "path"):
continue
if not route.path.startswith("/api/"):
continue
methods = route.methods - {"HEAD", "OPTIONS"} if route.methods else set()
if not methods:
continue
tags = getattr(route, "tags", []) or []
if not tags:
untagged.append(f"{route.path} [{','.join(sorted(methods))}]")
assert len(untagged) == 0, f"Routes without tags: {untagged}"
def test_key_routes_have_response_model():
"""Key routes should have response_model set for OpenAPI schema generation."""
from app.main import app
# Check that at least some routes have response_model
routes_with_response_model = 0
for route in app.routes:
if not hasattr(route, "methods") or not hasattr(route, "path"):
continue
if not route.path.startswith("/api/"):
continue
response_model = getattr(route, "response_model", None)
if response_model is not None:
routes_with_response_model += 1
assert routes_with_response_model >= 5, (
f"Expected at least 5 routes with response_model, got {routes_with_response_model}"
)
def test_auth_login_has_auth_response_model():
"""POST /api/v1/auth/login should have AuthResponse as response_model."""
from app.main import app
from app.schemas.auth import AuthResponse
for route in app.routes:
if (
hasattr(route, "path")
and route.path == "/api/v1/auth/login"
and hasattr(route, "response_model")
):
assert route.response_model == AuthResponse
return
pytest.fail("Route /api/v1/auth/login not found")
def test_health_has_health_response_model():
"""GET /api/v1/health should have HealthResponse as response_model."""
from app.main import app
from app.schemas.common import HealthResponse
for route in app.routes:
if (
hasattr(route, "path")
and route.path == "/api/v1/health"
and hasattr(route, "response_model")
):
assert route.response_model == HealthResponse
return
pytest.fail("Route /api/v1/health not found")
def test_api_documentation_md_exists():
"""docs/api-documentation.md should exist and have content."""
doc_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"docs",
"api-documentation.md",
)
assert os.path.isfile(doc_path), "docs/api-documentation.md not found"
with open(doc_path) as f:
content = f.read()
assert "LeoCRM API Documentation" in content
assert "auth" in content.lower()
assert "contacts" in content.lower()
assert "plugins" in content.lower()
+240
View File
@@ -0,0 +1,240 @@
"""Tests for backup and restore scripts (scripts/backup.py, scripts/restore.py)."""
from __future__ import annotations
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
# Add scripts dir to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
def test_get_db_connection_params_from_asyncpg_url():
"""get_db_connection_params should convert asyncpg URL to standard postgresql URL."""
from backup import get_db_connection_params
with patch.dict(os.environ, {
"DATABASE_URL": "postgresql+asyncpg://user:pass@localhost:5432/leocrm"
}):
params = get_db_connection_params()
assert params["host"] == "localhost"
assert params["port"] == "5432"
assert params["database"] == "leocrm"
assert params["username"] == "user"
assert params["password"] == "pass"
def test_get_db_connection_params_missing_env():
"""get_db_connection_params should raise ValueError when DATABASE_URL is missing."""
from backup import get_db_connection_params
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="DATABASE_URL"):
get_db_connection_params()
def test_create_backup_manifest_structure():
"""create_backup_manifest should return a valid manifest dict."""
from backup import create_backup_manifest
manifest = create_backup_manifest(
Path("/tmp/test_backup"),
db_success=True,
files_success=True,
db_size=1024,
files_count=5,
files_size=2048,
)
assert manifest["backup_id"] == "test_backup"
assert manifest["database"]["success"] is True
assert manifest["database"]["size_bytes"] == 1024
assert manifest["files"]["count"] == 5
assert manifest["version"] == "1.0.0"
def test_backup_files_nonexistent_path():
"""backup_files should return success when storage path doesn't exist."""
from backup import backup_files
with tempfile.TemporaryDirectory() as tmpdir:
success, count, size, error = backup_files(Path(tmpdir), "/nonexistent/path")
assert success is True
assert count == 0
assert size == 0
def test_backup_files_copies_files():
"""backup_files should copy files from storage to backup directory."""
from backup import backup_files
with tempfile.TemporaryDirectory() as storage_dir:
# Create test files
(Path(storage_dir) / "file1.txt").write_text("hello")
(Path(storage_dir) / "subdir").mkdir()
(Path(storage_dir) / "subdir" / "file2.txt").write_text("world")
with tempfile.TemporaryDirectory() as backup_dir:
success, count, size, error = backup_files(Path(backup_dir), storage_dir)
assert success is True
assert count == 2
assert size == 10 # "hello" (5) + "world" (5)
def test_apply_retention_policy_deletes_old_backups():
"""apply_retention_policy should delete backups older than retention_days."""
import time
from backup import apply_retention_policy
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
# Create old backup (modify mtime to past)
old_backup = base / "leocrm_backup_20200101_000000"
old_backup.mkdir()
(old_backup / "database.sql").write_text("old")
old_time = time.time() - (30 * 86400) # 30 days ago
os.utime(old_backup, (old_time, old_time))
# Create recent backup
recent_backup = base / "leocrm_backup_20260723_220000"
recent_backup.mkdir()
(recent_backup / "database.sql").write_text("recent")
deleted = apply_retention_policy(base, retention_days=7)
assert len(deleted) == 1
assert "leocrm_backup_20200101_000000" in deleted[0]
assert recent_backup.exists()
def test_apply_retention_policy_zero_days_keeps_all():
"""apply_retention_policy with 0 days should keep all backups."""
from backup import apply_retention_policy
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
(base / "leocrm_backup_20200101_000000").mkdir()
deleted = apply_retention_policy(base, retention_days=0)
assert len(deleted) == 0
def test_run_backup_dry_run():
"""run_backup in dry-run mode should return success without executing."""
from backup import run_backup
with tempfile.TemporaryDirectory() as tmpdir:
report = run_backup(
destination="local",
retention_days=7,
storage_path="/tmp",
backup_base_dir=tmpdir,
dry_run=True,
)
assert report["dry_run"] is True
assert report["overall_success"] is True
assert "DRY-RUN" in report["database"]["error"]
def test_restore_files_copies_files():
"""restore_files should copy files from backup to target path."""
from restore import restore_files
with tempfile.TemporaryDirectory() as backup_dir:
# Create backup files
files_dir = Path(backup_dir) / "files"
files_dir.mkdir()
(files_dir / "file1.txt").write_text("hello")
(files_dir / "subdir").mkdir()
(files_dir / "subdir" / "file2.txt").write_text("world")
with tempfile.TemporaryDirectory() as target_dir:
success, count, size, error = restore_files(Path(backup_dir), target_dir)
assert success is True
assert count == 2
assert size == 10
def test_restore_files_no_files_dir():
"""restore_files should return success when no files/ directory exists."""
from restore import restore_files
with tempfile.TemporaryDirectory() as backup_dir:
with tempfile.TemporaryDirectory() as target_dir:
success, count, size, error = restore_files(Path(backup_dir), target_dir)
assert success is True
assert count == 0
def test_validate_backup_dir_with_manifest():
"""validate_backup_dir should return manifest dict when manifest.json exists."""
from restore import validate_backup_dir
with tempfile.TemporaryDirectory() as tmpdir:
manifest = {"backup_id": "test", "version": "1.0.0"}
(Path(tmpdir) / "manifest.json").write_text(json.dumps(manifest))
result = validate_backup_dir(Path(tmpdir))
assert result is not None
assert result["backup_id"] == "test"
def test_validate_backup_dir_without_manifest():
"""validate_backup_dir should return None when manifest.json doesn't exist."""
from restore import validate_backup_dir
with tempfile.TemporaryDirectory() as tmpdir:
result = validate_backup_dir(Path(tmpdir))
assert result is None
def test_run_restore_nonexistent_dir():
"""run_restore should report error when backup directory doesn't exist."""
from restore import run_restore
report = run_restore(
backup_dir_path="/nonexistent/backup/dir",
target_storage_path="/tmp",
dry_run=False,
)
assert report["overall_success"] is False
assert any("not found" in e for e in report["errors"])
def test_run_restore_dry_run():
"""run_restore in dry-run mode should return success without executing."""
from restore import run_restore
with tempfile.TemporaryDirectory() as tmpdir:
report = run_restore(
backup_dir_path=tmpdir,
target_storage_path="/tmp",
dry_run=True,
)
assert report["dry_run"] is True
assert report["overall_success"] is True
def test_system_notif_has_backup_events():
"""system_notif plugin should register backup.completed and backup.failed events."""
from app.plugins.builtins.system_notif.plugin import SystemNotifPlugin
events = SystemNotifPlugin.manifest.events
assert "backup.completed" in events
assert "backup.failed" in events
def test_system_settings_has_backup_config():
"""SystemSettingsUpsert should have backup configuration fields."""
from app.schemas.system_settings import SystemSettingsUpsert
fields = SystemSettingsUpsert.model_fields
assert "backup_interval" in fields
assert "backup_retention_days" in fields
assert "backup_destination" in fields