Compare commits
5 Commits
42b19040ce
...
f4beb78f91
| Author | SHA1 | Date | |
|---|---|---|---|
| f4beb78f91 | |||
| 9cfc6bf3b0 | |||
| 66b6c32ed8 | |||
| 3c1b2f227b | |||
| d9c9ba6630 |
+46
@@ -249,3 +249,49 @@ Siehe `MASTER-PLAN.md` für alle Tasks.
|
|||||||
|
|
||||||
### Modifizierte Dateien Phase 5 Batch 2
|
### Modifizierte Dateien Phase 5 Batch 2
|
||||||
- `frontend/package.json` — @playwright/test devDependency + e2e scripts
|
- `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
@@ -212,7 +212,63 @@ async def lifespan(app: FastAPI):
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
settings = get_settings()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.plugins.builtins.ai_ui_control.schemas import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["ai-ui-control"])
|
||||||
|
|
||||||
|
|
||||||
# ─── REST endpoints (for AI agents) ───
|
# ─── REST endpoints (for AI agents) ───
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class SystemNotifPlugin(BasePlugin):
|
|||||||
"user.created",
|
"user.created",
|
||||||
"workflow.completed",
|
"workflow.completed",
|
||||||
"notification.created",
|
"notification.created",
|
||||||
|
"backup.completed",
|
||||||
|
"backup.failed",
|
||||||
],
|
],
|
||||||
migrations=[],
|
migrations=[],
|
||||||
permissions=["system_notif:read"],
|
permissions=["system_notif:read"],
|
||||||
@@ -112,6 +114,14 @@ class SystemNotifPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
await self._create_system_notification(payload, event_type="notification.created")
|
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(
|
async def _create_system_notification(
|
||||||
self,
|
self,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
@@ -155,6 +165,8 @@ class SystemNotifPlugin(BasePlugin):
|
|||||||
"user.created": "Neuer Benutzer",
|
"user.created": "Neuer Benutzer",
|
||||||
"workflow.completed": "Workflow abgeschlossen",
|
"workflow.completed": "Workflow abgeschlossen",
|
||||||
"notification.created": "Benachrichtigung",
|
"notification.created": "Benachrichtigung",
|
||||||
|
"backup.completed": "Backup erfolgreich",
|
||||||
|
"backup.failed": "Backup fehlgeschlagen",
|
||||||
}
|
}
|
||||||
title = event_titles.get(event_type, event_type)
|
title = event_titles.get(event_type, event_type)
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -12,7 +12,9 @@ from app.core.auth import get_redis
|
|||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
|
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
|
AuthResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
MessageResponse,
|
||||||
PasswordResetConfirm,
|
PasswordResetConfirm,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
SwitchTenantRequest,
|
SwitchTenantRequest,
|
||||||
@@ -24,7 +26,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login", response_model=AuthResponse)
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: LoginRequest,
|
body: LoginRequest,
|
||||||
@@ -91,7 +93,7 @@ async def login(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout", response_model=MessageResponse)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -112,7 +114,7 @@ async def logout(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me", response_model=AuthResponse)
|
||||||
async def me(
|
async def me(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.core.monitoring import get_health_status
|
from app.core.monitoring import get_health_status
|
||||||
|
from app.schemas.common import HealthResponse
|
||||||
|
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/health")
|
@router.get("/api/v1/health", response_model=HealthResponse)
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check — no auth required.
|
"""Health check — no auth required.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.models.notification import (
|
|||||||
NotificationPreference,
|
NotificationPreference,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
)
|
)
|
||||||
from app.schemas.common import NotificationPreferenceUpdate
|
from app.schemas.common import NotificationPreferenceUpdate, UnreadCountResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
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(
|
async def unread_count_endpoint(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("notifications:read")),
|
current_user: dict = Depends(require_permission("notifications:read")),
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import require_permission
|
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
|
from app.services import system_settings_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("", response_model=SystemSettingsResponse)
|
||||||
async def get_system_settings(
|
async def get_system_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("settings:read")),
|
current_user: dict = Depends(require_permission("settings:read")),
|
||||||
@@ -28,7 +28,7 @@ async def get_system_settings(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("", response_model=SystemSettingsResponse)
|
||||||
async def upsert_system_settings(
|
async def upsert_system_settings(
|
||||||
body: SystemSettingsUpsert,
|
body: SystemSettingsUpsert,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|||||||
+4
-4
@@ -14,7 +14,7 @@ from app.core.db import get_db
|
|||||||
from app.core.notifications import create_notification
|
from app.core.notifications import create_notification
|
||||||
from app.core.permissions import invalidate_permission_cache
|
from app.core.permissions import invalidate_permission_cache
|
||||||
from app.deps import require_permission
|
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
|
from app.services.user_service import user_service, _UNSET
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
@@ -36,7 +36,7 @@ def _parse_role_id(raw: str | None) -> uuid.UUID | None:
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("", response_model=PaginatedUsers)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(25, ge=1, le=100),
|
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)
|
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(
|
async def create_user(
|
||||||
body: UserCreate,
|
body: UserCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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(
|
async def get_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|||||||
+8
-8
@@ -6,8 +6,8 @@ from pydantic import BaseModel, EmailStr, Field
|
|||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr = Field(..., examples=["admin@leocrm.local"])
|
||||||
password: str = Field(..., min_length=1)
|
password: str = Field(..., min_length=1, examples=["secure-password"])
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetRequest(BaseModel):
|
class PasswordResetRequest(BaseModel):
|
||||||
@@ -24,12 +24,12 @@ class SwitchTenantRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AuthResponse(BaseModel):
|
class AuthResponse(BaseModel):
|
||||||
user_id: str
|
user_id: str = Field(..., examples=["550e8400-e29b-41d4-a716-446655440000"])
|
||||||
email: str
|
email: str = Field(..., examples=["admin@leocrm.local"])
|
||||||
name: str
|
name: str = Field(..., examples=["Admin User"])
|
||||||
role: str
|
role: str = Field(..., examples=["admin"])
|
||||||
tenant_id: str
|
tenant_id: str = Field(..., examples=["550e8400-e29b-41d4-a716-446655440001"])
|
||||||
tenant_name: str | None = None
|
tenant_name: str | None = Field(None, examples=["Acme GmbH"])
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
class MessageResponse(BaseModel):
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class SystemSettingsUpsert(BaseModel):
|
|||||||
theme_accent_color: str = Field("#d946ef", max_length=20)
|
theme_accent_color: str = Field("#d946ef", max_length=20)
|
||||||
theme_font_family: str = Field("Inter", max_length=100)
|
theme_font_family: str = Field("Inter", max_length=100)
|
||||||
theme_border_radius: str = Field("0.5rem", max_length=20)
|
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):
|
class SystemSettingsResponse(BaseModel):
|
||||||
@@ -56,5 +60,9 @@ class SystemSettingsResponse(BaseModel):
|
|||||||
theme_accent_color: str = "#d946ef"
|
theme_accent_color: str = "#d946ef"
|
||||||
theme_font_family: str = "Inter"
|
theme_font_family: str = "Inter"
|
||||||
theme_border_radius: str = "0.5rem"
|
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
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|||||||
+5
-5
@@ -6,11 +6,11 @@ from pydantic import BaseModel, EmailStr, Field
|
|||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr = Field(..., examples=["user@leocrm.local"])
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
name: str = Field(..., min_length=1, max_length=200, examples=["John Doe"])
|
||||||
password: str = Field(..., min_length=8)
|
password: str = Field(..., min_length=8, examples=["secure-password"])
|
||||||
role: str = Field(default="viewer")
|
role: str = Field(default="viewer", examples=["viewer"])
|
||||||
role_id: str | None = Field(default=None, description="UUID of a custom Role")
|
role_id: str | None = Field(default=None, description="UUID of a custom Role", examples=["550e8400-e29b-41d4-a716-446655440000"])
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
Executable
+276
@@ -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())
|
||||||
Executable
+445
@@ -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())
|
||||||
Executable
+301
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user