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>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
``location`` is ``local`` when the file is stored on-disk under the
|
||||
configured backup directory, or ``remote`` when it has been uploaded to
|
||||
a storage provider or sent via e-mail.
|
||||
"""
|
||||
|
||||
__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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Backup management dashboard view – admin only.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
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
|
||||
import os
|
||||
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
{% 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 -->
|
||||
<div x-show="flashMsg" x-transition aria-live="polite"
|
||||
:class="flashError ? 'bg-red-100 border-red-500 text-red-700' : 'bg-green-100 border-green-500 text-green-700'"
|
||||
class="border-l-4 p-4 mb-4 rounded" role="alert">
|
||||
<span x-text="flashMsg"></span>
|
||||
</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 ·
|
||||
<span class="font-semibold">{{ backup_retain_daily }}</span> daily ·
|
||||
<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,
|
||||
|
||||
flash(msg, isError = false) {
|
||||
this.flashMsg = msg;
|
||||
this.flashError = isError;
|
||||
setTimeout(() => { this.flashMsg = ''; }, 5000);
|
||||
},
|
||||
|
||||
async triggerBackup(type) {
|
||||
this.triggering = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/backup/create?backup_type=${type}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' },
|
||||
});
|
||||
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': document.querySelector('[name=csrf_token]')?.value || '' },
|
||||
});
|
||||
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) {
|
||||
if (!confirm(`Delete backup "${filename}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/backup/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' },
|
||||
});
|
||||
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;
|
||||
|
||||
if (!confirm('Are you sure? This will OVERWRITE all current database data with the contents of the backup file.')) return;
|
||||
|
||||
this.restoring = true;
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const resp = await fetch('/api/admin/backup/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' },
|
||||
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 %}
|
||||
@@ -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 & 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 & 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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user