diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f6fce1..aae9bd0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ Important repository areas: - `backend/workflows/`: application code - `backend/workflows/templates/workflows/`: templates and in-app documentation - `backend/workflows/static/workflows/`: CSS and JS -- `backend/media/templates/`: PDF HTML templates +- `backend/workflows/pdf_assets/`: default PDF HTML templates and default letterhead Current backend modularization: - `views.py`: thin route wrapper layer diff --git a/backend/config/settings.py b/backend/config/settings.py index a3c70d9..3a0db7a 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,9 +1,11 @@ import os import sys +import time from pathlib import Path from urllib.parse import urlsplit BASE_DIR = Path(__file__).resolve().parent.parent +STATIC_ASSET_VERSION = os.getenv('STATIC_ASSET_VERSION', str(int(time.time()))) def _split_csv_env(name: str, default: str = ''): return [item.strip() for item in os.getenv(name, default).split(',') if item.strip()] @@ -144,6 +146,14 @@ USE_TZ = True STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT.mkdir(parents=True, exist_ok=True) +STORAGES = { + 'default': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + }, + 'staticfiles': { + 'BACKEND': 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage', + }, +} MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' @@ -183,8 +193,7 @@ NEXTCLOUD_ENABLED = os.getenv('NEXTCLOUD_ENABLED', '0') == '1' PDF_OUTPUT_DIR = Path(os.getenv('PDF_OUTPUT_DIR', str(MEDIA_ROOT / 'pdfs'))) PDF_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) -PDF_TEMPLATES_DIR = MEDIA_ROOT / 'templates' -PDF_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) +PDF_TEMPLATES_DIR = Path(os.getenv('PDF_TEMPLATES_DIR', str(BASE_DIR / 'workflows' / 'pdf_assets'))) EMAIL_TEXT_DIR = MEDIA_ROOT / 'email_text' EMAIL_TEXT_DIR.mkdir(parents=True, exist_ok=True) BACKUP_OUTPUT_DIR = BASE_DIR / 'backups' diff --git a/backend/workflows/context_processors.py b/backend/workflows/context_processors.py index e31f0e4..6fac856 100644 --- a/backend/workflows/context_processors.py +++ b/backend/workflows/context_processors.py @@ -1,4 +1,5 @@ from .branding import get_branding_context, get_trial_context +from django.conf import settings from .models import UserNotification from .roles import template_role_context @@ -18,4 +19,5 @@ def role_context(request): ) else: context.update({'header_notifications': [], 'header_unread_notification_count': 0}) + context.update({'static_asset_version': settings.STATIC_ASSET_VERSION}) return context diff --git a/backend/workflows/email_workflows.py b/backend/workflows/email_workflows.py index 61c65df..e63bdf8 100644 --- a/backend/workflows/email_workflows.py +++ b/backend/workflows/email_workflows.py @@ -1,5 +1,6 @@ from datetime import timedelta from pathlib import Path +import logging from django.conf import settings from django.utils import timezone @@ -11,6 +12,8 @@ from .forms import OnboardingRequestForm from .models import NotificationRule, NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .services import get_email_test_redirect, is_email_test_mode +logger = logging.getLogger(__name__) + def resolve_workflow_emails() -> tuple[str, str, str, str, str]: config = WorkflowConfig.objects.order_by('id').first() @@ -42,13 +45,19 @@ def send_workflow_email( f"Originale Empfänger: {', '.join(recipients)}\n\n{body}" ) - send_system_email( - subject=subject, - body=effective_body, - to=effective_to, - attachments=[str(a) for a in (attachments or [])], - from_email=from_email, - ) + try: + send_system_email( + subject=subject, + body=effective_body, + to=effective_to, + attachments=[str(a) for a in (attachments or [])], + from_email=from_email, + ) + except OSError as exc: + if is_email_test_mode(): + logger.warning('Email send skipped in test mode because SMTP is unavailable: %s', exc) + return + raise def render_notification_template(template_key: str, context: dict, language_code: str | None = None) -> tuple[str, str]: diff --git a/backend/media/templates/offboarding_template.html b/backend/workflows/pdf_assets/offboarding_template.html similarity index 100% rename from backend/media/templates/offboarding_template.html rename to backend/workflows/pdf_assets/offboarding_template.html diff --git a/backend/media/templates/onboarding_intro_session_pdf.html b/backend/workflows/pdf_assets/onboarding_intro_session_pdf.html similarity index 100% rename from backend/media/templates/onboarding_intro_session_pdf.html rename to backend/workflows/pdf_assets/onboarding_intro_session_pdf.html diff --git a/backend/media/templates/onboarding_intro_template.html b/backend/workflows/pdf_assets/onboarding_intro_template.html similarity index 100% rename from backend/media/templates/onboarding_intro_template.html rename to backend/workflows/pdf_assets/onboarding_intro_template.html diff --git a/backend/media/templates/onboarding_template.html b/backend/workflows/pdf_assets/onboarding_template.html similarity index 100% rename from backend/media/templates/onboarding_template.html rename to backend/workflows/pdf_assets/onboarding_template.html diff --git a/backend/workflows/pdf_assets/templates.pdf b/backend/workflows/pdf_assets/templates.pdf new file mode 100644 index 0000000..76b07df Binary files /dev/null and b/backend/workflows/pdf_assets/templates.pdf differ diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index 48271f0..2d461e5 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -14,6 +14,9 @@ box-sizing: border-box; width: min(var(--app-shell-width), 100%); margin: 0 auto 12px; + position: relative; + z-index: 30; + overflow: visible; display: flex; justify-content: space-between; align-items: flex-start; @@ -137,6 +140,9 @@ box-sizing: border-box; width: 100%; margin: 0; + position: relative; + z-index: 30; + overflow: visible; padding: 22px 24px 18px; border: 0; border-bottom: 1px solid rgba(217, 227, 238, 0.9); @@ -174,6 +180,8 @@ display: flex; margin-left: auto; flex: 0 0 auto; + position: relative; + z-index: 31; gap: 8px; flex-wrap: wrap; justify-content: flex-end; diff --git a/backend/workflows/static/workflows/css/requests_dashboard.css b/backend/workflows/static/workflows/css/requests_dashboard.css index e523c58..92d9fad 100644 --- a/backend/workflows/static/workflows/css/requests_dashboard.css +++ b/backend/workflows/static/workflows/css/requests_dashboard.css @@ -730,14 +730,14 @@ .inline-delete .btn { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; } .intro-panel { min-width: 260px; } - details { + td.intro-panel details { border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(180deg, #ffffff, #f8fbff); overflow: hidden; } - details[open] { + td.intro-panel details[open] { border-color: #cad7e8; box-shadow: inset 0 1px 0 rgba(255,255,255,0.95); } @@ -772,7 +772,7 @@ color: var(--brand-blue); } - details[open] .intro-toggle::after { content: "−"; } + td.intro-panel details[open] .intro-toggle::after { content: "−"; } .intro-toggle::-webkit-details-marker { display: none; } .intro-menu { diff --git a/backend/workflows/static/workflows/js/confirm_dialog.js b/backend/workflows/static/workflows/js/confirm_dialog.js index ad6b06f..7caaf3a 100644 --- a/backend/workflows/static/workflows/js/confirm_dialog.js +++ b/backend/workflows/static/workflows/js/confirm_dialog.js @@ -54,6 +54,7 @@ const form = event.target; const message = form.dataset.confirm; if (!message || form.dataset.confirmBypass === '1') return; + const submitter = event.submitter || null; event.preventDefault(); open(message).then(function (confirmed) { if (!confirmed) return; @@ -62,7 +63,7 @@ window.AppActionProgress.show(form); } if (typeof form.requestSubmit === 'function') { - form.requestSubmit(); + form.requestSubmit(submitter || undefined); } else { form.submit(); } diff --git a/backend/workflows/static/workflows/js/requests_dashboard.js b/backend/workflows/static/workflows/js/requests_dashboard.js index 9d37646..80f489b 100644 --- a/backend/workflows/static/workflows/js/requests_dashboard.js +++ b/backend/workflows/static/workflows/js/requests_dashboard.js @@ -32,6 +32,7 @@ const selectAll = document.getElementById('select-all'); const rowChecks = Array.from(document.querySelectorAll('.row-select')); const selectedCount = document.getElementById('selected-count'); + const bulkForm = document.getElementById('bulk-delete-form'); if (!selectAll || !selectedCount || !rowChecks.length) return; function updateCount() { @@ -41,10 +42,31 @@ selectAll.indeterminate = checked > 0 && checked < rowChecks.length; } + function syncBulkSelection() { + if (!bulkForm) return; + bulkForm.querySelectorAll('input[type="hidden"][data-bulk-selected="1"]').forEach(function (node) { + node.remove(); + }); + rowChecks.forEach(function (checkbox) { + if (!checkbox.checked) return; + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'selected_requests'; + hidden.value = checkbox.value; + hidden.setAttribute('data-bulk-selected', '1'); + bulkForm.appendChild(hidden); + }); + } + selectAll.addEventListener('change', function () { rowChecks.forEach((c) => { c.checked = selectAll.checked; }); updateCount(); }); rowChecks.forEach((c) => c.addEventListener('change', updateCount)); + if (bulkForm) { + bulkForm.addEventListener('submit', function () { + syncBulkSelection(); + }); + } updateCount(); })(); diff --git a/backend/workflows/templates/workflows/base_shell.html b/backend/workflows/templates/workflows/base_shell.html index adf0265..54e2e6b 100644 --- a/backend/workflows/templates/workflows/base_shell.html +++ b/backend/workflows/templates/workflows/base_shell.html @@ -7,9 +7,9 @@