Merge pull request #507 from christianlouis/copilot/add-backup-and-restore-functionality

This commit is contained in:
Christian Krakau-Louis
2026-03-08 10:04:18 +01:00
committed by GitHub
15 changed files with 2029 additions and 0 deletions
+14
View File
@@ -350,6 +350,20 @@ WEBHOOK_ENABLED=True
UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up
UPTIME_KUMA_PING_INTERVAL=5
# Backup & Restore
# Enable automatic scheduled backups (hourly, daily, weekly)
BACKUP_ENABLED=True
# Directory for local backup archives (defaults to <WORKDIR>/backups)
# BACKUP_DIR=/data/backups
# Optional remote destination: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email
# BACKUP_REMOTE_DESTINATION=s3
# Sub-folder used when uploading backup archives to the remote destination
BACKUP_REMOTE_FOLDER=backups
# Retention: number of snapshots to keep per tier
BACKUP_RETAIN_HOURLY=96 # 4 days of hourly snapshots
BACKUP_RETAIN_DAILY=21 # 3 weeks of daily snapshots
BACKUP_RETAIN_WEEKLY=13 # ~3 months of weekly snapshots
# **Full-Text Search (Meilisearch)**
# URL for the Meilisearch instance.
# Default is "http://meilisearch:7700" — the Docker Compose / K8s service name —
+2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
@@ -43,6 +44,7 @@ router = APIRouter()
# Include all the routers
router.include_router(admin_users_router)
router.include_router(user_router)
router.include_router(backup_router)
router.include_router(files_router)
router.include_router(process_router)
router.include_router(diagnostic_router)
+237
View File
@@ -0,0 +1,237 @@
"""
Backup and restore API endpoints for DocuElevate.
Provides REST endpoints for:
- Listing existing backups
- Triggering a manual backup
- Downloading a backup archive
- Restoring from an uploaded backup file
- Deleting a backup record
- Running retention cleanup
"""
import logging
import os
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import BackupRecord
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/backup", tags=["backup"])
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
# Annotated shorthand so FastAPI can resolve and tests can override it.
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/")
async def list_backups(
_admin: AdminUser,
db: Session = Depends(get_db),
) -> list[dict]:
"""Return all backup records, newest first."""
records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).all()
return [
{
"id": r.id,
"filename": r.filename,
"backup_type": r.backup_type,
"size_bytes": r.size_bytes,
"checksum": r.checksum,
"status": r.status,
"local_path": r.local_path,
"remote_destination": r.remote_destination,
"remote_path": r.remote_path,
"created_at": r.created_at.isoformat() if r.created_at else None,
"local_available": bool(r.local_path and os.path.exists(r.local_path)),
}
for r in records
]
@router.post("/create")
async def trigger_backup(
_admin: AdminUser,
backup_type: str = "hourly",
) -> dict:
"""Trigger a manual backup immediately.
Query parameter ``backup_type`` accepts ``hourly``, ``daily``, or
``weekly`` (default: ``hourly``).
"""
if backup_type not in ("hourly", "daily", "weekly"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid backup_type")
from app.tasks.backup_tasks import create_backup
task = create_backup.delay(backup_type=backup_type)
return {"task_id": task.id, "status": "queued", "backup_type": backup_type}
@router.get("/{backup_id}/download")
async def download_backup(
backup_id: int,
_admin: AdminUser,
db: Session = Depends(get_db),
) -> FileResponse:
"""Stream the backup archive to the client."""
rec = db.get(BackupRecord, backup_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
if not rec.local_path or not os.path.exists(rec.local_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Local archive file is not available (may have been pruned)",
)
return FileResponse(
path=rec.local_path,
filename=rec.filename,
media_type="application/gzip",
)
@router.post("/restore")
async def restore_backup(
_admin: AdminUser,
file: UploadFile,
db: Session = Depends(get_db),
) -> dict:
"""Restore the database from an uploaded gzip-compressed SQL dump.
**Warning**: This overwrites the current database contents.
Only SQLite databases are supported.
The uploaded file must be a ``.db.gz`` file produced by the DocuElevate
backup task (a gzip-compressed SQLite ``.dump()`` SQL script).
"""
from app.tasks.backup_tasks import _db_path
db_path = _db_path()
if db_path is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Restore is only supported for SQLite databases.",
)
if not file.filename or not file.filename.endswith(".db.gz"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Uploaded file must be a .db.gz backup archive.",
)
import gzip
import sqlite3
import tempfile
from pathlib import Path
# Write the upload to a temp file first so we can validate it
with tempfile.NamedTemporaryFile(suffix=".db.gz", delete=False) as tmp:
tmp_path = Path(tmp.name)
content = await file.read()
tmp.write(content)
try:
# Decompress and read SQL statements
with gzip.open(str(tmp_path), "rt", encoding="utf-8") as gz:
sql_script = gz.read()
except Exception as exc:
tmp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to decompress backup file: {exc}",
) from exc
# Create a fresh in-memory DB from the script to validate it
try:
mem_conn = sqlite3.connect(":memory:")
mem_conn.executescript(sql_script)
mem_conn.close()
except sqlite3.Error as exc:
tmp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Backup file contains invalid SQL: {exc}",
) from exc
# Close the application DB session before replacing the file
db.close()
# Preserve the current DB before overwriting
import shutil
bak = str(db_path) + ".pre_restore"
try:
shutil.copy2(str(db_path), bak)
except OSError as exc:
logger.warning(f"Could not create pre-restore backup at {bak}: {exc}")
try:
# Write the restored database
restore_conn = sqlite3.connect(str(db_path))
restore_conn.executescript(sql_script)
restore_conn.close()
except sqlite3.Error as exc:
# Attempt rollback
try:
if os.path.exists(bak):
shutil.copy2(bak, str(db_path))
except OSError as rollback_exc:
logger.error(f"Rollback failed; database may be corrupted: {rollback_exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Restore failed: {exc}",
) from exc
finally:
tmp_path.unlink(missing_ok=True)
logger.info(f"Database restored from uploaded backup: {file.filename}")
return {"status": "restored", "filename": file.filename}
@router.delete("/{backup_id}")
async def delete_backup(
backup_id: int,
_admin: AdminUser,
db: Session = Depends(get_db),
) -> dict:
"""Delete a backup record (and local file if present)."""
rec = db.get(BackupRecord, backup_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
if rec.local_path and os.path.exists(rec.local_path):
try:
os.remove(rec.local_path)
except OSError as exc:
logger.warning(f"Could not remove local backup file {rec.local_path}: {exc}")
db.delete(rec)
db.commit()
return {"status": "deleted", "id": backup_id}
@router.post("/cleanup")
async def run_cleanup(_admin: AdminUser) -> dict:
"""Manually trigger the retention cleanup for all backup tiers."""
from app.tasks.backup_tasks import cleanup_old_backups
task = cleanup_old_backups.delay()
return {"task_id": task.id, "status": "queued"}
+35
View File
@@ -8,6 +8,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
from app.tasks.check_credentials import check_credentials
from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
@@ -111,6 +112,40 @@ celery.conf.beat_schedule = {
"schedule": crontab(hour="0", minute="5"), # 00:05 UTC daily
"options": {"expires": 3600},
},
# ── Database backup tasks ──────────────────────────────────────────────
# Hourly backup (kept for 4 days)
"backup-hourly": (
{
"task": "app.tasks.backup_tasks.create_backup",
"schedule": crontab(minute="0"), # top of every hour
"kwargs": {"backup_type": "hourly"},
"options": {"expires": 3300},
}
if settings.backup_enabled
else None
),
# Daily backup (kept for 3 weeks) runs at 02:30 UTC
"backup-daily": (
{
"task": "app.tasks.backup_tasks.create_backup",
"schedule": crontab(hour="2", minute="30"),
"kwargs": {"backup_type": "daily"},
"options": {"expires": 3600},
}
if settings.backup_enabled
else None
),
# Weekly backup (kept for 13 weeks) runs every Sunday at 03:00 UTC
"backup-weekly": (
{
"task": "app.tasks.backup_tasks.create_backup",
"schedule": crontab(hour="3", minute="0", day_of_week="0"),
"kwargs": {"backup_type": "weekly"},
"options": {"expires": 3600},
}
if settings.backup_enabled
else None
),
}
# Remove None entries from beat_schedule
+42
View File
@@ -423,6 +423,48 @@ class Settings(BaseSettings):
description="Enable webhook delivery for document events",
)
# ── Backup / restore settings ──────────────────────────────────────────────
backup_enabled: bool = Field(
default=True,
description=(
"Enable automatic scheduled database backups. "
"When enabled, hourly, daily, and weekly backups are created automatically. Default: True."
),
)
backup_dir: Optional[str] = Field(
default=None,
description=("Directory where local backup archives are stored. Defaults to <workdir>/backups when not set."),
)
# Remote destination: one of s3, dropbox, google_drive, onedrive, nextcloud,
# webdav, ftp, sftp, email, or empty/None for local-only.
backup_remote_destination: Optional[str] = Field(
default=None,
description=(
"Storage provider to upload remote backup copies to. "
"Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. "
"Leave empty to keep backups local only."
),
)
backup_remote_folder: str = Field(
default="backups",
description=(
"Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'."
),
)
# Retention counts (number of snapshots to keep per tier)
backup_retain_hourly: int = Field(
default=96,
description="Number of hourly backups to retain (default 96 = 4 days × 24 h).",
)
backup_retain_daily: int = Field(
default=21,
description="Number of daily backups to retain (default 21 = 3 weeks).",
)
backup_retain_weekly: int = Field(
default=13,
description="Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).",
)
# File upload size limits (for security - see SECURITY_AUDIT.md)
max_upload_size: int = Field(
default=1073741824, # 1GB in bytes (1024 * 1024 * 1024)
+44
View File
@@ -374,3 +374,47 @@ class PipelineStep(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class BackupRecord(Base):
"""Tracks database backup files and their retention metadata.
Each row represents one backup archive (a gzipped SQLite dump).
``backup_type`` classifies the backup for retention purposes:
- ``hourly`` kept for up to 4 days (96 snapshots)
- ``daily`` kept for up to 3 weeks (21 snapshots)
- ``weekly`` kept for up to 13 weeks (≈ 90 days)
``local_path`` is the full filesystem path of the local copy (``None``
once pruned). ``remote_destination`` and ``remote_path`` describe the
remote copy when one has been uploaded to a storage provider or e-mailed.
"""
__tablename__ = "backup_records"
id = Column(Integer, primary_key=True, index=True)
# Human-readable archive filename (e.g. backup_hourly_2026-03-07T12-00-00.db.gz)
filename = Column(String(255), nullable=False, unique=True)
# Full path on the local filesystem (may be NULL for remote-only backups)
local_path = Column(String(1024), nullable=True)
# Classification used by the retention policy
backup_type = Column(String(20), nullable=False, index=True) # hourly | daily | weekly
# Archive size in bytes (0 if unknown)
size_bytes = Column(Integer, nullable=False, default=0)
# Checksum of the archive for integrity verification (SHA-256 hex)
checksum = Column(String(64), nullable=True)
# Whether the backup was successfully created
status = Column(String(20), nullable=False, default="ok") # ok | failed
# Storage destination where a remote copy was uploaded (e.g. "s3", "dropbox", "email")
remote_destination = Column(String(50), nullable=True)
# Path / key of the remote copy (bucket key, folder path, etc.)
remote_path = Column(String(1024), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+413
View File
@@ -0,0 +1,413 @@
"""
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"}
+66
View File
@@ -1300,6 +1300,72 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
# Backup / Restore
"backup_enabled": {
"category": "Backup",
"description": ("Enable automatic scheduled database backups (hourly, daily, weekly). Default: True."),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"backup_dir": {
"category": "Backup",
"description": (
"Directory where local backup archives are stored. Defaults to <workdir>/backups when not set."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"backup_remote_destination": {
"category": "Backup",
"description": (
"Storage provider for remote backup copies. "
"Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. "
"Leave empty to keep backups local only."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
"options": ["", "s3", "dropbox", "google_drive", "onedrive", "nextcloud", "webdav", "ftp", "sftp", "email"],
},
"backup_remote_folder": {
"category": "Backup",
"description": (
"Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"backup_retain_hourly": {
"category": "Backup",
"description": "Number of hourly backups to retain (default 96 = 4 days × 24 h).",
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"backup_retain_daily": {
"category": "Backup",
"description": "Number of daily backups to retain (default 21 = 3 weeks).",
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"backup_retain_weekly": {
"category": "Backup",
"description": "Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).",
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
# UI / Appearance
"ui_default_color_scheme": {
"category": "UI",
+2
View File
@@ -5,6 +5,7 @@ Aggregated view routers for the application.
from fastapi import APIRouter
from app.views.admin_users import router as admin_users_router
from app.views.backup import router as backup_router
from app.views.db_wizard import router as db_wizard_router
from app.views.dropbox import router as dropbox_router
from app.views.filemanager import router as filemanager_router
@@ -30,6 +31,7 @@ router = APIRouter()
router.include_router(wizard_router) # Wizard first (for /setup)
router.include_router(db_wizard_router) # Database wizard
router.include_router(admin_users_router) # Admin user management
router.include_router(backup_router) # Backup dashboard
router.include_router(general_router)
router.include_router(status_router)
router.include_router(onedrive_router)
+57
View File
@@ -0,0 +1,57 @@
"""
Backup management dashboard view admin only.
"""
import logging
import os
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.config import settings
from app.models import BackupRecord
from app.views.base import APIRouter, get_db, require_login, templates
from app.views.settings import require_admin_access
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/admin/backup")
@require_login
@require_admin_access
async def backup_dashboard(request: Request, db: Session = Depends(get_db)):
"""Backup management dashboard admin only."""
try:
records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).limit(500).all()
# Summarise counts per tier
counts: dict[str, int] = {"hourly": 0, "daily": 0, "weekly": 0}
for r in records:
if r.backup_type in counts:
counts[r.backup_type] += 1
# Compute total local size
total_size = sum(r.size_bytes for r in records if r.local_path and os.path.exists(r.local_path))
return templates.TemplateResponse(
"backup.html",
{
"request": request,
"records": records,
"counts": counts,
"total_size": total_size,
"backup_enabled": getattr(settings, "backup_enabled", True),
"backup_remote_destination": getattr(settings, "backup_remote_destination", None),
"backup_retain_hourly": getattr(settings, "backup_retain_hourly", 96),
"backup_retain_daily": getattr(settings, "backup_retain_daily", 21),
"backup_retain_weekly": getattr(settings, "backup_retain_weekly", 13),
"app_version": settings.version,
},
)
except Exception as e:
logger.error(f"Error loading backup dashboard: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load backup dashboard",
)
+36
View File
@@ -887,6 +887,33 @@ Configurations are stored in the database and managed through the API (see [API
Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure.
### Backup & Restore
DocuElevate can automatically back up the SQLite database on a scheduled basis.
Backups are managed from the **Admin → Backup & Restore** dashboard.
| **Variable** | **Description** | **Default** |
|--------------------------------|-----------------------------------------------------------------------------------------------|---------------------|
| `BACKUP_ENABLED` | Enable or disable automatic scheduled backups (`True`/`False`). | `True` |
| `BACKUP_DIR` | Filesystem path where local backup archives are stored. Defaults to `<WORKDIR>/backups`. | *(workdir/backups)* |
| `BACKUP_REMOTE_DESTINATION` | Storage provider to copy backups to. Options: `s3`, `dropbox`, `google_drive`, `onedrive`, `nextcloud`, `webdav`, `ftp`, `sftp`, `email`. Leave empty for local-only storage. | *(empty)* |
| `BACKUP_REMOTE_FOLDER` | Sub-folder / key prefix used when uploading to the remote destination. | `backups` |
| `BACKUP_RETAIN_HOURLY` | Number of hourly snapshots to keep (1 per hour = 96 covers 4 days). | `96` |
| `BACKUP_RETAIN_DAILY` | Number of daily snapshots to keep (21 = 3 weeks). | `21` |
| `BACKUP_RETAIN_WEEKLY` | Number of weekly snapshots to keep (13 ≈ 3 months). | `13` |
**Retention schedule:**
| Tier | Frequency | Default retention | Coverage |
|---------|------------------|-------------------|--------------|
| Hourly | Every hour | 96 snapshots | ~4 days |
| Daily | Daily at 02:00 | 21 snapshots | ~3 weeks |
| Weekly | Sundays at 03:00 | 13 snapshots | ~3 months |
Archives beyond the retention window are automatically pruned after each new backup. The **Clean Up** button on the dashboard applies retention immediately. When a remote destination is configured, remote copies follow the same retention policy.
> **Note:** Backup and restore is currently supported only for SQLite databases.
### Uptime Kuma
| **Variable** | **Description** |
@@ -1223,6 +1250,15 @@ S3_ACL=private
# Uptime Kuma
UPTIME_KUMA_URL=https://kuma.example.com/api/push/abcde12345?status=up
UPTIME_KUMA_PING_INTERVAL=5
# Backup & Restore
BACKUP_ENABLED=True
BACKUP_DIR=/data/backups
BACKUP_REMOTE_DESTINATION=s3 # or dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email
BACKUP_REMOTE_FOLDER=backups
BACKUP_RETAIN_HOURLY=96
BACKUP_RETAIN_DAILY=21
BACKUP_RETAIN_WEEKLY=13
```
## Selective Service Configuration
+411
View File
@@ -0,0 +1,411 @@
{% extends "base.html" %}
{% block title %}Backup Management{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="backupDashboard()">
<!-- Header -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900">
<i class="fas fa-database mr-2 text-blue-600" aria-hidden="true"></i>
Backup Management
</h1>
<p class="mt-1 text-sm text-gray-500">
Database backups are created automatically: hourly (4 days), daily (3 weeks), weekly (13 weeks).
</p>
</div>
<div class="flex gap-2 flex-wrap">
<!-- Manual backup triggers -->
<button @click="triggerBackup('hourly')"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-clock mr-1 text-blue-500" aria-hidden="true"></i> Backup Now (Hourly)
</button>
<button @click="triggerBackup('daily')"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-calendar-day mr-1 text-green-500" aria-hidden="true"></i> Backup Now (Daily)
</button>
<button @click="triggerBackup('weekly')"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-calendar-week mr-1 text-purple-500" aria-hidden="true"></i> Backup Now (Weekly)
</button>
<button @click="runCleanup()"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]"
title="Run retention cleanup now">
<i class="fas fa-broom mr-1 text-orange-400" aria-hidden="true"></i> Clean Up
</button>
</div>
</div>
<!-- Status flash (always in DOM; content toggled via aria-live) -->
<div role="alert" aria-live="polite" aria-atomic="true"
:class="flashMsg ? '' : 'sr-only'"
class="border-l-4 p-4 mb-4 rounded transition-all"
:style="flashMsg ? '' : 'pointer-events:none'"
x-bind:class="flashMsg ? (flashError ? 'bg-red-100 border-red-500 text-red-700' : 'bg-green-100 border-green-500 text-green-700') : 'sr-only'">
<span x-text="flashMsg"></span>
</div>
<!-- Accessible confirm dialog -->
<div x-show="confirmOpen" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
role="dialog" aria-modal="true" :aria-labelledby="'confirmTitle'">
<div class="bg-white rounded-lg shadow-xl max-w-sm w-full p-6"
@keydown.escape.window="confirmOpen = false">
<h2 id="confirmTitle" class="text-lg font-semibold text-gray-900 mb-2">Confirm action</h2>
<p class="text-sm text-gray-600 mb-4" x-text="confirmMsg"></p>
<div class="flex justify-end gap-3">
<button @click="confirmOpen = false; confirmResolve(false)"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 min-h-[44px]">
Cancel
</button>
<button @click="confirmOpen = false; confirmResolve(true)"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 min-h-[44px]">
Confirm
</button>
</div>
</div>
</div>
<!-- Config summary -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- Backup enabled -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Auto-backup</p>
<p class="mt-1 text-lg font-semibold {% if backup_enabled %}text-green-600{% else %}text-red-600{% endif %}">
{% if backup_enabled %}<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> Enabled
{% else %}<i class="fas fa-times-circle mr-1" aria-hidden="true"></i> Disabled{% endif %}
</p>
</div>
<!-- Remote destination -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Remote destination</p>
<p class="mt-1 text-lg font-semibold text-gray-800">
{% if backup_remote_destination %}
<i class="fas fa-cloud-upload-alt mr-1 text-blue-500" aria-hidden="true"></i>
{{ backup_remote_destination }}
{% else %}
<span class="text-gray-400"><i class="fas fa-hdd mr-1" aria-hidden="true"></i> Local only</span>
{% endif %}
</p>
</div>
<!-- Retention hourly -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Retention</p>
<p class="mt-1 text-sm text-gray-700">
<span class="font-semibold">{{ backup_retain_hourly }}</span> hourly &middot;
<span class="font-semibold">{{ backup_retain_daily }}</span> daily &middot;
<span class="font-semibold">{{ backup_retain_weekly }}</span> weekly
</p>
</div>
<!-- Total backup size -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Total local size</p>
<p class="mt-1 text-lg font-semibold text-gray-800" id="totalSize">
{{ (total_size / 1048576) | round(2) }} MB
</p>
</div>
</div>
<!-- Upload restore section -->
<div class="bg-white shadow rounded-lg p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-1">
<i class="fas fa-upload mr-2 text-yellow-500" aria-hidden="true"></i>Restore from File
</h2>
<p class="text-sm text-gray-500 mb-4">
Upload a <code>.db.gz</code> backup archive to restore the database.
<strong class="text-red-600">This will overwrite all current data.</strong>
</p>
<form id="restoreForm" @submit.prevent="submitRestore" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div>
<label for="restoreFile" class="block text-sm font-medium text-gray-700 mb-1">Backup archive (.db.gz)</label>
<input type="file" id="restoreFile" name="file" accept=".gz"
required
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<button type="submit"
:disabled="restoring"
class="inline-flex items-center px-4 py-2 bg-yellow-500 hover:bg-yellow-600 text-white text-sm font-medium rounded-md disabled:opacity-50 min-h-[44px]">
<i class="fas fa-undo mr-2" aria-hidden="true"></i>
<span x-text="restoring ? 'Restoring…' : 'Restore'"></span>
</button>
</div>
</form>
</div>
<!-- Backup list -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-list mr-2 text-gray-500" aria-hidden="true"></i>Backup Archives
<span class="ml-2 text-sm font-normal text-gray-400">({{ records | length }} records)</span>
</h2>
<!-- Tier filter -->
<div class="flex gap-2 text-sm">
<button @click="filterType = ''" :class="filterType === '' ? 'bg-gray-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">All</button>
<button @click="filterType = 'hourly'" :class="filterType === 'hourly' ? 'bg-blue-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">Hourly</button>
<button @click="filterType = 'daily'" :class="filterType === 'daily' ? 'bg-green-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">Daily</button>
<button @click="filterType = 'weekly'" :class="filterType === 'weekly' ? 'bg-purple-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">Weekly</button>
</div>
</div>
{% if records %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="Backup archives">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Filename</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Storage</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for r in records %}
<tr class="hover:bg-gray-50"
x-show="filterType === '' || filterType === '{{ r.backup_type }}'"
data-backup-id="{{ r.id }}">
<td class="px-4 py-3 text-sm text-gray-900 font-mono truncate max-w-xs" title="{{ r.filename }}">
{{ r.filename }}
</td>
<td class="px-4 py-3 text-sm">
{% if r.backup_type == 'hourly' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-clock mr-1" aria-hidden="true"></i> hourly
</span>
{% elif r.backup_type == 'daily' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-calendar-day mr-1" aria-hidden="true"></i> daily
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-calendar-week mr-1" aria-hidden="true"></i> weekly
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
{% if r.created_at %}{{ r.created_at.strftime('%Y-%m-%d %H:%M') }} UTC{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
{% if r.size_bytes %}
{{ (r.size_bytes / 1024) | round(1) }} KB
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-sm">
{% if r.status == 'ok' %}
<span class="inline-flex items-center text-green-700">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> ok
</span>
{% else %}
<span class="inline-flex items-center text-red-600">
<i class="fas fa-exclamation-circle mr-1" aria-hidden="true"></i> {{ r.status }}
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{% if r.local_path %}
<span class="inline-flex items-center" title="{{ r.local_path }}">
<i class="fas fa-hdd mr-1 text-gray-400" aria-hidden="true"></i> local
</span>
{% endif %}
{% if r.remote_destination %}
<span class="inline-flex items-center ml-2 text-blue-600" title="{{ r.remote_path }}">
<i class="fas fa-cloud mr-1" aria-hidden="true"></i> {{ r.remote_destination }}
</span>
{% endif %}
{% if not r.local_path and not r.remote_destination %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
{% if r.local_path %}
<a href="/api/admin/backup/{{ r.id }}/download"
class="inline-flex items-center px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700 min-h-[32px]"
aria-label="Download backup {{ r.filename }}">
<i class="fas fa-download mr-1" aria-hidden="true"></i> Download
</a>
{% endif %}
<button @click="deleteBackup({{ r.id }}, '{{ r.filename }}')"
type="button"
class="inline-flex items-center px-2 py-1 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600 ml-1 min-h-[32px]"
aria-label="Delete backup {{ r.filename }}">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center text-gray-400">
<i class="fas fa-database text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg font-medium">No backups yet.</p>
<p class="text-sm">Click <em>Backup Now</em> to create your first backup.</p>
</div>
{% endif %}
</div>
<!-- Retention explanation -->
<div class="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-5">
<h3 class="text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-info-circle mr-1 text-blue-400" aria-hidden="true"></i> Retention policy
</h3>
<ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
<li><strong>Hourly</strong> kept for 4 days ({{ backup_retain_hourly }} snapshots)</li>
<li><strong>Daily</strong> kept for 3 weeks ({{ backup_retain_daily }} snapshots)</li>
<li><strong>Weekly</strong> kept for ~3 months ({{ backup_retain_weekly }} snapshots)</li>
</ul>
<p class="text-xs text-gray-400 mt-2">
Backups beyond these limits are automatically pruned after each new backup is created.
Use the <em>Clean Up</em> button to apply retention manually.
</p>
</div>
</div>
<script>
function backupDashboard() {
return {
triggering: false,
restoring: false,
filterType: '',
flashMsg: '',
flashError: false,
confirmOpen: false,
confirmMsg: '',
confirmResolve: null,
/** Show a timed status flash message. */
flash(msg, isError = false) {
this.flashMsg = msg;
this.flashError = isError;
setTimeout(() => { this.flashMsg = ''; }, 5000);
},
/** Return CSRF token value, or throw if missing. */
csrfToken() {
const el = document.querySelector('[name=csrf_token]');
if (!el || !el.value) throw new Error('CSRF token missing cannot proceed.');
return el.value;
},
/** Show the accessible confirm dialog and return a Promise<boolean>. */
askConfirm(msg) {
this.confirmMsg = msg;
this.confirmOpen = true;
return new Promise(resolve => { this.confirmResolve = resolve; });
},
async triggerBackup(type) {
this.triggering = true;
try {
const resp = await fetch(`/api/admin/backup/create?backup_type=${type}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.csrfToken() },
});
if (resp.ok) {
const data = await resp.json();
this.flash(`${type.charAt(0).toUpperCase() + type.slice(1)} backup queued (task ${data.task_id}).`);
setTimeout(() => location.reload(), 3000);
} else {
const err = await resp.json();
this.flash(`Error: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
} finally {
this.triggering = false;
}
},
async runCleanup() {
this.triggering = true;
try {
const resp = await fetch('/api/admin/backup/cleanup', {
method: 'POST',
headers: { 'X-CSRF-Token': this.csrfToken() },
});
if (resp.ok) {
this.flash('Cleanup queued.');
setTimeout(() => location.reload(), 3000);
} else {
const err = await resp.json();
this.flash(`Error: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
} finally {
this.triggering = false;
}
},
async deleteBackup(id, filename) {
const ok = await this.askConfirm(`Delete backup "${filename}"? This cannot be undone.`);
if (!ok) return;
try {
const resp = await fetch(`/api/admin/backup/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': this.csrfToken() },
});
if (resp.ok) {
this.flash(`Backup ${filename} deleted.`);
const row = document.querySelector(`[data-backup-id="${id}"]`);
if (row) row.remove();
} else {
const err = await resp.json();
this.flash(`Error: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
}
},
async submitRestore() {
const form = document.getElementById('restoreForm');
const fileInput = document.getElementById('restoreFile');
if (!fileInput.files.length) return;
const ok = await this.askConfirm('Are you sure? This will OVERWRITE all current database data with the contents of the backup file.');
if (!ok) return;
this.restoring = true;
try {
const formData = new FormData(form);
const resp = await fetch('/api/admin/backup/restore', {
method: 'POST',
headers: { 'X-CSRF-Token': this.csrfToken() },
body: formData,
});
if (resp.ok) {
this.flash('Database restored successfully. The page will reload.');
setTimeout(() => location.reload(), 3000);
} else {
const err = await resp.json();
this.flash(`Restore failed: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
} finally {
this.restoring = false;
}
},
};
}
</script>
{% endblock %}
+6
View File
@@ -160,6 +160,9 @@
<a href="/admin/queue" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-stream w-4 mr-2 text-blue-500" aria-hidden="true"></i> Queue Monitor
</a>
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> Backup &amp; Restore
</a>
</div>
</div>
</div>
@@ -298,6 +301,9 @@
<a href="/admin/queue" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-stream mr-2 text-blue-400" aria-hidden="true"></i> Queue Monitor
</a>
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> Backup &amp; Restore
</a>
</div>
</div>
@@ -0,0 +1,43 @@
"""Add backup_records table for database backup tracking
Revision ID: 021_add_backup_records
Revises: 020_add_subscription_change_pending
Create Date: 2026-03-07
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "021_add_backup_records"
down_revision: Union[str, None] = "020_add_subscription_change_pending"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create backup_records table."""
op.create_table(
"backup_records",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("local_path", sa.String(1024), nullable=True),
sa.Column("backup_type", sa.String(20), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("checksum", sa.String(64), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="ok"),
sa.Column("remote_destination", sa.String(50), nullable=True),
sa.Column("remote_path", sa.String(1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("filename"),
)
op.create_index("ix_backup_records_backup_type", "backup_records", ["backup_type"])
op.create_index("ix_backup_records_created_at", "backup_records", ["created_at"])
def downgrade() -> None:
"""Drop backup_records table."""
op.drop_index("ix_backup_records_created_at", "backup_records")
op.drop_index("ix_backup_records_backup_type", "backup_records")
op.drop_table("backup_records")
+621
View File
@@ -0,0 +1,621 @@
"""
Tests for the backup/restore functionality.
Covers:
- BackupRecord model creation
- backup_tasks: create_backup, cleanup_old_backups, retention helpers
- app/api/backup.py endpoints: list, create, download, restore, delete, cleanup
- app/views/backup.py dashboard view
"""
import gzip
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.main import app
from app.models import BackupRecord
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def bk_engine():
"""In-memory SQLite engine with all tables for backup tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
@pytest.fixture()
def admin_client(bk_engine):
"""TestClient with admin override for backup routes."""
from app.api.backup import _require_admin
def override_db():
Session = sessionmaker(bind=bk_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_require_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_require_admin] = override_require_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def non_admin_client(bk_engine):
"""TestClient without admin override - _require_admin will raise 403."""
def override_db():
Session = sessionmaker(bind=bk_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def db_session(bk_engine):
"""SQLAlchemy session against the in-memory engine."""
Session = sessionmaker(bind=bk_engine)
session = Session()
yield session
session.close()
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBackupRecordModel:
"""Test the BackupRecord SQLAlchemy model."""
def test_create_backup_record(self, db_session):
"""Test creating a BackupRecord persists correctly."""
rec = BackupRecord(
filename="backup_daily_2026-01-01T02-30-00.db.gz",
local_path="/tmp/test.db.gz",
backup_type="daily",
size_bytes=2048,
checksum="deadbeef",
status="ok",
)
db_session.add(rec)
db_session.commit()
db_session.refresh(rec)
assert rec.id is not None
assert rec.filename == "backup_daily_2026-01-01T02-30-00.db.gz"
assert rec.backup_type == "daily"
assert rec.size_bytes == 2048
def test_backup_record_defaults(self, db_session):
"""Test default values for BackupRecord fields."""
rec = BackupRecord(
filename="backup_weekly_2026-01-01T03-00-00.db.gz",
backup_type="weekly",
)
db_session.add(rec)
db_session.commit()
db_session.refresh(rec)
assert rec.status == "ok"
assert rec.size_bytes == 0
assert rec.remote_destination is None
assert rec.remote_path is None
def test_backup_record_remote_fields(self, db_session):
"""Test remote destination fields on BackupRecord."""
rec = BackupRecord(
filename="backup_hourly_remote.db.gz",
backup_type="hourly",
remote_destination="s3",
remote_path="backups/backup_hourly_remote.db.gz",
)
db_session.add(rec)
db_session.commit()
db_session.refresh(rec)
assert rec.remote_destination == "s3"
assert rec.remote_path == "backups/backup_hourly_remote.db.gz"
# ---------------------------------------------------------------------------
# Task helper unit tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBackupTaskHelpers:
"""Unit tests for backup_tasks helper functions."""
def test_backup_dir_creation(self, tmp_path):
"""_backup_dir() creates the directory if it does not exist."""
from app.tasks.backup_tasks import _backup_dir
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_dir = str(tmp_path / "mybkp")
d = _backup_dir()
assert d.exists()
def test_backup_dir_default(self, tmp_path):
"""_backup_dir() defaults to <workdir>/backups."""
from app.tasks.backup_tasks import _backup_dir
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_dir = None
mock_settings.workdir = str(tmp_path)
d = _backup_dir()
assert d == tmp_path / "backups"
def test_sha256(self, tmp_path):
"""_sha256() returns a 64-char hex string."""
from app.tasks.backup_tasks import _sha256
f = tmp_path / "test.bin"
f.write_bytes(b"hello world")
digest = _sha256(f)
assert len(digest) == 64
assert all(c in "0123456789abcdef" for c in digest)
def test_dump_sqlite(self, tmp_path):
"""_dump_sqlite() writes a gzip-compressed SQL dump."""
from app.tasks.backup_tasks import _dump_sqlite
src = tmp_path / "src.db"
conn = sqlite3.connect(str(src))
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)")
conn.execute("INSERT INTO t VALUES (1, 'hello')")
conn.commit()
conn.close()
dest = tmp_path / "dump.db.gz"
_dump_sqlite(src, dest)
assert dest.exists()
with gzip.open(str(dest), "rt") as gz:
content = gz.read()
assert "CREATE TABLE t" in content
assert "hello" in content
def test_db_path_sqlite(self):
"""_db_path() returns path for sqlite:/// URLs."""
from app.tasks.backup_tasks import _db_path
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.database_url = "sqlite:////tmp/test.db"
result = _db_path()
assert result == Path("/tmp/test.db")
def test_db_path_memory(self):
"""_db_path() returns None for in-memory sqlite."""
from app.tasks.backup_tasks import _db_path
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.database_url = "sqlite:///:memory:"
result = _db_path()
assert result is None
def test_db_path_postgres(self):
"""_db_path() returns None for non-SQLite databases."""
from app.tasks.backup_tasks import _db_path
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.database_url = "postgresql://user:pass@localhost/db"
result = _db_path()
assert result is None
def test_apply_retention_prunes_old(self, tmp_path, db_session):
"""_apply_retention() deletes backups beyond the retention limit."""
from app.tasks.backup_tasks import _apply_retention
for i in range(5):
f = tmp_path / f"backup_hourly_{i:04d}.db.gz"
f.write_bytes(b"x")
rec = BackupRecord(
filename=f"backup_hourly_{i:04d}.db.gz",
local_path=str(f),
backup_type="hourly",
size_bytes=1,
status="ok",
created_at=datetime(2026, 1, 1, i, 0, 0, tzinfo=timezone.utc),
)
db_session.add(rec)
db_session.commit()
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_retain_hourly = 3
_apply_retention("hourly", db_session)
remaining = db_session.query(BackupRecord).filter_by(backup_type="hourly").all()
remaining_paths = [r.local_path for r in remaining if r.local_path is not None]
assert len(remaining_paths) <= 3
def test_apply_retention_removes_record_no_remote(self, tmp_path, db_session):
"""_apply_retention() deletes the DB record when no local file or remote copy remain."""
from app.tasks.backup_tasks import _apply_retention
# Old record local file doesn't exist, no remote
old_rec = BackupRecord(
filename="backup_hourly_old.db.gz",
local_path=str(tmp_path / "nonexistent.db.gz"),
backup_type="hourly",
size_bytes=1,
status="ok",
created_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
db_session.add(old_rec)
# Two newer records so the old one falls outside retention window
for i, dt in enumerate([datetime(2026, 1, 1), datetime(2026, 1, 2)]):
db_session.add(
BackupRecord(
filename=f"backup_hourly_new_{i}.db.gz",
backup_type="hourly",
size_bytes=1,
status="ok",
created_at=dt.replace(tzinfo=timezone.utc),
)
)
db_session.commit()
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_retain_hourly = 2
_apply_retention("hourly", db_session)
remaining = db_session.query(BackupRecord).filter_by(filename="backup_hourly_old.db.gz").first()
assert remaining is None
def test_upload_remote_no_destination(self, tmp_path):
"""_upload_remote() returns None when no destination is configured."""
from app.tasks.backup_tasks import _upload_remote
f = tmp_path / "bkp.db.gz"
f.write_bytes(b"data")
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_remote_destination = None
result = _upload_remote(f, "bkp.db.gz")
assert result is None
def test_upload_remote_unknown_dest(self, tmp_path):
"""_upload_remote() returns None for an unimplemented destination."""
from app.tasks.backup_tasks import _upload_remote
f = tmp_path / "bkp.db.gz"
f.write_bytes(b"data")
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_remote_destination = "unknown_provider"
mock_settings.backup_remote_folder = "backups"
result = _upload_remote(f, "bkp.db.gz")
assert result is None
def test_delete_remote_copy_no_dest(self):
"""_delete_remote_copy() does nothing when rec has no destination."""
from app.tasks.backup_tasks import _delete_remote_copy
rec = BackupRecord(
filename="x.db.gz",
backup_type="hourly",
remote_destination=None,
remote_path=None,
)
_delete_remote_copy(rec)
# ---------------------------------------------------------------------------
# Task integration tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCreateBackupTask:
"""Tests for the create_backup Celery task."""
def test_backup_disabled(self):
"""create_backup returns early when backup_enabled is False."""
from app.tasks.backup_tasks import create_backup
with patch("app.tasks.backup_tasks.settings") as mock_settings:
mock_settings.backup_enabled = False
result = create_backup("hourly")
assert result["status"] == "disabled"
def test_non_sqlite_db(self):
"""create_backup returns unsupported_db for non-SQLite databases."""
from app.tasks.backup_tasks import create_backup
with (
patch("app.tasks.backup_tasks.settings") as mock_settings,
patch("app.tasks.backup_tasks._db_path", return_value=None),
):
mock_settings.backup_enabled = True
result = create_backup("hourly")
assert result["status"] == "unsupported_db"
def test_missing_db_file(self, tmp_path):
"""create_backup returns error when the DB file does not exist."""
from app.tasks.backup_tasks import create_backup
missing = tmp_path / "does_not_exist.db"
with (
patch("app.tasks.backup_tasks.settings") as mock_settings,
patch("app.tasks.backup_tasks._db_path", return_value=missing),
):
mock_settings.backup_enabled = True
result = create_backup("hourly")
assert result["status"] == "error"
def test_successful_backup(self, tmp_path):
"""create_backup creates a .db.gz archive and a BackupRecord."""
from app.tasks.backup_tasks import create_backup
db_file = tmp_path / "test.db"
conn = sqlite3.connect(str(db_file))
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
conn.close()
backup_dir = tmp_path / "backups"
with (
patch("app.tasks.backup_tasks.settings") as mock_settings,
patch("app.tasks.backup_tasks._db_path", return_value=db_file),
patch("app.tasks.backup_tasks._backup_dir", return_value=backup_dir),
patch("app.tasks.backup_tasks._upload_remote", return_value=None),
patch("app.tasks.backup_tasks._apply_retention"),
patch("app.tasks.backup_tasks._prune_remote_backups"),
patch("app.tasks.backup_tasks.SessionLocal") as mock_sl,
):
backup_dir.mkdir(parents=True, exist_ok=True)
mock_settings.backup_enabled = True
mock_db = MagicMock()
mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_sl.return_value.__exit__ = MagicMock(return_value=False)
result = create_backup("hourly")
assert result["status"] == "ok"
assert "filename" in result
assert result["filename"].startswith("backup_hourly_")
def test_invalid_backup_type_defaults_to_hourly(self, tmp_path):
"""create_backup normalises unknown backup_type to 'hourly'."""
from app.tasks.backup_tasks import create_backup
db_file = tmp_path / "test.db"
conn = sqlite3.connect(str(db_file))
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
conn.close()
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
with (
patch("app.tasks.backup_tasks.settings") as mock_settings,
patch("app.tasks.backup_tasks._db_path", return_value=db_file),
patch("app.tasks.backup_tasks._backup_dir", return_value=backup_dir),
patch("app.tasks.backup_tasks._upload_remote", return_value=None),
patch("app.tasks.backup_tasks._apply_retention"),
patch("app.tasks.backup_tasks._prune_remote_backups"),
patch("app.tasks.backup_tasks.SessionLocal") as mock_sl,
):
mock_settings.backup_enabled = True
mock_db = MagicMock()
mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_sl.return_value.__exit__ = MagicMock(return_value=False)
result = create_backup("invalid_type")
assert result.get("status") == "ok"
assert "hourly" in result["filename"]
# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestBackupAPIEndpoints:
"""Tests for /api/admin/backup/* endpoints."""
def test_list_backups_admin(self, admin_client):
"""GET /api/admin/backup/ returns a list for admin users."""
resp = admin_client.get("/api/admin/backup/")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_list_backups_non_admin(self, non_admin_client):
"""GET /api/admin/backup/ returns 403 for non-admin users."""
resp = non_admin_client.get("/api/admin/backup/")
assert resp.status_code == 403
def test_trigger_backup_admin(self, admin_client):
"""POST /api/admin/backup/create queues a backup task."""
with patch("app.tasks.backup_tasks.create_backup") as mock_task:
mock_result = MagicMock()
mock_result.id = "fake-task-id"
mock_task.delay.return_value = mock_result
resp = admin_client.post("/api/admin/backup/create?backup_type=hourly")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "queued"
assert data["backup_type"] == "hourly"
def test_trigger_backup_invalid_type(self, admin_client):
"""POST /api/admin/backup/create returns 400 for invalid type."""
resp = admin_client.post("/api/admin/backup/create?backup_type=invalid")
assert resp.status_code == 400
def test_trigger_backup_non_admin(self, non_admin_client):
"""POST /api/admin/backup/create returns 403 for non-admin."""
resp = non_admin_client.post("/api/admin/backup/create")
assert resp.status_code == 403
def test_download_backup_not_found(self, admin_client):
"""GET /api/admin/backup/99999/download returns 404 for unknown ID."""
resp = admin_client.get("/api/admin/backup/99999/download")
assert resp.status_code == 404
def test_download_backup_no_local_file(self, admin_client, bk_engine):
"""GET /api/admin/backup/{id}/download returns 404 when file was pruned."""
Session = sessionmaker(bind=bk_engine)
with Session() as db:
rec = BackupRecord(
filename="backup_hourly_pruned.db.gz",
local_path="/nonexistent/path/file.db.gz",
backup_type="hourly",
size_bytes=0,
status="ok",
)
db.add(rec)
db.commit()
rid = rec.id
resp = admin_client.get(f"/api/admin/backup/{rid}/download")
assert resp.status_code == 404
def test_delete_backup_admin(self, admin_client, bk_engine):
"""DELETE /api/admin/backup/{id} removes the record."""
Session = sessionmaker(bind=bk_engine)
with Session() as db:
rec = BackupRecord(
filename="backup_hourly_to_delete.db.gz",
backup_type="hourly",
size_bytes=0,
status="ok",
)
db.add(rec)
db.commit()
rid = rec.id
resp = admin_client.delete(f"/api/admin/backup/{rid}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
def test_delete_backup_not_found(self, admin_client):
"""DELETE /api/admin/backup/99999 returns 404."""
resp = admin_client.delete("/api/admin/backup/99999")
assert resp.status_code == 404
def test_delete_backup_non_admin(self, non_admin_client):
"""DELETE /api/admin/backup/1 returns 403 for non-admin."""
resp = non_admin_client.delete("/api/admin/backup/1")
assert resp.status_code == 403
def test_cleanup_endpoint_admin(self, admin_client):
"""POST /api/admin/backup/cleanup queues cleanup task."""
with patch("app.tasks.backup_tasks.cleanup_old_backups") as mock_task:
mock_result = MagicMock()
mock_result.id = "fake-cleanup-id"
mock_task.delay.return_value = mock_result
resp = admin_client.post("/api/admin/backup/cleanup")
assert resp.status_code == 200
assert resp.json()["status"] == "queued"
def test_cleanup_endpoint_non_admin(self, non_admin_client):
"""POST /api/admin/backup/cleanup returns 403 for non-admin."""
resp = non_admin_client.post("/api/admin/backup/cleanup")
assert resp.status_code == 403
def test_restore_wrong_extension(self, admin_client):
"""POST /api/admin/backup/restore rejects non-.db.gz files."""
resp = admin_client.post(
"/api/admin/backup/restore",
files={"file": ("backup.zip", b"data", "application/zip")},
)
assert resp.status_code == 400
def test_restore_invalid_gz_content(self, admin_client):
"""POST /api/admin/backup/restore rejects corrupt gzip data."""
resp = admin_client.post(
"/api/admin/backup/restore",
files={"file": ("backup.db.gz", b"not gzip data at all", "application/gzip")},
)
assert resp.status_code == 400
def test_restore_valid_archive(self, admin_client, tmp_path):
"""POST /api/admin/backup/restore succeeds with a valid gzip SQL dump."""
sql = "BEGIN TRANSACTION;\nCOMMIT;\n"
gz_data = gzip.compress(sql.encode())
db_file = tmp_path / "restore_test.db"
conn = sqlite3.connect(str(db_file))
conn.close()
with patch("app.tasks.backup_tasks._db_path", return_value=db_file):
resp = admin_client.post(
"/api/admin/backup/restore",
files={"file": ("backup.db.gz", gz_data, "application/gzip")},
)
assert resp.status_code == 200
assert resp.json()["status"] == "restored"
def test_restore_non_sqlite_db(self, admin_client):
"""POST /api/admin/backup/restore returns 400 for non-SQLite database."""
sql = "BEGIN TRANSACTION;\nCOMMIT;\n"
gz_data = gzip.compress(sql.encode())
with patch("app.tasks.backup_tasks._db_path", return_value=None):
resp = admin_client.post(
"/api/admin/backup/restore",
files={"file": ("backup.db.gz", gz_data, "application/gzip")},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# View tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestBackupView:
"""Tests for the /admin/backup dashboard view."""
def test_backup_dashboard_admin(self, admin_client):
"""GET /admin/backup returns 200 for admin users."""
resp = admin_client.get("/admin/backup")
assert resp.status_code == 200
assert b"Backup" in resp.content
def test_backup_dashboard_non_admin_redirect(self, non_admin_client):
"""GET /admin/backup redirects non-admin users."""
resp = non_admin_client.get("/admin/backup", follow_redirects=False)
assert resp.status_code in (302, 303)
def test_backup_dashboard_unauthenticated(self):
"""GET /admin/backup redirects unauthenticated users."""
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
resp = client.get("/admin/backup", follow_redirects=False)
assert resp.status_code in (302, 303)