Files
gh-christianlouis-docuelevate/app/tasks/backup_tasks.py
T
copilot-swe-agent[bot] 2dd1ca0197 feat(backup): add database backup/restore with scheduled retention and admin dashboard
- Add BackupRecord model for tracking backup archives
- Add migration 021_add_backup_records
- Add backup configuration settings (backup_enabled, backup_dir, backup_remote_destination, backup_remote_folder, backup_retain_hourly/daily/weekly)
- Add backup_tasks.py with create_backup, cleanup_old_backups, and helpers
- Register hourly/daily/weekly Celery beat schedules
- Add /api/admin/backup/* REST endpoints (list, create, download, restore, delete, cleanup)
- Add /admin/backup dashboard view and template
- Add backup link to admin dropdown navigation in base.html
- Add backup settings to SETTING_METADATA in settings_service.py
- Add comprehensive test suite (39 tests passing)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-07 22:03:27 +00:00

414 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Backup and restore tasks for DocuElevate.
Retention strategy
------------------
- **hourly** backups retained for 4 days (``backup_retain_hourly``, default 96)
- **daily** backups retained for 3 weeks (``backup_retain_daily``, default 21)
- **weekly** backups retained for 13 weeks (``backup_retain_weekly``, default 13)
Three separate Celery-beat entries call ``create_backup`` with the appropriate
``backup_type`` argument:
- every hour → ``create_backup("hourly")``
- every day → ``create_backup("daily")``
- every week → ``create_backup("weekly")``
After each backup is created ``_apply_retention`` prunes old local backups for
that tier. Remote copies are pruned by ``_prune_remote_backups`` which mirrors
the same retention limits.
"""
import gzip
import hashlib
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import BackupRecord
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
_BACKUP_TYPE_RETAIN: dict[str, str] = {
"hourly": "backup_retain_hourly",
"daily": "backup_retain_daily",
"weekly": "backup_retain_weekly",
}
def _backup_dir() -> Path:
"""Return (and create) the local backup directory."""
raw = getattr(settings, "backup_dir", None) or os.path.join(settings.workdir, "backups")
path = Path(raw)
path.mkdir(parents=True, exist_ok=True)
return path
def _db_path() -> Path | None:
"""Return the SQLite database file path, or None for non-SQLite databases."""
from sqlalchemy.engine.url import make_url
url = make_url(settings.database_url)
if url.get_backend_name() != "sqlite":
return None
db = url.database
if not db or db == ":memory:":
return None
return Path(db)
def _sha256(path: Path) -> str:
"""Return the SHA-256 hex digest of *path*."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _dump_sqlite(db_path: Path, dest: Path) -> None:
"""Write a gzip-compressed SQL dump of *db_path* to *dest*."""
import sqlite3
conn = sqlite3.connect(str(db_path))
try:
with gzip.open(str(dest), "wt", encoding="utf-8") as gz:
for line in conn.iterdump():
gz.write(line + "\n")
finally:
conn.close()
def _apply_retention(backup_type: str, db: object) -> None:
"""Delete local backups beyond the retention limit for *backup_type*.
Args:
backup_type: One of ``hourly``, ``daily``, ``weekly``.
db: Active SQLAlchemy session.
"""
retain_attr = _BACKUP_TYPE_RETAIN.get(backup_type, "backup_retain_hourly")
retain = int(getattr(settings, retain_attr, 96))
# Query ALL records for this tier (with or without a local file) so that
# remote-only and already-pruned records still count toward the retention window.
records = (
db.query(BackupRecord)
.filter(BackupRecord.backup_type == backup_type)
.order_by(BackupRecord.created_at.desc())
.all()
)
to_prune = records[retain:]
for rec in to_prune:
if rec.local_path and os.path.exists(rec.local_path):
try:
os.remove(rec.local_path)
logger.info(f"Pruned local backup: {rec.local_path}")
except OSError as exc:
logger.warning(f"Failed to remove local backup {rec.local_path}: {exc}")
rec.local_path = None
# If no remote copy either, delete the record entirely
if not rec.remote_path:
db.delete(rec)
db.commit()
def _prune_remote_backups(backup_type: str, db: object) -> None:
"""Prune remote backup records beyond the retention limit.
The actual remote deletion is best-effort (logged but not fatal).
Args:
backup_type: One of ``hourly``, ``daily``, ``weekly``.
db: Active SQLAlchemy session.
"""
retain_attr = _BACKUP_TYPE_RETAIN.get(backup_type, "backup_retain_hourly")
retain = int(getattr(settings, retain_attr, 96))
# Query ALL records for this tier so that already-pruned local records
# still count toward the retention window.
records = (
db.query(BackupRecord)
.filter(BackupRecord.backup_type == backup_type)
.order_by(BackupRecord.created_at.desc())
.all()
)
to_prune = [r for r in records[retain:] if r.remote_path]
for rec in to_prune:
_delete_remote_copy(rec)
rec.remote_path = None
rec.remote_destination = None
if not rec.local_path:
db.delete(rec)
db.commit()
def _delete_remote_copy(rec: BackupRecord) -> None: # noqa: C901
"""Best-effort deletion of the remote copy described by *rec*."""
dest = rec.remote_destination
remote_path = rec.remote_path
if not dest or not remote_path:
return
try:
if dest == "s3":
import boto3
s3 = boto3.client(
"s3",
region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
s3.delete_object(Bucket=settings.s3_bucket_name, Key=remote_path)
logger.info(f"Deleted remote S3 backup: s3://{settings.s3_bucket_name}/{remote_path}")
elif dest == "dropbox":
import dropbox as dbx_module
dbx = dbx_module.Dropbox(settings.dropbox_refresh_token)
dbx.files_delete_v2(remote_path)
logger.info(f"Deleted remote Dropbox backup: {remote_path}")
elif dest in ("ftp", "sftp", "nextcloud", "webdav", "google_drive", "onedrive", "email"):
# For other providers best-effort is logged only deletion not implemented yet.
logger.debug(f"Remote deletion not implemented for destination '{dest}', skipping {remote_path}")
except Exception as exc:
logger.warning(f"Failed to delete remote backup {remote_path} from {dest}: {exc}")
def _upload_remote(archive_path: Path, filename: str) -> tuple[str, str] | None: # noqa: C901
"""Upload *archive_path* to the configured remote destination.
Returns:
``(destination, remote_path)`` on success, ``None`` on failure or when
no remote destination is configured.
"""
dest = getattr(settings, "backup_remote_destination", None)
if not dest:
return None
remote_folder = getattr(settings, "backup_remote_folder", "backups") or "backups"
remote_key = f"{remote_folder}/{filename}"
try:
if dest == "s3":
import boto3
s3 = boto3.client(
"s3",
region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
with open(archive_path, "rb") as fh:
s3.upload_fileobj(fh, settings.s3_bucket_name, remote_key)
logger.info(f"Uploaded backup to S3: s3://{settings.s3_bucket_name}/{remote_key}")
return (dest, remote_key)
elif dest == "dropbox":
import dropbox as dbx_module
dbx = dbx_module.Dropbox(settings.dropbox_refresh_token)
dropbox_path = f"/{remote_key}"
with open(archive_path, "rb") as fh:
dbx.files_upload(fh.read(), dropbox_path, mode=dbx_module.files.WriteMode("overwrite"))
logger.info(f"Uploaded backup to Dropbox: {dropbox_path}")
return (dest, dropbox_path)
elif dest == "email":
_email_backup(archive_path, filename)
return (dest, f"email:{filename}")
elif dest == "nextcloud":
import requests
url = f"{settings.nextcloud_upload_url}/{remote_key}"
with open(archive_path, "rb") as fh:
resp = requests.put(
url,
data=fh,
auth=(settings.nextcloud_username, settings.nextcloud_password),
timeout=120,
)
resp.raise_for_status()
logger.info(f"Uploaded backup to Nextcloud: {url}")
return (dest, url)
elif dest == "webdav":
import requests
url = f"{settings.webdav_url}/{remote_key}"
with open(archive_path, "rb") as fh:
resp = requests.put(
url,
data=fh,
auth=(settings.webdav_username, settings.webdav_password),
verify=settings.webdav_verify_ssl,
timeout=120,
)
resp.raise_for_status()
logger.info(f"Uploaded backup to WebDAV: {url}")
return (dest, url)
else:
logger.warning(f"Backup remote destination '{dest}' upload not implemented; keeping local only.")
return None
except Exception as exc:
logger.error(f"Failed to upload backup to {dest}: {exc}", exc_info=True)
return None
def _email_backup(archive_path: Path, filename: str) -> None:
"""Send *archive_path* as an e-mail attachment to the default recipient."""
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
recipient = settings.email_default_recipient
if not recipient:
raise ValueError("email_default_recipient is not configured")
msg = MIMEMultipart()
msg["Subject"] = f"[DocuElevate] Database backup {filename}"
msg["From"] = settings.email_sender or settings.email_username or "docuelevate@localhost"
msg["To"] = recipient
body = MIMEText(f"Automated database backup from DocuElevate.\n\nFile: {filename}\n", "plain")
msg.attach(body)
with open(archive_path, "rb") as fh:
part = MIMEApplication(fh.read(), Name=filename)
part["Content-Disposition"] = f'attachment; filename="{filename}"'
msg.attach(part)
with smtplib.SMTP(settings.email_host, settings.email_port, timeout=60) as server:
if settings.email_use_tls:
server.starttls()
if settings.email_username and settings.email_password:
server.login(settings.email_username, settings.email_password)
server.sendmail(msg["From"], [recipient], msg.as_string())
logger.info(f"Backup e-mailed to {recipient}: {filename}")
# ---------------------------------------------------------------------------
# Public Celery tasks
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.backup_tasks.create_backup", bind=True)
def create_backup(self, backup_type: str = "hourly") -> dict:
"""Create a database backup archive and apply retention.
Args:
backup_type: ``"hourly"``, ``"daily"``, or ``"weekly"``.
Returns:
A dict with ``filename``, ``size_bytes``, and ``status``.
"""
if backup_type not in _BACKUP_TYPE_RETAIN:
backup_type = "hourly"
if not getattr(settings, "backup_enabled", True):
logger.debug("Backup is disabled; skipping create_backup task.")
return {"status": "disabled"}
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
filename = f"backup_{backup_type}_{ts}.db.gz"
archive_path = _backup_dir() / filename
db_path = _db_path()
if db_path is None:
logger.warning("Backup task skipped: non-SQLite databases are not supported for file-based backups.")
return {"status": "unsupported_db"}
if not db_path.exists():
logger.error(f"Database file not found: {db_path}")
return {"status": "error", "detail": f"DB file missing: {db_path}"}
status = "ok"
checksum: str | None = None
size_bytes = 0
remote_destination: str | None = None
remote_path: str | None = None
try:
_dump_sqlite(db_path, archive_path)
size_bytes = archive_path.stat().st_size
checksum = _sha256(archive_path)
logger.info(f"Created {backup_type} backup: {archive_path} ({size_bytes:,} bytes)")
except Exception as exc:
logger.error(f"Failed to create backup archive {filename}: {exc}", exc_info=True)
status = "failed"
# Record the failure so it is visible in the dashboard
with SessionLocal() as db:
rec = BackupRecord(
filename=filename,
local_path=None,
backup_type=backup_type,
size_bytes=0,
checksum=None,
status="failed",
)
db.add(rec)
db.commit()
return {"status": "error", "detail": str(exc)}
# Optional remote upload
result = _upload_remote(archive_path, filename)
if result:
remote_destination, remote_path = result
with SessionLocal() as db:
rec = BackupRecord(
filename=filename,
local_path=str(archive_path),
backup_type=backup_type,
size_bytes=size_bytes,
checksum=checksum,
status=status,
remote_destination=remote_destination,
remote_path=remote_path,
)
db.add(rec)
db.commit()
# Apply retention policy for this tier
_apply_retention(backup_type, db)
if remote_destination:
_prune_remote_backups(backup_type, db)
return {
"filename": filename,
"size_bytes": size_bytes,
"status": status,
"remote_destination": remote_destination,
}
@celery.task(name="app.tasks.backup_tasks.cleanup_old_backups")
def cleanup_old_backups() -> dict:
"""Manually trigger retention clean-up for all backup tiers.
This is also called automatically after each ``create_backup`` run.
"""
with SessionLocal() as db:
for btype in ("hourly", "daily", "weekly"):
_apply_retention(btype, db)
_prune_remote_backups(btype, db)
return {"status": "ok"}