snapshot: preserve backup UX, remote target setup, and docs updates
This commit is contained in:
@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
|
||||
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/*
|
||||
|
||||
RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app
|
||||
|
||||
@@ -140,6 +140,8 @@ PDF_TEMPLATES_DIR = MEDIA_ROOT / 'templates'
|
||||
PDF_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
EMAIL_TEXT_DIR = MEDIA_ROOT / 'email_text'
|
||||
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', '')
|
||||
SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20'))
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
325
backend/workflows/backup_ops.py
Normal file
325
backend/workflows/backup_ops.py
Normal 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}
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -387,6 +387,12 @@ class OnboardingIntroductionSession(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)
|
||||
it_onboarding_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)')
|
||||
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)')
|
||||
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_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')
|
||||
|
||||
@@ -10,15 +10,19 @@ from .models import WorkflowConfig
|
||||
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:
|
||||
config = WorkflowConfig.objects.order_by('id').first()
|
||||
config = _active_workflow_config()
|
||||
if config and config.nextcloud_enabled_override is not None:
|
||||
return bool(config.nextcloud_enabled_override)
|
||||
return bool(settings.NEXTCLOUD_ENABLED)
|
||||
|
||||
|
||||
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:
|
||||
return bool(config.email_test_mode_override)
|
||||
return bool(settings.EMAIL_TEST_MODE)
|
||||
@@ -29,7 +33,7 @@ def get_email_test_redirect() -> str:
|
||||
|
||||
|
||||
def get_nextcloud_settings() -> dict[str, str]:
|
||||
config = WorkflowConfig.objects.order_by('id').first()
|
||||
config = _active_workflow_config()
|
||||
base_url = (
|
||||
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:
|
||||
if not is_nextcloud_enabled():
|
||||
def _nextcloud_remote_url(base_url: str, directory: str, remote_path: str) -> str:
|
||||
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
|
||||
|
||||
nc = get_nextcloud_settings()
|
||||
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:
|
||||
return False
|
||||
|
||||
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)))
|
||||
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):
|
||||
try:
|
||||
@@ -79,7 +112,7 @@ def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool:
|
||||
response = requests.put(
|
||||
remote_url,
|
||||
data=handle,
|
||||
auth=(nc['username'], nc['password']),
|
||||
auth=auth,
|
||||
timeout=timeout,
|
||||
)
|
||||
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:
|
||||
time.sleep(0.6 * (attempt + 1))
|
||||
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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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; }
|
||||
h1 { margin: 12px 0 6px; color: #000078; }
|
||||
.sub { margin: 0 0 12px; color: #54657c; }
|
||||
.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); }
|
||||
.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; }
|
||||
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; }
|
||||
|
||||
@@ -191,6 +191,77 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -259,6 +330,11 @@ body.confirm-open {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes actionProgressSlide {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(270%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-fast: 1ms;
|
||||
|
||||
35
backend/workflows/static/workflows/js/action_progress.js
Normal file
35
backend/workflows/static/workflows/js/action_progress.js
Normal 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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -58,6 +58,9 @@
|
||||
open(message).then(function (confirmed) {
|
||||
if (!confirmed) return;
|
||||
form.dataset.confirmBypass = '1';
|
||||
if (window.AppActionProgress && (form.dataset.progressTitle || form.dataset.progressCopy)) {
|
||||
window.AppActionProgress.show(form);
|
||||
}
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
@@ -79,6 +82,9 @@
|
||||
open(button.dataset.confirm).then(function (confirmed) {
|
||||
if (!confirmed) return;
|
||||
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') {
|
||||
form.requestSubmit(button);
|
||||
} else {
|
||||
|
||||
114
backend/workflows/templates/workflows/backup_recovery.html
Normal file
114
backend/workflows/templates/workflows/backup_recovery.html
Normal 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 %}
|
||||
@@ -30,7 +30,19 @@
|
||||
</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/action_progress.js' %}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<a href="#nextcloud">Nextcloud</a>
|
||||
<a href="#builders">Builders</a>
|
||||
<a href="#testing">Testing</a>
|
||||
<a href="#backup">Backup</a>
|
||||
<a href="#deploy">Deployment</a>
|
||||
<a href="#troubleshooting">Troubleshooting</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>Feature can be globally toggled without changing environment variables.</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>
|
||||
|
||||
<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>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>Backup UI page: <code>/admin-tools/backups/</code> for create, verify, and delete actions. Keep real restore CLI-only.</li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<li>Run <code>manage.py check</code></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>
|
||||
</ol>
|
||||
|
||||
<h2 id="troubleshooting">13) Troubleshooting</h2>
|
||||
<h2 id="troubleshooting">14) Troubleshooting</h2>
|
||||
<ul>
|
||||
<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>
|
||||
@@ -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>
|
||||
</ul>
|
||||
|
||||
<h2 id="security">14) Security and Maintenance Notes</h2>
|
||||
<h2 id="security">15) Security and Maintenance Notes</h2>
|
||||
<ul>
|
||||
<li>Containers run as non-root <code>app</code> user.</li>
|
||||
<li>Keep secrets in <code>.env</code>, not in tracked files.</li>
|
||||
|
||||
@@ -122,6 +122,11 @@
|
||||
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
|
||||
</section>
|
||||
<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>
|
||||
<p>{% trans "Geplante Welcome Mails verwalten." %}</p>
|
||||
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
<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 == '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 == 'backup' %}active{% endif %}" href="/admin-tools/integrations/?kind=backup">{% trans "Backup-Ziel" %}</a>
|
||||
</div>
|
||||
|
||||
{% include 'workflows/includes/messages.html' %}
|
||||
@@ -51,7 +52,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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 class="toggle-row">
|
||||
<span class="toggle-copy">
|
||||
@@ -113,7 +114,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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 class="toggle-row">
|
||||
<span class="toggle-copy">
|
||||
@@ -363,4 +364,67 @@
|
||||
</form>
|
||||
{% 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 %}
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<ul>
|
||||
<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>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>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>
|
||||
@@ -197,6 +197,12 @@
|
||||
<ul>
|
||||
<li>Container path: <code>/app/media/pdfs/</code></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 & 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>
|
||||
|
||||
<h3>Deployment Notes</h3>
|
||||
|
||||
@@ -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-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-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/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'),
|
||||
@@ -33,6 +34,10 @@ urlpatterns = [
|
||||
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/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/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'),
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
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 .form_builder import (
|
||||
DEFAULT_FIELD_ORDER,
|
||||
@@ -217,6 +218,10 @@ def _audit_action_label(action: str) -> str:
|
||||
'mail_settings_saved': _('Mail-Einstellungen gespeichert'),
|
||||
'email_routing_saved': _('E-Mail-Routing 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())
|
||||
|
||||
@@ -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
|
||||
@user_passes_test(_is_staff)
|
||||
def request_timeline_page(request, kind: str, request_id: int):
|
||||
@@ -1242,7 +1316,7 @@ def intro_builder_page(request):
|
||||
def integrations_setup_page(request):
|
||||
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
|
||||
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'
|
||||
templates = list(NotificationTemplate.objects.all().order_by('key'))
|
||||
system_email_config = (
|
||||
@@ -1263,6 +1337,7 @@ def integrations_setup_page(request):
|
||||
'rule_event_choices': NotificationRule.EVENT_CHOICES,
|
||||
'rule_operator_choices': NotificationRule.OPERATOR_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',
|
||||
target_type='workflow_config',
|
||||
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.')
|
||||
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
|
||||
@user_passes_test(_is_staff)
|
||||
@require_POST
|
||||
|
||||
Reference in New Issue
Block a user