snapshot: preserve backup UX, remote target setup, and docs updates

This commit is contained in:
Md Bayazid Bostame
2026-03-26 01:53:44 +01:00
parent 2a372fdb15
commit 438334bd92
26 changed files with 1737 additions and 383 deletions

2
.gitignore vendored
View File

@@ -14,3 +14,5 @@ staticfiles/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.DS_Store .DS_Store
backups/
backend/backups/

View File

@@ -1,6 +1,6 @@
COMPOSE ?= docker compose COMPOSE ?= docker compose
.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de .PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify
i18n-extract-en: i18n-extract-en:
$(COMPOSE) exec -T web django-admin makemessages -l en $(COMPOSE) exec -T web django-admin makemessages -l en
@@ -14,3 +14,10 @@ i18n-compile:
i18n-update-en: i18n-extract-en i18n-compile i18n-update-en: i18n-extract-en i18n-compile
i18n-update-de: i18n-extract-de i18n-compile i18n-update-de: i18n-extract-de i18n-compile
backup-create:
./scripts/backup_create.sh
backup-verify:
@if [ -z "$(BACKUP_DIR)" ]; then echo "Usage: make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS"; exit 1; fi
./scripts/backup_verify.sh "$(BACKUP_DIR)"

View File

@@ -78,3 +78,24 @@ Run a real workflow verification (onboarding + offboarding), including PDF check
- `docker compose exec -T web python manage.py run_staging_e2e_check --email-check mailhog --mailhog-api-url http://mailhog:8025/api/v2/messages` - `docker compose exec -T web python manage.py run_staging_e2e_check --email-check mailhog --mailhog-api-url http://mailhog:8025/api/v2/messages`
- Skip Nextcloud existence checks: - Skip Nextcloud existence checks:
- `docker compose exec -T web python manage.py run_staging_e2e_check --skip-nextcloud` - `docker compose exec -T web python manage.py run_staging_e2e_check --skip-nextcloud`
## Backup and restore
Use the repo-level scripts so database and media backups stay consistent.
- Create a backup bundle:
- `make backup-create`
- Verify a backup non-destructively:
- `make backup-verify BACKUP_DIR=backend/backups/backup_YYYYmmdd_HHMMSS`
- Real restore:
- `./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS`
What is included:
- PostgreSQL custom-format dump: `db.dump`
- media archive: `media.tar.gz`
- metadata file and SHA256 checksums
- default storage path: `backend/backups/`
Verification behavior:
- restores the dump into a temporary verification database
- extracts media into a temporary directory
- checks that the restored DB and media structure are readable

View File

@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential netcat-openbsd gettext \ && apt-get install -y --no-install-recommends build-essential netcat-openbsd gettext postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app

View File

@@ -140,6 +140,8 @@ PDF_TEMPLATES_DIR = MEDIA_ROOT / 'templates'
PDF_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) PDF_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
EMAIL_TEXT_DIR = MEDIA_ROOT / 'email_text' EMAIL_TEXT_DIR = MEDIA_ROOT / 'email_text'
EMAIL_TEXT_DIR.mkdir(parents=True, exist_ok=True) EMAIL_TEXT_DIR.mkdir(parents=True, exist_ok=True)
BACKUP_OUTPUT_DIR = BASE_DIR / 'backups'
BACKUP_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
ONBOARDING_SHARED_PDF_LINK = os.getenv('ONBOARDING_SHARED_PDF_LINK', '') ONBOARDING_SHARED_PDF_LINK = os.getenv('ONBOARDING_SHARED_PDF_LINK', '')
SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20')) SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20'))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext as _
from .models import WorkflowConfig
from .services import delete_from_nextcloud, upload_to_nextcloud
def _backup_root() -> Path:
root = Path(settings.BACKUP_OUTPUT_DIR)
root.mkdir(parents=True, exist_ok=True)
return root
def _metadata_path(backup_dir: Path) -> Path:
return backup_dir / 'backup_meta.json'
def _checksums_path(backup_dir: Path) -> Path:
return backup_dir / 'SHA256SUMS'
def _db_env() -> dict[str, str]:
db = settings.DATABASES['default']
env = os.environ.copy()
env['PGPASSWORD'] = str(db['PASSWORD'])
return env
def _db_base_args() -> list[str]:
db = settings.DATABASES['default']
return [
'-h', str(db['HOST']),
'-p', str(db['PORT']),
'-U', str(db['USER']),
]
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open('rb') as handle:
for chunk in iter(lambda: handle.read(65536), b''):
digest.update(chunk)
return digest.hexdigest()
def _write_checksums(backup_dir: Path, db_dump_path: Path, media_archive_path: Path) -> None:
_checksums_path(backup_dir).write_text(
f'{_sha256(db_dump_path)} {db_dump_path.name}\n{_sha256(media_archive_path)} {media_archive_path.name}\n',
encoding='utf-8',
)
def _ignorable_pg_restore(stderr: str) -> bool:
text = (stderr or '').strip()
if not text:
return False
normalized = ' '.join(line.strip() for line in text.splitlines())
return (
'unrecognized configuration parameter "transaction_timeout"' in normalized
and 'errors ignored on restore: 1' in normalized
)
def _load_metadata(backup_dir: Path) -> dict:
path = _metadata_path(backup_dir)
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding='utf-8'))
except json.JSONDecodeError:
return {}
def _save_metadata(backup_dir: Path, payload: dict) -> None:
_metadata_path(backup_dir).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8')
def list_backup_bundles() -> list[dict]:
rows: list[dict] = []
for entry in sorted(_backup_root().glob('backup_*'), reverse=True):
if not entry.is_dir():
continue
meta = _load_metadata(entry)
rows.append(
{
'name': entry.name,
'path': str(entry),
'created_at': meta.get('created_at') or '',
'verified_at': meta.get('verified_at') or '',
'verify_status': meta.get('verify_status') or '',
'summary': meta.get('verify_summary') or '',
'remote_status': meta.get('remote_status') or '',
'remote_summary': meta.get('remote_summary') or '',
'remote_target_type': meta.get('remote_target_type') or '',
'remote_path': meta.get('remote_path') or '',
'db_dump_exists': (entry / 'db.dump').exists(),
'media_archive_exists': (entry / 'media.tar.gz').exists(),
}
)
return rows
def _remote_backup_config() -> dict:
config = WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first()
if not config:
return {'enabled': False, 'target_type': '', 'nextcloud_directory': ''}
return {
'enabled': bool(config.remote_backup_enabled),
'target_type': (config.remote_backup_target_type or '').strip(),
'nextcloud_directory': (config.remote_backup_nextcloud_directory or '').strip().strip('/'),
's3_bucket': (config.remote_backup_s3_bucket or '').strip(),
'nfs_path': (config.remote_backup_nfs_path or '').strip(),
}
def _upload_bundle_remote(backup_dir: Path, payload: dict) -> dict:
remote = _remote_backup_config()
payload.update(
{
'remote_status': '',
'remote_summary': '',
'remote_target_type': remote['target_type'],
'remote_path': '',
}
)
if not remote['enabled']:
payload['remote_status'] = 'disabled'
payload['remote_summary'] = str(_('Remote Backup ist deaktiviert.'))
return payload
if remote['target_type'] != 'nextcloud':
payload['remote_status'] = 'not_implemented'
payload['remote_summary'] = _('Zieltyp %(target)s ist vorbereitet, aber noch nicht implementiert.') % {'target': remote['target_type'] or '-'}
return payload
remote_directory = remote['nextcloud_directory']
if not remote_directory:
payload['remote_status'] = 'failed'
payload['remote_summary'] = str(_('Nextcloud Backup-Verzeichnis fehlt.'))
return payload
remote_bundle_dir = f'{remote_directory}/{backup_dir.name}'
files_to_upload = ['db.dump', 'media.tar.gz', 'SHA256SUMS']
uploaded_files: list[str] = []
for filename in files_to_upload:
local_path = backup_dir / filename
if not local_path.exists():
continue
ok = upload_to_nextcloud(
local_path,
filename,
directory_override=remote_bundle_dir,
require_enabled=False,
)
if not ok:
payload['remote_status'] = 'failed'
payload['remote_summary'] = _('Upload nach Nextcloud fehlgeschlagen bei %(file)s.') % {'file': filename}
payload['remote_path'] = remote_bundle_dir
return payload
uploaded_files.append(filename)
payload['remote_status'] = 'uploaded'
payload['remote_summary'] = _('Nach Nextcloud hochgeladen: %(count)s Datei(en).') % {'count': len(uploaded_files)}
payload['remote_uploaded_at'] = timezone.now().isoformat()
payload['remote_target_type'] = 'nextcloud'
payload['remote_path'] = remote_bundle_dir
payload['remote_files'] = uploaded_files
return payload
def create_backup_bundle() -> dict:
timestamp = timezone.localtime().strftime('%Y%m%d_%H%M%S')
backup_dir = _backup_root() / f'backup_{timestamp}'
backup_dir.mkdir(parents=True, exist_ok=False)
db_dump_path = backup_dir / 'db.dump'
media_archive_path = backup_dir / 'media.tar.gz'
db = settings.DATABASES['default']
subprocess.run(
[
'pg_dump',
*_db_base_args(),
'-d', str(db['NAME']),
'-Fc',
'--no-owner',
'--no-privileges',
'-f', str(db_dump_path),
],
check=True,
env=_db_env(),
)
with tarfile.open(media_archive_path, 'w:gz') as archive:
archive.add(settings.MEDIA_ROOT, arcname='media')
payload = {
'created_at': timezone.now().isoformat(),
'postgres_db': str(db['NAME']),
'postgres_user': str(db['USER']),
'db_dump_file': db_dump_path.name,
'media_archive_file': media_archive_path.name,
'verify_status': '',
'verified_at': '',
'verify_summary': '',
}
_save_metadata(backup_dir, payload)
_write_checksums(backup_dir, db_dump_path, media_archive_path)
payload = _upload_bundle_remote(backup_dir, payload)
_save_metadata(backup_dir, payload)
if payload.get('remote_status') == 'uploaded' and payload.get('remote_path'):
upload_to_nextcloud(
_metadata_path(backup_dir),
_metadata_path(backup_dir).name,
directory_override=payload['remote_path'],
require_enabled=False,
)
return {'name': backup_dir.name, 'path': str(backup_dir)}
def verify_backup_bundle(backup_name: str) -> dict:
backup_dir = _backup_root() / backup_name
db_dump_path = backup_dir / 'db.dump'
media_archive_path = backup_dir / 'media.tar.gz'
if not backup_dir.exists() or not db_dump_path.exists() or not media_archive_path.exists():
raise FileNotFoundError(_('Backup-Dateien nicht gefunden.'))
verify_db = f'{settings.DATABASES["default"]["NAME"]}_verify_{int(timezone.now().timestamp())}'
env = _db_env()
args = _db_base_args()
meta = _load_metadata(backup_dir)
try:
subprocess.run(
['psql', *args, '-d', 'postgres', '-v', 'ON_ERROR_STOP=1', '-c', f'CREATE DATABASE "{verify_db}";'],
check=True,
env=env,
capture_output=True,
text=True,
)
restore = subprocess.run(
['pg_restore', *args, '-d', verify_db, '--no-owner', '--no-privileges', str(db_dump_path)],
env=env,
capture_output=True,
text=True,
)
if restore.returncode != 0 and not _ignorable_pg_restore(restore.stderr):
raise subprocess.CalledProcessError(
restore.returncode,
restore.args,
output=restore.stdout,
stderr=restore.stderr,
)
with connection.cursor() as cursor:
pass
table_count = subprocess.check_output(
['psql', *args, '-d', verify_db, '-t', '-A', '-c', "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';"],
env=env,
text=True,
).strip()
onboarding_count = subprocess.check_output(
['psql', *args, '-d', verify_db, '-t', '-A', '-c', 'SELECT COUNT(*) FROM workflows_onboardingrequest;'],
env=env,
text=True,
).strip()
offboarding_count = subprocess.check_output(
['psql', *args, '-d', verify_db, '-t', '-A', '-c', 'SELECT COUNT(*) FROM workflows_offboardingrequest;'],
env=env,
text=True,
).strip()
with tempfile.TemporaryDirectory(prefix='tubco_backup_verify_media_') as tmpdir:
with tarfile.open(media_archive_path, 'r:gz') as archive:
archive.extractall(tmpdir, filter='data')
media_dir = Path(tmpdir) / 'media'
if not media_dir.exists():
raise RuntimeError(_('Media-Archiv enthält kein media/-Verzeichnis.'))
media_file_count = sum(1 for path in media_dir.rglob('*') if path.is_file())
summary = _('%(tables)s Tabellen, %(onboarding)s Onboarding, %(offboarding)s Offboarding, %(media)s Mediendateien geprüft.') % {
'tables': table_count,
'onboarding': onboarding_count,
'offboarding': offboarding_count,
'media': media_file_count,
}
meta['verified_at'] = timezone.now().isoformat()
meta['verify_status'] = 'verified'
meta['verify_summary'] = summary
_save_metadata(backup_dir, meta)
return {'name': backup_name, 'summary': summary}
finally:
subprocess.run(
['psql', *args, '-d', 'postgres', '-v', 'ON_ERROR_STOP=1', '-c', f'DROP DATABASE IF EXISTS "{verify_db}";'],
check=False,
env=env,
capture_output=True,
text=True,
)
def delete_backup_bundle(backup_name: str) -> dict:
backup_dir = (_backup_root() / backup_name).resolve()
backup_root = _backup_root().resolve()
if backup_root not in backup_dir.parents:
raise ValueError(_('Ungültiger Backup-Pfad.'))
if not backup_dir.exists() or not backup_dir.is_dir():
raise FileNotFoundError(_('Backup-Dateien nicht gefunden.'))
meta = _load_metadata(backup_dir)
if meta.get('remote_status') == 'uploaded' and meta.get('remote_target_type') == 'nextcloud' and meta.get('remote_path'):
ok = delete_from_nextcloud(meta['remote_path'], directory_override='')
if not ok:
raise RuntimeError(_('Remote Backup in Nextcloud konnte nicht gelöscht werden.'))
shutil.rmtree(backup_dir)
return {'name': backup_name}

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.1.5 on 2026-03-26 00:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0034_workflowconfig_device_handover_lead_days'),
]
operations = [
migrations.AddField(
model_name='workflowconfig',
name='remote_backup_enabled',
field=models.BooleanField(default=False, verbose_name='Remote Backup aktiviert'),
),
migrations.AddField(
model_name='workflowconfig',
name='remote_backup_nextcloud_directory',
field=models.CharField(blank=True, max_length=255, verbose_name='Nextcloud Backup-Verzeichnis'),
),
migrations.AddField(
model_name='workflowconfig',
name='remote_backup_nfs_path',
field=models.CharField(blank=True, max_length=255, verbose_name='NFS Pfad (optional)'),
),
migrations.AddField(
model_name='workflowconfig',
name='remote_backup_s3_bucket',
field=models.CharField(blank=True, max_length=255, verbose_name='S3 Bucket (optional)'),
),
migrations.AddField(
model_name='workflowconfig',
name='remote_backup_target_type',
field=models.CharField(choices=[('nextcloud', 'Nextcloud'), ('s3', 'S3'), ('nfs', 'NFS')], default='nextcloud', max_length=20, verbose_name='Remote Backup Zieltyp'),
),
]

View File

@@ -387,6 +387,12 @@ class OnboardingIntroductionSession(models.Model):
class WorkflowConfig(models.Model): class WorkflowConfig(models.Model):
REMOTE_BACKUP_TARGET_CHOICES = [
('nextcloud', _('Nextcloud')),
('s3', _('S3')),
('nfs', _('NFS')),
]
name = models.CharField(max_length=120, default='Default', unique=True) name = models.CharField(max_length=120, default='Default', unique=True)
it_onboarding_email = models.EmailField(blank=True) it_onboarding_email = models.EmailField(blank=True)
general_info_email = models.EmailField(blank=True) general_info_email = models.EmailField(blank=True)
@@ -413,6 +419,16 @@ class WorkflowConfig(models.Model):
nextcloud_directory_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Verzeichnis (Override)') nextcloud_directory_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Verzeichnis (Override)')
sync_interval_seconds = models.PositiveIntegerField(default=60, verbose_name='Sync-Intervall (Sekunden)') sync_interval_seconds = models.PositiveIntegerField(default=60, verbose_name='Sync-Intervall (Sekunden)')
device_handover_lead_days = models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)') device_handover_lead_days = models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)')
remote_backup_enabled = models.BooleanField(default=False, verbose_name='Remote Backup aktiviert')
remote_backup_target_type = models.CharField(
max_length=20,
choices=REMOTE_BACKUP_TARGET_CHOICES,
default='nextcloud',
verbose_name='Remote Backup Zieltyp',
)
remote_backup_nextcloud_directory = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Backup-Verzeichnis')
remote_backup_s3_bucket = models.CharField(max_length=255, blank=True, verbose_name='S3 Bucket (optional)')
remote_backup_nfs_path = models.CharField(max_length=255, blank=True, verbose_name='NFS Pfad (optional)')
welcome_email_delay_days = models.PositiveIntegerField(default=5, verbose_name='Welcome E-Mail Verzögerung (Tage)') welcome_email_delay_days = models.PositiveIntegerField(default=5, verbose_name='Welcome E-Mail Verzögerung (Tage)')
welcome_sender_email = models.EmailField(blank=True, verbose_name='Welcome E-Mail Absender') welcome_sender_email = models.EmailField(blank=True, verbose_name='Welcome E-Mail Absender')
welcome_include_pdf = models.BooleanField(default=True, verbose_name='Welcome E-Mail mit PDF-Anhang') welcome_include_pdf = models.BooleanField(default=True, verbose_name='Welcome E-Mail mit PDF-Anhang')

View File

@@ -10,15 +10,19 @@ from .models import WorkflowConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _active_workflow_config() -> WorkflowConfig | None:
return WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first()
def is_nextcloud_enabled() -> bool: def is_nextcloud_enabled() -> bool:
config = WorkflowConfig.objects.order_by('id').first() config = _active_workflow_config()
if config and config.nextcloud_enabled_override is not None: if config and config.nextcloud_enabled_override is not None:
return bool(config.nextcloud_enabled_override) return bool(config.nextcloud_enabled_override)
return bool(settings.NEXTCLOUD_ENABLED) return bool(settings.NEXTCLOUD_ENABLED)
def is_email_test_mode() -> bool: def is_email_test_mode() -> bool:
config = WorkflowConfig.objects.order_by('id').first() config = _active_workflow_config()
if config and config.email_test_mode_override is not None: if config and config.email_test_mode_override is not None:
return bool(config.email_test_mode_override) return bool(config.email_test_mode_override)
return bool(settings.EMAIL_TEST_MODE) return bool(settings.EMAIL_TEST_MODE)
@@ -29,7 +33,7 @@ def get_email_test_redirect() -> str:
def get_nextcloud_settings() -> dict[str, str]: def get_nextcloud_settings() -> dict[str, str]:
config = WorkflowConfig.objects.order_by('id').first() config = _active_workflow_config()
base_url = ( base_url = (
config.nextcloud_base_url_override.strip() config.nextcloud_base_url_override.strip()
if config and config.nextcloud_base_url_override.strip() if config and config.nextcloud_base_url_override.strip()
@@ -58,20 +62,49 @@ def get_nextcloud_settings() -> dict[str, str]:
} }
def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool: def _nextcloud_remote_url(base_url: str, directory: str, remote_path: str) -> str:
if not is_nextcloud_enabled(): cleaned_parts = [part.strip('/') for part in [directory, remote_path] if part and part.strip('/')]
return f"{base_url}/{'/'.join(cleaned_parts)}"
def _ensure_nextcloud_directory(base_url: str, directory: str, auth: tuple[str, str], timeout: int) -> bool:
if not directory:
return False
current_parts: list[str] = []
for part in [p for p in directory.split('/') if p]:
current_parts.append(part)
response = requests.request(
'MKCOL',
f"{base_url}/{'/'.join(current_parts)}",
auth=auth,
timeout=timeout,
)
if response.status_code in (201, 301, 405):
continue
logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts))
return False
return True
def upload_to_nextcloud(local_file: Path, remote_filename: str, *, directory_override: str | None = None, require_enabled: bool = True) -> bool:
if require_enabled and not is_nextcloud_enabled():
return False return False
nc = get_nextcloud_settings() nc = get_nextcloud_settings()
base_url = nc['base_url'] base_url = nc['base_url']
directory = nc['directory'] directory_source = nc['directory'] if directory_override is None else directory_override
directory = (directory_source or '').strip('/')
if not base_url or not directory: if not base_url or not directory:
return False return False
safe_remote_name = Path(remote_filename).name safe_remote_name = Path(remote_filename).name
remote_url = f"{base_url}/{directory}/{safe_remote_name}" remote_url = _nextcloud_remote_url(base_url, directory, safe_remote_name)
retries = max(0, int(getattr(settings, 'NEXTCLOUD_UPLOAD_RETRIES', 2))) retries = max(0, int(getattr(settings, 'NEXTCLOUD_UPLOAD_RETRIES', 2)))
timeout = max(5, int(getattr(settings, 'NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', 30))) timeout = max(5, int(getattr(settings, 'NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', 30)))
auth = (nc['username'], nc['password'])
if not _ensure_nextcloud_directory(base_url, directory, auth, timeout):
return False
for attempt in range(retries + 1): for attempt in range(retries + 1):
try: try:
@@ -79,7 +112,7 @@ def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool:
response = requests.put( response = requests.put(
remote_url, remote_url,
data=handle, data=handle,
auth=(nc['username'], nc['password']), auth=auth,
timeout=timeout, timeout=timeout,
) )
if response.status_code in (200, 201, 204): if response.status_code in (200, 201, 204):
@@ -102,3 +135,21 @@ def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool:
if attempt < retries: if attempt < retries:
time.sleep(0.6 * (attempt + 1)) time.sleep(0.6 * (attempt + 1))
return False return False
def delete_from_nextcloud(remote_path: str, *, directory_override: str | None = None) -> bool:
nc = get_nextcloud_settings()
base_url = nc['base_url']
directory_source = nc['directory'] if directory_override is None else directory_override
directory = (directory_source or '').strip('/')
if not base_url:
return False
if directory_override is None and not directory:
return False
timeout = max(5, int(getattr(settings, 'NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', 30)))
response = requests.delete(
_nextcloud_remote_url(base_url, directory, remote_path),
auth=(nc['username'], nc['password']),
timeout=timeout,
)
return response.status_code in (200, 204, 404)

View File

@@ -1,10 +1,12 @@
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; } body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; }
[hidden] { display: none !important; }
.shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; } .shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; }
h1 { margin: 12px 0 6px; color: #000078; } h1 { margin: 12px 0 6px; color: #000078; }
.sub { margin: 0 0 12px; color: #54657c; } .sub { margin: 0 0 12px; color: #54657c; }
.app-messages { margin-bottom: 12px; } .app-messages { margin-bottom: 12px; }
.card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; margin-bottom: 14px; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; margin-bottom: 14px; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 10px; } .grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 10px; }
.backup-grid { grid-template-columns: minmax(280px, 720px); }
label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; } label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; }
input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); }
textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }

View File

@@ -191,6 +191,77 @@
display: none; display: none;
} }
.action-progress-modal[hidden] {
display: none;
}
.action-progress-modal {
position: fixed;
inset: 0;
z-index: 1190;
}
.action-progress-backdrop {
position: absolute;
inset: 0;
background: rgba(16, 32, 57, 0.34);
backdrop-filter: blur(5px);
animation: confirmFadeIn var(--motion-base) var(--motion-ease);
}
.action-progress-panel {
position: relative;
width: min(520px, calc(100% - 32px));
margin: min(16vh, 120px) auto 0;
padding: 22px;
border: 1px solid rgba(217, 227, 238, 0.94);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(247,250,255,0.97));
box-shadow: 0 26px 64px rgba(18, 34, 56, 0.20);
animation: confirmPopIn var(--motion-slow) var(--motion-ease);
}
.action-progress-kicker {
margin: 0 0 8px;
color: #5a6d87;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.action-progress-title {
margin: 0 0 8px;
color: #16315b;
font-size: 24px;
letter-spacing: -0.03em;
}
.action-progress-copy {
margin: 0 0 16px;
color: #5a6d87;
font-size: 14px;
line-height: 1.55;
}
.action-progress-track {
overflow: hidden;
position: relative;
height: 12px;
border-radius: 999px;
background: linear-gradient(180deg, #dce6f2, #d1dce9);
box-shadow: inset 0 1px 2px rgba(16, 32, 57, 0.10);
}
.action-progress-bar {
width: 40%;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #000078, #3b70ea);
box-shadow: 0 0 18px rgba(59, 112, 234, 0.28);
animation: actionProgressSlide 1.05s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.confirm-modal { .confirm-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -259,6 +330,11 @@ body.confirm-open {
} }
} }
@keyframes actionProgressSlide {
0% { transform: translateX(-120%); }
100% { transform: translateX(270%); }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
:root { :root {
--motion-fast: 1ms; --motion-fast: 1ms;

View File

@@ -0,0 +1,35 @@
(function () {
const modal = document.getElementById('app-action-progress');
const title = document.getElementById('app-action-progress-title');
const copy = document.getElementById('app-action-progress-copy');
if (!modal || !title || !copy) return;
function showProgress(source) {
const nextTitle = source?.dataset?.progressTitle;
const nextCopy = source?.dataset?.progressCopy;
if (nextTitle) title.textContent = nextTitle;
if (nextCopy) copy.textContent = nextCopy;
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('confirm-open');
}
window.AppActionProgress = {
show: showProgress,
};
document.addEventListener('submit', function (event) {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
const submitter = event.submitter;
if (submitter?.dataset?.progressTitle || submitter?.dataset?.progressCopy) {
showProgress(submitter);
return;
}
if (form.dataset.progressTitle || form.dataset.progressCopy) {
showProgress(form);
}
});
})();

View File

@@ -58,6 +58,9 @@
open(message).then(function (confirmed) { open(message).then(function (confirmed) {
if (!confirmed) return; if (!confirmed) return;
form.dataset.confirmBypass = '1'; form.dataset.confirmBypass = '1';
if (window.AppActionProgress && (form.dataset.progressTitle || form.dataset.progressCopy)) {
window.AppActionProgress.show(form);
}
if (typeof form.requestSubmit === 'function') { if (typeof form.requestSubmit === 'function') {
form.requestSubmit(); form.requestSubmit();
} else { } else {
@@ -79,6 +82,9 @@
open(button.dataset.confirm).then(function (confirmed) { open(button.dataset.confirm).then(function (confirmed) {
if (!confirmed) return; if (!confirmed) return;
button.dataset.confirmBypass = '1'; button.dataset.confirmBypass = '1';
if (window.AppActionProgress && (button.dataset.progressTitle || button.dataset.progressCopy || form.dataset.progressTitle || form.dataset.progressCopy)) {
window.AppActionProgress.show(button.dataset.progressTitle || button.dataset.progressCopy ? button : form);
}
if (typeof form.requestSubmit === 'function') { if (typeof form.requestSubmit === 'function') {
form.requestSubmit(button); form.requestSubmit(button);
} else { } else {

View File

@@ -0,0 +1,114 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Backup & Recovery" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}
<h1>{% trans "Backup & Recovery" %}</h1>
<p class="sub">{% trans "Datenbank- und Media-Backups erstellen und vorhandene Bundles sicher verifizieren." %}</p>
{% include 'workflows/includes/messages.html' %}
<section class="card">
<div class="toolbar">
<div>
<h2 style="margin:0;">{% trans "Aktionen" %}</h2>
<div class="hint">{% trans "Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst CLI-only." %}</div>
</div>
<form method="post" action="{% url 'create_backup_from_admin' %}" data-confirm="{% trans 'Neues Backup jetzt erstellen?' %}" data-progress-title="{% trans 'Backup wird erstellt' %}" data-progress-copy="{% trans 'Bitte warten. Datenbank- und Media-Bundle werden gerade vorbereitet.' %}">
{% csrf_token %}
<button class="btn btn-primary" type="submit">{% trans "Backup erstellen" %}</button>
</form>
</div>
</section>
<section class="card">
<h2>{% trans "Verfügbare Backup-Bundles" %}</h2>
{% if rows %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>{% trans "Bundle" %}</th>
<th>{% trans "Erstellt" %}</th>
<th>{% trans "Verifiziert" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Inhalt" %}</th>
<th>{% trans "Remote" %}</th>
<th>{% trans "Aktion" %}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td><code>{{ row.name }}</code></td>
<td>{% if row.created_at %}{{ row.created_at|slice:":16"|cut:"T" }}{% else %}-{% endif %}</td>
<td>{% if row.verified_at %}{{ row.verified_at|slice:":16"|cut:"T" }}{% else %}-{% endif %}</td>
<td>
{% if row.verify_status == 'verified' %}
<span class="badge sent">{% trans "Verifiziert" %}</span>
{% else %}
<span class="badge paused">{% trans "Nicht geprüft" %}</span>
{% endif %}
</td>
<td>
{% if row.db_dump_exists %}<span class="badge scheduled">DB</span>{% endif %}
{% if row.media_archive_exists %}<span class="badge scheduled">Media</span>{% endif %}
{% if row.summary %}
<div class="hint">{{ row.summary }}</div>
{% endif %}
</td>
<td>
{% if row.remote_status == 'uploaded' %}
<span class="badge sent">{% trans "Hochgeladen" %}</span>
{% elif row.remote_status == 'failed' %}
<span class="badge failed">{% trans "Fehlgeschlagen" %}</span>
{% elif row.remote_status == 'not_implemented' %}
<span class="badge paused">{% trans "Vorbereitet" %}</span>
{% elif row.remote_status == 'disabled' %}
<span class="badge cancelled">{% trans "Deaktiviert" %}</span>
{% else %}
<span class="badge paused">{% trans "Lokal" %}</span>
{% endif %}
{% if row.db_dump_exists or row.media_archive_exists %}
<div class="hint">{% trans "Lokal gespeichert" %}</div>
{% else %}
<div class="hint">{% trans "Lokal nicht vorhanden" %}</div>
{% endif %}
{% if row.remote_target_type %}
<div class="hint">{{ row.remote_target_type|upper }}</div>
{% endif %}
{% if row.remote_path %}
<div class="hint"><code>{{ row.remote_path }}</code></div>
{% endif %}
{% if row.remote_summary %}
<div class="hint">{{ row.remote_summary }}</div>
{% endif %}
</td>
<td>
<div class="actions">
<form method="post" action="{% url 'verify_backup_from_admin' row.name %}" data-confirm="{% trans 'Backup jetzt verifizieren?' %}" data-progress-title="{% trans 'Backup wird verifiziert' %}" data-progress-copy="{% trans 'Bitte warten. Bundle, Datenbank-Dump und Media-Archiv werden geprüft.' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Verifizieren" %}</button>
</form>
<form method="post" action="{% url 'delete_backup_from_admin' row.name %}" data-confirm="{% trans 'Backup-Bundle wirklich löschen?' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Löschen" %}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="hint">{% trans "Noch keine Backup-Bundles vorhanden." %}</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -30,7 +30,19 @@
</div> </div>
</div> </div>
</div> </div>
<div class="action-progress-modal" id="app-action-progress" hidden aria-hidden="true">
<div class="action-progress-backdrop"></div>
<div class="action-progress-panel" role="status" aria-live="polite" aria-labelledby="app-action-progress-title" aria-describedby="app-action-progress-copy">
<p class="action-progress-kicker">{% trans "Bitte warten" %}</p>
<h2 class="action-progress-title" id="app-action-progress-title">{% trans "Aktion läuft" %}</h2>
<p class="action-progress-copy" id="app-action-progress-copy">{% trans "Die Aktion wird im aktuellen Tab ausgeführt." %}</p>
<div class="action-progress-track" aria-hidden="true">
<div class="action-progress-bar"></div>
</div>
</div>
</div>
<script src="{% static 'workflows/js/confirm_dialog.js' %}"></script> <script src="{% static 'workflows/js/confirm_dialog.js' %}"></script>
<script src="{% static 'workflows/js/action_progress.js' %}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -31,6 +31,7 @@
<a href="#nextcloud">Nextcloud</a> <a href="#nextcloud">Nextcloud</a>
<a href="#builders">Builders</a> <a href="#builders">Builders</a>
<a href="#testing">Testing</a> <a href="#testing">Testing</a>
<a href="#backup">Backup</a>
<a href="#deploy">Deployment</a> <a href="#deploy">Deployment</a>
<a href="#troubleshooting">Troubleshooting</a> <a href="#troubleshooting">Troubleshooting</a>
<a href="#security">Security</a> <a href="#security">Security</a>
@@ -152,6 +153,8 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<li>Upload logic lives in <code>backend/workflows/services.py</code>.</li> <li>Upload logic lives in <code>backend/workflows/services.py</code>.</li>
<li>Feature can be globally toggled without changing environment variables.</li> <li>Feature can be globally toggled without changing environment variables.</li>
<li>Failures should degrade gracefully and not block request persistence.</li> <li>Failures should degrade gracefully and not block request persistence.</li>
<li>Remote backup currently reuses the configured Nextcloud connection, but writes into a separate backup directory configured under <code>Integrationen → Backup-Ziel</code>.</li>
<li>Do not point remote backup at the same Nextcloud directory used for normal onboarding/offboarding document uploads.</li>
</ul> </ul>
<h2 id="builders">10) Builder Architecture</h2> <h2 id="builders">10) Builder Architecture</h2>
@@ -176,6 +179,7 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<li>Current hooks include builder edits, PDF generation, welcome-email actions, integration changes, mode toggles, tests, and request deletions.</li> <li>Current hooks include builder edits, PDF generation, welcome-email actions, integration changes, mode toggles, tests, and request deletions.</li>
<li>Staff UI page: <code>/admin-tools/audit-log/</code></li> <li>Staff UI page: <code>/admin-tools/audit-log/</code></li>
<li>The current UI supports filtering by action, user, and date range. Keep filters server-side to avoid loading unbounded audit rows into the browser.</li> <li>The current UI supports filtering by action, user, and date range. Keep filters server-side to avoid loading unbounded audit rows into the browser.</li>
<li>Backup UI page: <code>/admin-tools/backups/</code> for create, verify, and delete actions. Keep real restore CLI-only.</li>
</ul> </ul>
<h2 id="testing">11) Testing and Validation</h2> <h2 id="testing">11) Testing and Validation</h2>
@@ -193,7 +197,24 @@ docker compose exec -T web python manage.py run_staging_e2e_check</code></pre>
<li>The Requests Dashboard includes a retry action for failed requests. Retries reset the error text, set the request back to <code>submitted</code>, and enqueue the appropriate Celery task again.</li> <li>The Requests Dashboard includes a retry action for failed requests. Retries reset the error text, set the request back to <code>submitted</code>, and enqueue the appropriate Celery task again.</li>
</ul> </ul>
<h2 id="deploy">12) Deployment and Release Checklist</h2> <h2 id="backup">12) Backup and Restore</h2>
<pre><code>make backup-create
make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
<ul>
<li>Backups are stored under <code>backend/backups/</code> and ignored by git.</li>
<li>Each backup contains a PostgreSQL custom-format dump, a compressed <code>media/</code> archive, metadata, and SHA256 checksums.</li>
<li>Backup metadata records both local availability and remote backup status.</li>
<li>Remote backup target configuration is managed in <code>Integrationen → Backup-Ziel</code>.</li>
<li>Current remote target support: <code>nextcloud</code> implemented, <code>s3</code> and <code>nfs</code> config-ready but not yet implemented.</li>
<li>Verification is non-destructive: it restores into a temporary verification database and extracts media into a temporary directory.</li>
<li>Real restore is explicit and destructive by design:
<pre><code>./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS</code></pre>
</li>
<li>Do not run the restore script casually against a live working dataset.</li>
<li>The staff UI uses the shared action-progress overlay for backup creation and verification so long-running actions present one standard app behavior.</li>
</ul>
<h2 id="deploy">13) Deployment and Release Checklist</h2>
<ol> <ol>
<li>Run <code>manage.py check</code></li> <li>Run <code>manage.py check</code></li>
<li>Run tests or targeted verification</li> <li>Run tests or targeted verification</li>
@@ -206,7 +227,7 @@ docker compose exec -T web python manage.py run_staging_e2e_check</code></pre>
<li>Take a snapshot commit before major next-phase work</li> <li>Take a snapshot commit before major next-phase work</li>
</ol> </ol>
<h2 id="troubleshooting">13) Troubleshooting</h2> <h2 id="troubleshooting">14) Troubleshooting</h2>
<ul> <ul>
<li><strong>Page looks stale:</strong> restart <code>web</code> and hard-refresh browser</li> <li><strong>Page looks stale:</strong> restart <code>web</code> and hard-refresh browser</li>
<li><strong>Second request hangs:</strong> inspect web logs and verify health endpoint</li> <li><strong>Second request hangs:</strong> inspect web logs and verify health endpoint</li>
@@ -217,7 +238,7 @@ docker compose exec -T web python manage.py run_staging_e2e_check</code></pre>
<li><strong>Requests dependency warning appears:</strong> verify <code>chardet==5.2.0</code> is installed in the rebuilt image and restart <code>web</code>/<code>worker</code></li> <li><strong>Requests dependency warning appears:</strong> verify <code>chardet==5.2.0</code> is installed in the rebuilt image and restart <code>web</code>/<code>worker</code></li>
</ul> </ul>
<h2 id="security">14) Security and Maintenance Notes</h2> <h2 id="security">15) Security and Maintenance Notes</h2>
<ul> <ul>
<li>Containers run as non-root <code>app</code> user.</li> <li>Containers run as non-root <code>app</code> user.</li>
<li>Keep secrets in <code>.env</code>, not in tracked files.</li> <li>Keep secrets in <code>.env</code>, not in tracked files.</li>

View File

@@ -122,6 +122,11 @@
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
</section> </section>
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Backup & Recovery" %}</h3>
<p>{% trans "Backups erstellen und sicher verifizieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/backups/">{% trans "Öffnen" %}</a>
</section>
<section class="admin-card">
<h3>{% trans "Welcome E-Mails" %}</h3> <h3>{% trans "Welcome E-Mails" %}</h3>
<p>{% trans "Geplante Welcome Mails verwalten." %}</p> <p>{% trans "Geplante Welcome Mails verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a>

View File

@@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block shell_body %} {% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} {% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}
<h1>{% trans "Integrationen Setup" %}</h1> <h1>{% trans "Integrationen Setup" %}</h1>
<p class="sub">{% trans "Verwalten Sie Nextcloud- und Mail-Konfiguration ohne Backend-Wechsel." %}</p> <p class="sub">{% trans "Verwalten Sie Nextcloud- und Mail-Konfiguration ohne Backend-Wechsel." %}</p>
@@ -19,6 +19,7 @@
<a class="tab {% if kind == 'mail' %}active{% endif %}" href="/admin-tools/integrations/?kind=mail">{% trans "Setup Mail" %}</a> <a class="tab {% if kind == 'mail' %}active{% endif %}" href="/admin-tools/integrations/?kind=mail">{% trans "Setup Mail" %}</a>
<a class="tab {% if kind == 'emails' %}active{% endif %}" href="/admin-tools/integrations/?kind=emails">{% trans "E-Mail Routing & Vorlagen" %}</a> <a class="tab {% if kind == 'emails' %}active{% endif %}" href="/admin-tools/integrations/?kind=emails">{% trans "E-Mail Routing & Vorlagen" %}</a>
<a class="tab {% if kind == 'rules' %}active{% endif %}" href="/admin-tools/integrations/?kind=rules">{% trans "Workflow-Regeln" %}</a> <a class="tab {% if kind == 'rules' %}active{% endif %}" href="/admin-tools/integrations/?kind=rules">{% trans "Workflow-Regeln" %}</a>
<a class="tab {% if kind == 'backup' %}active{% endif %}" href="/admin-tools/integrations/?kind=backup">{% trans "Backup-Ziel" %}</a>
</div> </div>
{% include 'workflows/includes/messages.html' %} {% include 'workflows/includes/messages.html' %}
@@ -51,7 +52,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Nextcloud speichern" %}</button> <button class="btn btn-primary" type="submit">{% trans "Nextcloud speichern" %}</button>
<button class="btn btn-secondary" type="submit" formaction="/test/nextcloud/">{% trans "Nextcloud-Test starten" %}</button> <button class="btn btn-secondary" type="submit" formaction="/test/nextcloud/" data-progress-title="{% trans 'Nextcloud-Test läuft' %}" data-progress-copy="{% trans 'Bitte warten. Verbindung und Upload in das konfigurierte Ziel werden geprüft.' %}">{% trans "Nextcloud-Test starten" %}</button>
</div> </div>
<div class="toggle-row"> <div class="toggle-row">
<span class="toggle-copy"> <span class="toggle-copy">
@@ -113,7 +114,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Mail speichern" %}</button> <button class="btn btn-primary" type="submit">{% trans "Mail speichern" %}</button>
<button class="btn btn-secondary" type="submit" formaction="/test/email/">{% trans "SMTP-Test starten" %}</button> <button class="btn btn-secondary" type="submit" formaction="/test/email/" data-progress-title="{% trans 'SMTP-Test läuft' %}" data-progress-copy="{% trans 'Bitte warten. SMTP-Verbindung und Testversand werden geprüft.' %}">{% trans "SMTP-Test starten" %}</button>
</div> </div>
<div class="toggle-row"> <div class="toggle-row">
<span class="toggle-copy"> <span class="toggle-copy">
@@ -363,4 +364,67 @@
</form> </form>
{% endif %} {% endif %}
{% if kind == 'backup' %}
<form class="card" method="post" action="/admin-tools/integrations/save-backup-settings/" data-remote-backup-form>
{% csrf_token %}
<div class="grid backup-grid" style="grid-template-columns:1fr">
<div class="field-full">
<label for="remote_backup_enabled">{% trans "Remote Backup aktiviert" %}</label>
<div class="check-row">
<label><input id="remote_backup_enabled" type="checkbox" name="remote_backup_enabled" {% if workflow_config.remote_backup_enabled %}checked{% endif %} onchange="window.syncRemoteBackupSettingsVisibility && window.syncRemoteBackupSettingsVisibility(this.form)" /> {% trans "Remote Kopie nach lokalem Bundle erstellen" %}</label>
</div>
</div>
<div class="grid" style="grid-template-columns:repeat(2,minmax(240px,1fr)); align-items:end;">
<div class="remote-backup-targets">
<label for="remote_backup_target_type">{% trans "Remote Backup Zieltyp" %}</label>
<select id="remote_backup_target_type" name="remote_backup_target_type" onchange="window.syncRemoteBackupSettingsVisibility && window.syncRemoteBackupSettingsVisibility(this.form)">
{% for value, label in remote_backup_target_choices %}
<option value="{{ value }}" {% if workflow_config.remote_backup_target_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="remote-backup-targets" data-remote-target="nextcloud"{% if not workflow_config.remote_backup_enabled or workflow_config.remote_backup_target_type != 'nextcloud' %} style="display:none"{% endif %}>
<label for="remote_backup_nextcloud_directory">{% trans "Nextcloud Backup-Verzeichnis" %}</label>
<input id="remote_backup_nextcloud_directory" name="remote_backup_nextcloud_directory" value="{{ workflow_config.remote_backup_nextcloud_directory }}" placeholder="Group-on-off-boarding-backups" />
</div>
<div class="remote-backup-targets" data-remote-target="s3"{% if not workflow_config.remote_backup_enabled or workflow_config.remote_backup_target_type != 's3' %} style="display:none"{% endif %}>
<label for="remote_backup_s3_bucket">{% trans "S3 Bucket (optional)" %}</label>
<input id="remote_backup_s3_bucket" name="remote_backup_s3_bucket" value="{{ workflow_config.remote_backup_s3_bucket }}" placeholder="reserved for future support" />
</div>
<div class="remote-backup-targets" data-remote-target="nfs"{% if not workflow_config.remote_backup_enabled or workflow_config.remote_backup_target_type != 'nfs' %} style="display:none"{% endif %}>
<label for="remote_backup_nfs_path">{% trans "NFS Pfad (optional)" %}</label>
<input id="remote_backup_nfs_path" name="remote_backup_nfs_path" value="{{ workflow_config.remote_backup_nfs_path }}" placeholder="/mnt/backup-share" />
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Backup-Einstellungen speichern" %}</button>
</div>
<div class="hint remote-backup-targets">{% trans "Empfehlung: Nextcloud als erstes Remote-Ziel verwenden. S3 und NFS sind als Zieltypen vorbereitet, aber noch nicht aktiv implementiert." %}</div>
<div class="hint remote-backup-targets" data-remote-target="nextcloud"{% if not workflow_config.remote_backup_enabled or workflow_config.remote_backup_target_type != 'nextcloud' %} style="display:none"{% endif %}>{% trans "Das Backup-Verzeichnis muss getrennt vom normalen Nextcloud Dokumentenordner sein, z. B. Group-on-off-boarding-backups." %}</div>
</form>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{{ block.super }}
{% if kind == 'backup' %}
<script>
window.syncRemoteBackupSettingsVisibility = function (form) {
if (!form) return;
const target = form.querySelector('#remote_backup_target_type');
if (!target) return;
const selectedTarget = String(target.value || '').toLowerCase();
form.querySelectorAll('[data-remote-target]').forEach((node) => {
node.style.display = node.dataset.remoteTarget === selectedTarget ? '' : 'none';
});
};
document.querySelectorAll('[data-remote-backup-form]').forEach((form) => {
window.syncRemoteBackupSettingsVisibility(form);
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -174,7 +174,7 @@
<ul> <ul>
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li> <li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li> <li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li> <li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li> <li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li> <li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li> <li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
@@ -197,6 +197,12 @@
<ul> <ul>
<li>Container path: <code>/app/media/pdfs/</code></li> <li>Container path: <code>/app/media/pdfs/</code></li>
<li>Host path: project <code>backend/media/pdfs/</code> via mounted volume.</li> <li>Host path: project <code>backend/media/pdfs/</code> via mounted volume.</li>
<li>Backup standard: create DB+media bundles under <code>backups/</code> and verify them with a temporary restore before using a real restore.</li>
<li>Staff UI shortcut: Admin Apps → <code>Backup &amp; Recovery</code> for create, verify, and delete actions.</li>
<li>Each backup row shows both local bundle availability and remote backup state.</li>
<li>Remote backup target configuration lives under Admin Apps → <code>Integrationen</code><code>Backup-Ziel</code>.</li>
<li>Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.</li>
<li>Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.</li>
</ul> </ul>
<h3>Deployment Notes</h3> <h3>Deployment Notes</h3>

View File

@@ -21,6 +21,7 @@ urlpatterns = [
path('admin-tools/integrations/save-emails/', views.save_email_routing_settings, name='save_email_routing_settings'), path('admin-tools/integrations/save-emails/', views.save_email_routing_settings, name='save_email_routing_settings'),
path('admin-tools/integrations/save-rules/', views.save_notification_rules, name='save_notification_rules'), path('admin-tools/integrations/save-rules/', views.save_notification_rules, name='save_notification_rules'),
path('admin-tools/integrations/save-workflow-rules/', views.save_workflow_rules, name='save_workflow_rules'), path('admin-tools/integrations/save-workflow-rules/', views.save_workflow_rules, name='save_workflow_rules'),
path('admin-tools/integrations/save-backup-settings/', views.save_backup_settings, name='save_backup_settings'),
path('admin-tools/welcome-emails/', views.welcome_emails_page, name='welcome_emails_page'), path('admin-tools/welcome-emails/', views.welcome_emails_page, name='welcome_emails_page'),
path('admin-tools/welcome-emails/settings/', views.save_welcome_email_settings, name='save_welcome_email_settings'), path('admin-tools/welcome-emails/settings/', views.save_welcome_email_settings, name='save_welcome_email_settings'),
path('admin-tools/welcome-emails/bulk-action/', views.bulk_welcome_email_action, name='bulk_welcome_email_action'), path('admin-tools/welcome-emails/bulk-action/', views.bulk_welcome_email_action, name='bulk_welcome_email_action'),
@@ -33,6 +34,10 @@ urlpatterns = [
path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'), path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'),
path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'), path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'),
path('admin-tools/audit-log/', views.audit_log_page, name='audit_log_page'), path('admin-tools/audit-log/', views.audit_log_page, name='audit_log_page'),
path('admin-tools/backups/', views.backup_recovery_page, name='backup_recovery_page'),
path('admin-tools/backups/create/', views.create_backup_from_admin, name='create_backup_from_admin'),
path('admin-tools/backups/<str:backup_name>/verify/', views.verify_backup_from_admin, name='verify_backup_from_admin'),
path('admin-tools/backups/<str:backup_name>/delete/', views.delete_backup_from_admin, name='delete_backup_from_admin'),
path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'), path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'),
path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'), path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'),
path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'), path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'),

View File

@@ -18,6 +18,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language, override from django.utils.translation import get_language, override
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
from .forms import OffboardingRequestForm, OnboardingRequestForm from .forms import OffboardingRequestForm, OnboardingRequestForm
from .form_builder import ( from .form_builder import (
DEFAULT_FIELD_ORDER, DEFAULT_FIELD_ORDER,
@@ -217,6 +218,10 @@ def _audit_action_label(action: str) -> str:
'mail_settings_saved': _('Mail-Einstellungen gespeichert'), 'mail_settings_saved': _('Mail-Einstellungen gespeichert'),
'email_routing_saved': _('E-Mail-Routing gespeichert'), 'email_routing_saved': _('E-Mail-Routing gespeichert'),
'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'), 'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'),
'backup_created': _('Backup erstellt'),
'backup_verified': _('Backup verifiziert'),
'backup_deleted': _('Backup gelöscht'),
'backup_settings_saved': _('Backup-Einstellungen gespeichert'),
} }
return labels.get(action, action.replace('_', ' ').strip().capitalize()) return labels.get(action, action.replace('_', ' ').strip().capitalize())
@@ -375,6 +380,75 @@ def audit_log_page(request):
) )
@login_required
@user_passes_test(_is_staff)
def backup_recovery_page(request):
return render(
request,
'workflows/backup_recovery.html',
{
'rows': list_backup_bundles(),
},
)
@login_required
@user_passes_test(_is_staff)
@require_POST
def create_backup_from_admin(request):
try:
result = create_backup_bundle()
_audit(
request,
'backup_created',
target_type='backup_bundle',
target_label=result['name'],
details={'path': result['path']},
)
messages.success(request, _('Backup wurde erstellt: %(name)s') % {'name': result['name']})
except Exception as exc:
messages.error(request, _('Backup konnte nicht erstellt werden: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')
@login_required
@user_passes_test(_is_staff)
@require_POST
def verify_backup_from_admin(request, backup_name: str):
try:
result = verify_backup_bundle(backup_name)
_audit(
request,
'backup_verified',
target_type='backup_bundle',
target_label=backup_name,
details={'summary': result['summary']},
)
messages.success(request, _('Backup wurde verifiziert: %(name)s') % {'name': result['name']})
except Exception as exc:
messages.error(request, _('Backup-Verifikation fehlgeschlagen: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')
@login_required
@user_passes_test(_is_staff)
@require_POST
def delete_backup_from_admin(request, backup_name: str):
try:
result = delete_backup_bundle(backup_name)
_audit(
request,
'backup_deleted',
target_type='backup_bundle',
target_label=backup_name,
details={},
)
messages.success(request, _('Backup wurde gelöscht: %(name)s') % {'name': result['name']})
except Exception as exc:
messages.error(request, _('Backup konnte nicht gelöscht werden: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')
@login_required @login_required
@user_passes_test(_is_staff) @user_passes_test(_is_staff)
def request_timeline_page(request, kind: str, request_id: int): def request_timeline_page(request, kind: str, request_id: int):
@@ -1242,7 +1316,7 @@ def intro_builder_page(request):
def integrations_setup_page(request): def integrations_setup_page(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
kind = (request.GET.get('kind') or 'nextcloud').strip().lower() kind = (request.GET.get('kind') or 'nextcloud').strip().lower()
if kind not in {'nextcloud', 'mail', 'emails', 'rules'}: if kind not in {'nextcloud', 'mail', 'emails', 'rules', 'backup'}:
kind = 'nextcloud' kind = 'nextcloud'
templates = list(NotificationTemplate.objects.all().order_by('key')) templates = list(NotificationTemplate.objects.all().order_by('key'))
system_email_config = ( system_email_config = (
@@ -1263,6 +1337,7 @@ def integrations_setup_page(request):
'rule_event_choices': NotificationRule.EVENT_CHOICES, 'rule_event_choices': NotificationRule.EVENT_CHOICES,
'rule_operator_choices': NotificationRule.OPERATOR_CHOICES, 'rule_operator_choices': NotificationRule.OPERATOR_CHOICES,
'template_choices': NotificationTemplate.TEMPLATE_CHOICES, 'template_choices': NotificationTemplate.TEMPLATE_CHOICES,
'remote_backup_target_choices': WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES,
}, },
) )
@@ -1794,12 +1869,67 @@ def save_workflow_rules(request):
'workflow_rules_saved', 'workflow_rules_saved',
target_type='workflow_config', target_type='workflow_config',
target_label='workflow_rules', target_label='workflow_rules',
details={'device_handover_lead_days': config.device_handover_lead_days}, details={
'device_handover_lead_days': config.device_handover_lead_days,
},
) )
messages.success(request, 'Workflow-Regeln wurden gespeichert.') messages.success(request, 'Workflow-Regeln wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=rules') return redirect('/admin-tools/integrations/?kind=rules')
@login_required
@user_passes_test(_is_staff)
@require_POST
def save_backup_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
target_type = (request.POST.get('remote_backup_target_type') or config.remote_backup_target_type or 'nextcloud').strip().lower()
if target_type not in {choice for choice, _ in WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES}:
target_type = 'nextcloud'
remote_backup_enabled = request.POST.get('remote_backup_enabled') == 'on'
remote_backup_nextcloud_directory = request.POST.get('remote_backup_nextcloud_directory', '').strip()
primary_nextcloud_directory = (
(config.nextcloud_directory_override or '').strip()
or settings.NEXTCLOUD_DIRECTORY.strip()
).strip('/')
if remote_backup_enabled and target_type == 'nextcloud':
if not remote_backup_nextcloud_directory:
messages.error(request, 'Bitte ein separates Nextcloud Backup-Verzeichnis angeben.')
return redirect('/admin-tools/integrations/?kind=backup')
if remote_backup_nextcloud_directory.strip('/') == primary_nextcloud_directory:
messages.error(request, 'Das Backup-Verzeichnis muss vom normalen Nextcloud Dokumentenordner getrennt sein.')
return redirect('/admin-tools/integrations/?kind=backup')
config.remote_backup_enabled = remote_backup_enabled
config.remote_backup_target_type = target_type
config.remote_backup_nextcloud_directory = remote_backup_nextcloud_directory
config.remote_backup_s3_bucket = request.POST.get('remote_backup_s3_bucket', '').strip()
config.remote_backup_nfs_path = request.POST.get('remote_backup_nfs_path', '').strip()
config.save(
update_fields=[
'device_handover_lead_days',
'remote_backup_enabled',
'remote_backup_target_type',
'remote_backup_nextcloud_directory',
'remote_backup_s3_bucket',
'remote_backup_nfs_path',
]
)
_audit(
request,
'backup_settings_saved',
target_type='workflow_config',
target_label='backup_settings',
details={
'remote_backup_enabled': config.remote_backup_enabled,
'remote_backup_target_type': config.remote_backup_target_type,
'remote_backup_nextcloud_directory': config.remote_backup_nextcloud_directory,
},
)
messages.success(request, 'Backup-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=backup')
@login_required @login_required
@user_passes_test(_is_staff) @user_passes_test(_is_staff)
@require_POST @require_POST

36
scripts/backup_create.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
set -a
. ./.env
set +a
timestamp="$(date +"%Y%m%d_%H%M%S")"
backup_dir="${1:-$ROOT_DIR/backend/backups/backup_${timestamp}}"
mkdir -p "$backup_dir"
db_dump_path="$backup_dir/db.dump"
media_archive_path="$backup_dir/media.tar.gz"
meta_path="$backup_dir/backup_meta.env"
checksums_path="$backup_dir/SHA256SUMS"
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' pg_dump -U '$POSTGRES_USER' -d '$POSTGRES_DB' -Fc --no-owner --no-privileges" > "$db_dump_path"
tar -C "$ROOT_DIR/backend" -czf "$media_archive_path" media
cat > "$meta_path" <<EOF
BACKUP_CREATED_AT=$timestamp
POSTGRES_DB=$POSTGRES_DB
POSTGRES_USER=$POSTGRES_USER
DB_DUMP_FILE=$(basename "$db_dump_path")
MEDIA_ARCHIVE_FILE=$(basename "$media_archive_path")
EOF
(
cd "$backup_dir"
shasum -a 256 "$(basename "$db_dump_path")" "$(basename "$media_archive_path")" > "$checksums_path"
)
printf '%s\n' "$backup_dir"

35
scripts/backup_restore.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 || "$1" != "--yes-restore" ]]; then
echo "Usage: $0 --yes-restore <backup_dir>" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
backup_dir="$2"
db_dump_path="$backup_dir/db.dump"
media_archive_path="$backup_dir/media.tar.gz"
if [[ ! -f "$db_dump_path" || ! -f "$media_archive_path" ]]; then
echo "Backup files missing in $backup_dir" >&2
exit 1
fi
set -a
. ./.env
set +a
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$POSTGRES_DB' AND pid <> pg_backend_pid();\""
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"DROP DATABASE IF EXISTS \\\"$POSTGRES_DB\\\";\""
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"CREATE DATABASE \\\"$POSTGRES_DB\\\";\""
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' pg_restore -U '$POSTGRES_USER' -d '$POSTGRES_DB' --no-owner --no-privileges" < "$db_dump_path"
rm -rf "$ROOT_DIR/backend/media"
mkdir -p "$ROOT_DIR/backend"
tar -C "$ROOT_DIR/backend" -xzf "$media_archive_path"
echo "Restore completed from $backup_dir"

55
scripts/backup_verify.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <backup_dir>" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
backup_dir="$1"
db_dump_path="$backup_dir/db.dump"
media_archive_path="$backup_dir/media.tar.gz"
if [[ ! -f "$db_dump_path" || ! -f "$media_archive_path" ]]; then
echo "Backup files missing in $backup_dir" >&2
exit 1
fi
set -a
. ./.env
set +a
verify_db="${POSTGRES_DB}_verify_$(date +%s)"
verify_media_dir="$(mktemp -d /tmp/tubco_backup_verify_media.XXXXXX)"
cleanup() {
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"DROP DATABASE IF EXISTS \\\"$verify_db\\\";\"" >/dev/null 2>&1 || true
chmod -R u+rwx "$verify_media_dir" >/dev/null 2>&1 || true
rm -rf "$verify_media_dir"
}
trap cleanup EXIT
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"CREATE DATABASE \\\"$verify_db\\\";\""
docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' pg_restore -U '$POSTGRES_USER' -d '$verify_db' --no-owner --no-privileges" < "$db_dump_path"
table_count="$(docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d '$verify_db' -t -A -c \"SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';\"")"
onboarding_count="$(docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d '$verify_db' -t -A -c \"SELECT COUNT(*) FROM workflows_onboardingrequest;\"")"
offboarding_count="$(docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d '$verify_db' -t -A -c \"SELECT COUNT(*) FROM workflows_offboardingrequest;\"")"
tar -C "$verify_media_dir" --no-same-owner --no-same-permissions -xzf "$media_archive_path"
if [[ ! -d "$verify_media_dir/media" ]]; then
echo "Media restore verification failed: extracted media directory missing" >&2
exit 1
fi
media_file_count="$(find "$verify_media_dir/media" -type f | wc -l | tr -d ' ')"
printf 'Verified backup: %s\n' "$backup_dir"
printf 'Restore DB: %s\n' "$verify_db"
printf 'Public tables: %s\n' "$table_count"
printf 'Onboarding rows: %s\n' "$onboarding_count"
printf 'Offboarding rows: %s\n' "$offboarding_count"
printf 'Media files: %s\n' "$media_file_count"