feat: punkt 6 (backup) — ARQ cron job, backup config in settings, backup-now trigger, backup history, migration 0130

This commit is contained in:
Agent Zero
2026-08-20 13:47:35 +02:00
parent 9a20ae5528
commit 10b1f83fb3
11 changed files with 900 additions and 17 deletions
+238
View File
@@ -0,0 +1,238 @@
"""ARQ backup job — scheduled database backup via scripts/backup.py.
Reads backup configuration from system settings, executes backup.py as a
subprocess, logs the result to audit_log, and notifies admins on failure.
"""
from __future__ import annotations
import asyncio
import logging
import os
import sys
import uuid
from typing import Any
logger = logging.getLogger(__name__)
# Path to the backup script relative to project root
_BACKUP_SCRIPT = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"scripts",
"backup.py",
)
async def _get_backup_config() -> dict[str, Any]:
"""Read backup configuration from system settings for all tenants.
Returns the first tenant's settings that have backup_enabled=True,
or defaults if no settings exist.
"""
from sqlalchemy import select as sa_select
from app.core.db import get_worker_session_factory
from app.models.system_settings import SystemSettings
factory = get_worker_session_factory()
async with factory() as db:
result = await db.execute(
sa_select(SystemSettings).where(
SystemSettings.backup_enabled.is_(True),
SystemSettings.deleted_at.is_(None),
).limit(1)
)
settings = result.scalar_one_or_none()
if settings is None:
return {
"backup_enabled": False,
"backup_interval": "daily",
"backup_retention_days": 7,
"backup_destination": "local",
"tenant_id": None,
}
return {
"backup_enabled": True,
"backup_interval": settings.backup_interval,
"backup_retention_days": settings.backup_retention_days,
"backup_destination": settings.backup_destination,
"tenant_id": settings.tenant_id,
}
async def run_backup_job(ctx: dict[str, Any]) -> dict[str, Any]:
"""Execute a scheduled backup by calling scripts/backup.py as a subprocess.
Reads backup configuration from system settings. If backup_enabled is
False, the job is silently skipped.
Returns a dict with keys: success (bool), message (str), backup_id (str|None).
"""
config = await _get_backup_config()
if not config["backup_enabled"]:
logger.debug("Backup job skipped — backup_enabled is False")
return {"success": False, "message": "Backup disabled", "backup_id": None}
tenant_id = config.get("tenant_id")
retention_days = config.get("backup_retention_days", 7)
destination = config.get("backup_destination", "local")
logger.info(
"Starting scheduled backup: destination=%s, retention=%dd",
destination,
retention_days,
)
# Build subprocess command
cmd = [
sys.executable,
_BACKUP_SCRIPT,
"--destination",
destination,
"--retention-days",
str(retention_days),
]
# Pass environment with DATABASE_URL
env = os.environ.copy()
try:
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
success = process.returncode == 0
output = stdout.decode() if stdout else ""
error = stderr.decode() if stderr else ""
if success:
logger.info("Scheduled backup completed successfully: %s", output[-500:] if output else "")
else:
logger.error("Scheduled backup failed (exit %d): %s", process.returncode, error)
# Log to audit_log
await _log_backup_result(
tenant_id=tenant_id,
success=success,
output=output,
error=error,
destination=destination,
retention_days=retention_days,
)
# Notify admin on failure
if not success and tenant_id:
await _notify_admin_failure(tenant_id, error)
return {
"success": success,
"message": "Backup completed" if success else f"Backup failed: {error[:200]}",
"backup_id": None,
}
except Exception as exc:
logger.exception("Backup job encountered an exception")
if tenant_id:
await _log_backup_result(
tenant_id=tenant_id,
success=False,
output="",
error=str(exc),
destination=destination,
retention_days=retention_days,
)
await _notify_admin_failure(tenant_id, str(exc))
return {"success": False, "message": str(exc), "backup_id": None}
async def _log_backup_result(
tenant_id: uuid.UUID | None,
success: bool,
output: str,
error: str,
destination: str,
retention_days: int,
) -> None:
"""Write backup result to audit_log."""
from app.core.audit import log_audit
from app.core.db import get_worker_session_factory
if tenant_id is None:
return
factory = get_worker_session_factory()
async with factory() as db:
try:
await log_audit(
db,
tenant_id=tenant_id,
user_id=None,
action="backup_success" if success else "backup_failed",
entity_type="backup",
entity_id=None,
changes={
"success": success,
"destination": destination,
"retention_days": retention_days,
"output": output[-1000:] if output else "",
"error": error[-1000:] if error else "",
},
)
await db.commit()
except Exception:
logger.exception("Failed to write backup audit log")
await db.rollback()
async def _notify_admin_failure(tenant_id: uuid.UUID, error: str) -> None:
"""Send a notification to admin users about backup failure."""
from sqlalchemy import select as sa_select
from app.core.db import get_worker_session_factory
from app.core.notifications import post_system_message
from app.models.user import User
factory = get_worker_session_factory()
async with factory() as db:
try:
# Find system admin users for this tenant
result = await db.execute(
sa_select(User).where(
User.tenant_id == tenant_id,
User.is_system_admin.is_(True),
User.deleted_at.is_(None),
).limit(1)
)
admin_user = result.scalar_one_or_none()
if admin_user is None:
logger.warning("No admin user found to notify about backup failure")
return
await post_system_message(
db,
tenant_id=tenant_id,
user_id=admin_user.id,
message_type="backup_failed",
title="Backup fehlgeschlagen",
body=f"Das geplante Backup ist fehlgeschlagen: {error[:500]}",
entity_type="backup",
severity="error",
)
await db.commit()
except Exception:
logger.exception("Failed to notify admin about backup failure")
await db.rollback()
# Register with the job registry
from app.core.job_registry import register_job # noqa: E402
register_job("run_backup", run_backup_job)
+6 -1
View File
@@ -236,7 +236,7 @@ def _lazy_register_plugin_jobs() -> None:
if not registry.list_discovered():
registry.discover_builtins()
job_modules: list[str] = ["app.core.jobs", "app.services.import_export_jobs"]
job_modules: list[str] = ["app.core.jobs", "app.core.backup_job", "app.services.import_export_jobs"]
for plugin_name in registry.list_discovered():
plugin = registry.get_plugin(plugin_name)
if plugin is None:
@@ -371,4 +371,9 @@ class WorkerSettings:
_wrap_cron_with_lock("cleanup_outbox", cleanup_outbox_job, ttl_seconds=300),
minute=0,
),
# Scheduled backup — daily at 02:00 (guarded by distributed lock)
cron(
_wrap_cron_with_lock("run_backup", get_job("run_backup"), ttl_seconds=600),
hour=2, minute=0,
),
]
+3 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -50,5 +50,7 @@ class SystemSettings(Base, TenantMixin):
theme_accent_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#d946ef")
theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter")
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
# Backup configuration
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Automation plugin settings (JSONB)
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+112 -3
View File
@@ -1,14 +1,17 @@
"""System settings routes — get and upsert (admin only)."""
"""System settings routes — get, upsert, backup config (admin only)."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.jobs import enqueue_job
from app.deps import require_permission
from app.models.audit import AuditLog
from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert
from app.services import system_settings_service
@@ -51,6 +54,112 @@ async def upsert_system_settings(
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump()
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
# ── Backup configuration endpoints ───────────────────────────────────────────
@router.get("/backup-config")
async def get_backup_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:read")),
):
"""Get backup configuration for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await system_settings_service.get_system_settings(db, tenant_id)
if result is None:
return {
"backup_enabled": False,
"backup_interval": "24h",
"backup_retention_days": 7,
"backup_destination": "local",
}
return {
"backup_enabled": result.get("backup_enabled", False),
"backup_interval": result.get("backup_interval", "24h"),
"backup_retention_days": result.get("backup_retention_days", 7),
"backup_destination": result.get("backup_destination", "local"),
}
@router.put("/backup-config")
async def update_backup_config(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")),
):
"""Update backup configuration. Admin only.
Accepts: backup_enabled (bool), backup_interval (str),
backup_retention_days (int), backup_destination (str).
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Get existing settings
existing = await system_settings_service.get_system_settings(db, tenant_id)
if existing is None:
raise HTTPException(404, detail={"detail": "System settings not found. Please configure company settings first.", "code": "settings_not_found"})
# Merge backup fields into existing data
data = dict(existing)
if "backup_enabled" in body:
data["backup_enabled"] = bool(body["backup_enabled"])
if "backup_interval" in body:
data["backup_interval"] = str(body["backup_interval"])
if "backup_retention_days" in body:
data["backup_retention_days"] = int(body["backup_retention_days"])
if "backup_destination" in body:
data["backup_destination"] = str(body["backup_destination"])
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
@router.post("/backup-now")
async def trigger_backup_now(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")),
):
"""Trigger an immediate backup via ARQ job. Admin only."""
job_id = await enqueue_job("run_backup")
if job_id is None:
raise HTTPException(503, detail={"detail": "Failed to enqueue backup job. Worker may not be running.", "code": "enqueue_failed"})
return {"message": "Backup job enqueued", "job_id": job_id}
@router.get("/backup-history")
async def get_backup_history(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:read")),
):
"""Get last 10 backup results from audit log."""
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = (
sa_select(AuditLog)
.where(
AuditLog.tenant_id == tenant_id,
AuditLog.entity_type == "backup",
AuditLog.action.in_(["backup_success", "backup_failed"]),
)
.order_by(AuditLog.timestamp.desc())
.limit(10)
)
result = await db.execute(stmt)
entries = result.scalars().all()
history = []
for entry in entries:
changes = entry.changes or {}
history.append({
"id": str(entry.id),
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
"action": entry.action,
"success": changes.get("success", entry.action == "backup_success"),
"destination": changes.get("destination", "local"),
"error": changes.get("error", ""),
})
return {"history": history}
+3 -1
View File
@@ -30,7 +30,8 @@ class SystemSettingsUpsert(BaseModel):
theme_font_family: str = Field("Inter", max_length=100)
theme_border_radius: str = Field("0.5rem", max_length=20)
# Backup configuration
backup_interval: str = Field("daily", max_length=20, description="Backup interval: hourly, daily, weekly, manual")
backup_enabled: bool = Field(False, description="Enable automated backups")
backup_interval: str = Field("daily", max_length=20, description="Backup interval: 6h, 12h, 24h, 48h, weekly")
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")
@@ -62,6 +63,7 @@ class SystemSettingsResponse(BaseModel):
theme_font_family: str = "Inter"
theme_border_radius: str = "0.5rem"
# Backup configuration
backup_enabled: bool = False
backup_interval: str = "daily"
backup_retention_days: int = 7
backup_destination: str = "local"
+9
View File
@@ -38,6 +38,10 @@ def _settings_to_dict(s: SystemSettings) -> dict[str, Any]:
"theme_accent_color": s.theme_accent_color,
"theme_font_family": s.theme_font_family,
"theme_border_radius": s.theme_border_radius,
"backup_enabled": s.backup_enabled,
"backup_interval": s.backup_interval,
"backup_retention_days": s.backup_retention_days,
"backup_destination": s.backup_destination,
"created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
@@ -114,6 +118,10 @@ async def upsert_system_settings(
theme_accent_color=data.get("theme_accent_color", "#d946ef"),
theme_font_family=data.get("theme_font_family", "Inter"),
theme_border_radius=data.get("theme_border_radius", "0.5rem"),
backup_enabled=data.get("backup_enabled", False),
backup_interval=data.get("backup_interval", "daily"),
backup_retention_days=data.get("backup_retention_days", 7),
backup_destination=data.get("backup_destination", "local"),
)
db.add(settings)
await db.flush()
@@ -131,6 +139,7 @@ async def upsert_system_settings(
"bic", "bank_name", "ceo", "trade_register",
"invoice_prefix", "quote_prefix", "payment_terms_days",
"theme_primary_color", "theme_accent_color", "theme_font_family", "theme_border_radius",
"backup_enabled", "backup_interval", "backup_retention_days", "backup_destination",
)
for field in all_fields:
if field in data and data[field] is not None: