feat: punkt 6 (backup) — ARQ cron job, backup config in settings, backup-now trigger, backup history, migration 0130
This commit is contained in:
@@ -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
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user