Merge branch 'develop'

This commit is contained in:
Md Bayazid Bostame
2026-03-30 13:00:50 +02:00
18 changed files with 109 additions and 30 deletions

View File

@@ -44,7 +44,7 @@ Important repository areas:
- `backend/workflows/`: application code - `backend/workflows/`: application code
- `backend/workflows/templates/workflows/`: templates and in-app documentation - `backend/workflows/templates/workflows/`: templates and in-app documentation
- `backend/workflows/static/workflows/`: CSS and JS - `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: Current backend modularization:
- `views.py`: thin route wrapper layer - `views.py`: thin route wrapper layer

View File

@@ -1,9 +1,11 @@
import os import os
import sys import sys
import time
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit from urllib.parse import urlsplit
BASE_DIR = Path(__file__).resolve().parent.parent 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 = ''): def _split_csv_env(name: str, default: str = ''):
return [item.strip() for item in os.getenv(name, default).split(',') if item.strip()] 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_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATIC_ROOT.mkdir(parents=True, exist_ok=True) 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_URL = '/media/'
MEDIA_ROOT = BASE_DIR / '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 = Path(os.getenv('PDF_OUTPUT_DIR', str(MEDIA_ROOT / 'pdfs')))
PDF_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) PDF_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
PDF_TEMPLATES_DIR = MEDIA_ROOT / 'templates' PDF_TEMPLATES_DIR = Path(os.getenv('PDF_TEMPLATES_DIR', str(BASE_DIR / 'workflows' / 'pdf_assets')))
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 = BASE_DIR / 'backups'

View File

@@ -1,4 +1,5 @@
from .branding import get_branding_context, get_trial_context from .branding import get_branding_context, get_trial_context
from django.conf import settings
from .models import UserNotification from .models import UserNotification
from .roles import template_role_context from .roles import template_role_context
@@ -18,4 +19,5 @@ def role_context(request):
) )
else: else:
context.update({'header_notifications': [], 'header_unread_notification_count': 0}) context.update({'header_notifications': [], 'header_unread_notification_count': 0})
context.update({'static_asset_version': settings.STATIC_ASSET_VERSION})
return context return context

View File

@@ -1,5 +1,6 @@
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
import logging
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -11,6 +12,8 @@ from .forms import OnboardingRequestForm
from .models import NotificationRule, NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .models import NotificationRule, NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .services import get_email_test_redirect, is_email_test_mode 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]: def resolve_workflow_emails() -> tuple[str, str, str, str, str]:
config = WorkflowConfig.objects.order_by('id').first() config = WorkflowConfig.objects.order_by('id').first()
@@ -42,13 +45,19 @@ def send_workflow_email(
f"Originale Empfänger: {', '.join(recipients)}\n\n{body}" f"Originale Empfänger: {', '.join(recipients)}\n\n{body}"
) )
send_system_email( try:
subject=subject, send_system_email(
body=effective_body, subject=subject,
to=effective_to, body=effective_body,
attachments=[str(a) for a in (attachments or [])], to=effective_to,
from_email=from_email, 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]: def render_notification_template(template_key: str, context: dict, language_code: str | None = None) -> tuple[str, str]:

Binary file not shown.

View File

@@ -14,6 +14,9 @@
box-sizing: border-box; box-sizing: border-box;
width: min(var(--app-shell-width), 100%); width: min(var(--app-shell-width), 100%);
margin: 0 auto 12px; margin: 0 auto 12px;
position: relative;
z-index: 30;
overflow: visible;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
@@ -137,6 +140,9 @@
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
margin: 0; margin: 0;
position: relative;
z-index: 30;
overflow: visible;
padding: 22px 24px 18px; padding: 22px 24px 18px;
border: 0; border: 0;
border-bottom: 1px solid rgba(217, 227, 238, 0.9); border-bottom: 1px solid rgba(217, 227, 238, 0.9);
@@ -174,6 +180,8 @@
display: flex; display: flex;
margin-left: auto; margin-left: auto;
flex: 0 0 auto; flex: 0 0 auto;
position: relative;
z-index: 31;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -730,14 +730,14 @@
.inline-delete .btn { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; } .inline-delete .btn { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; }
.intro-panel { min-width: 260px; } .intro-panel { min-width: 260px; }
details { td.intro-panel details {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 16px;
background: linear-gradient(180deg, #ffffff, #f8fbff); background: linear-gradient(180deg, #ffffff, #f8fbff);
overflow: hidden; overflow: hidden;
} }
details[open] { td.intro-panel details[open] {
border-color: #cad7e8; border-color: #cad7e8;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.95); box-shadow: inset 0 1px 0 rgba(255,255,255,0.95);
} }
@@ -772,7 +772,7 @@
color: var(--brand-blue); 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-toggle::-webkit-details-marker { display: none; }
.intro-menu { .intro-menu {

View File

@@ -54,6 +54,7 @@
const form = event.target; const form = event.target;
const message = form.dataset.confirm; const message = form.dataset.confirm;
if (!message || form.dataset.confirmBypass === '1') return; if (!message || form.dataset.confirmBypass === '1') return;
const submitter = event.submitter || null;
event.preventDefault(); event.preventDefault();
open(message).then(function (confirmed) { open(message).then(function (confirmed) {
if (!confirmed) return; if (!confirmed) return;
@@ -62,7 +63,7 @@
window.AppActionProgress.show(form); window.AppActionProgress.show(form);
} }
if (typeof form.requestSubmit === 'function') { if (typeof form.requestSubmit === 'function') {
form.requestSubmit(); form.requestSubmit(submitter || undefined);
} else { } else {
form.submit(); form.submit();
} }

View File

@@ -32,6 +32,7 @@
const selectAll = document.getElementById('select-all'); const selectAll = document.getElementById('select-all');
const rowChecks = Array.from(document.querySelectorAll('.row-select')); const rowChecks = Array.from(document.querySelectorAll('.row-select'));
const selectedCount = document.getElementById('selected-count'); const selectedCount = document.getElementById('selected-count');
const bulkForm = document.getElementById('bulk-delete-form');
if (!selectAll || !selectedCount || !rowChecks.length) return; if (!selectAll || !selectedCount || !rowChecks.length) return;
function updateCount() { function updateCount() {
@@ -41,10 +42,31 @@
selectAll.indeterminate = checked > 0 && checked < rowChecks.length; 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 () { selectAll.addEventListener('change', function () {
rowChecks.forEach((c) => { c.checked = selectAll.checked; }); rowChecks.forEach((c) => { c.checked = selectAll.checked; });
updateCount(); updateCount();
}); });
rowChecks.forEach((c) => c.addEventListener('change', updateCount)); rowChecks.forEach((c) => c.addEventListener('change', updateCount));
if (bulkForm) {
bulkForm.addEventListener('submit', function () {
syncBulkSelection();
});
}
updateCount(); updateCount();
})(); })();

View File

@@ -7,9 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="icon" href="{{ portal_favicon_url }}" /> <link rel="icon" href="{{ portal_favicon_url }}" />
<link rel="stylesheet" href="{% static 'workflows/css/design_system.css' %}" /> <link rel="stylesheet" href="{% static 'workflows/css/design_system.css' %}?v={{ static_asset_version }}" />
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" /> <link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}?v={{ static_asset_version }}" />
<link rel="stylesheet" href="{% static 'workflows/css/app_chrome.css' %}" /> <link rel="stylesheet" href="{% static 'workflows/css/app_chrome.css' %}?v={{ static_asset_version }}" />
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
@@ -93,8 +93,8 @@
</div> </div>
</div> </div>
</div> </div>
<script src="{% static 'workflows/js/confirm_dialog.js' %}"></script> <script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'workflows/js/action_progress.js' %}"></script> <script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -64,7 +64,7 @@
<li><code>/backend/workflows/templates/workflows/base_shell.html</code>: standard page shell for new staff-facing pages</li> <li><code>/backend/workflows/templates/workflows/base_shell.html</code>: standard page shell for new staff-facing pages</li>
<li><code>/backend/workflows/roles.py</code>: centralized role names, capability matrix, and template permission helpers</li> <li><code>/backend/workflows/roles.py</code>: centralized role names, capability matrix, and template permission helpers</li>
<li>Rule: all interactive app pages should extend <code>base_shell.html</code>; do not rebuild topbar/frame logic in page-local templates.</li> <li>Rule: all interactive app pages should extend <code>base_shell.html</code>; do not rebuild topbar/frame logic in page-local templates.</li>
<li><code>/backend/media/templates/</code>: PDF HTML templates and letterhead source files</li> <li><code>/backend/workflows/pdf_assets/</code>: default PDF HTML templates and default letterhead</li>
<li><code>/backend/media/pdfs/</code>: generated PDF outputs on host volume</li> <li><code>/backend/media/pdfs/</code>: generated PDF outputs on host volume</li>
<li><code>/backend/locale/</code>: translation catalogs</li> <li><code>/backend/locale/</code>: translation catalogs</li>
<li><code>/docker-compose.yml</code>: local runtime orchestration</li> <li><code>/docker-compose.yml</code>: local runtime orchestration</li>

View File

@@ -119,9 +119,9 @@
<h2 id="pdfs">7) PDF Engine</h2> <h2 id="pdfs">7) PDF Engine</h2>
<ul> <ul>
<li>Template source: <code>/backend/media/templates/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li> <li>Template source: <code>/backend/workflows/pdf_assets/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li>
<li>Additional onboarding intro template: <code>/backend/media/templates/onboarding_intro_template.html</code>.</li> <li>Additional onboarding intro template: <code>/backend/workflows/pdf_assets/onboarding_intro_template.html</code>.</li>
<li>Letterhead defaults to <code>/backend/media/templates/templates.pdf</code>, but can be overridden from Admin Apps → <code>Branding</code>.</li> <li>Letterhead defaults to <code>/backend/workflows/pdf_assets/templates.pdf</code>, but can be overridden from Admin Apps → <code>Branding</code>.</li>
<li>Output folder: <code>/backend/media/pdfs/</code>.</li> <li>Output folder: <code>/backend/media/pdfs/</code>.</li>
<li>Fixed PDF labels and notes are rendered through task-level DE/EN text maps and follow the request language with German fallback.</li> <li>Fixed PDF labels and notes are rendered through task-level DE/EN text maps and follow the request language with German fallback.</li>
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li> <li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li>
@@ -190,7 +190,7 @@
<li><strong>Einweisungs- und Übergabeprotokoll:</strong> staff-only <code>PDF erzeugen</code>, <code>Neu erzeugen</code>, and <code>PDF öffnen</code> actions directly on onboarding rows in the Requests Dashboard.</li> <li><strong>Einweisungs- und Übergabeprotokoll:</strong> staff-only <code>PDF erzeugen</code>, <code>Neu erzeugen</code>, and <code>PDF öffnen</code> actions directly on onboarding rows in the Requests Dashboard.</li>
<li><strong>Einweisung durchführen:</strong> staff-only live checklist page opened from onboarding rows, with draft/completed status, notes, progress tracking, and a separate live-status PDF export.</li> <li><strong>Einweisung durchführen:</strong> staff-only live checklist page opened from onboarding rows, with draft/completed status, notes, progress tracking, and a separate live-status PDF export.</li>
<li><strong>Project Wiki:</strong> this documentation page.</li> <li><strong>Project Wiki:</strong> this documentation page.</li>
<li><strong>UI Shell Standard:</strong> interactive app pages should extend <code>base_shell.html</code>; PDF templates under <code>backend/media/templates/</code> must stay separate.</li> <li><strong>UI Shell Standard:</strong> interactive app pages should extend <code>base_shell.html</code>; default PDF templates under <code>backend/workflows/pdf_assets/</code> must stay separate from runtime media.</li>
<li><strong>Release Checklist:</strong> dedicated staff-only release runbook for validation, rollout evidence, and rollback basics.</li> <li><strong>Release Checklist:</strong> dedicated staff-only release runbook for validation, rollout evidence, and rollback basics.</li>
</ul> </ul>

View File

@@ -10,7 +10,7 @@
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/requests_dashboard.css' %}" /> <link rel="stylesheet" href="{% static 'workflows/css/requests_dashboard.css' %}?v={{ static_asset_version }}" />
{% endblock %} {% endblock %}
{% block shell_body %} {% block shell_body %}
@@ -267,15 +267,15 @@
<a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a> <a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a>
{% endif %} {% endif %}
{% if can_retry_requests and row.status_key == 'failed' %} {% if can_retry_requests and row.status_key == 'failed' %}
<form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" data-confirm="{% trans 'Eintrag erneut verarbeiten?' %}"> <form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Erneut versuchen" %}</button> <button class="btn btn-secondary" type="submit" data-confirm="{% trans 'Eintrag erneut verarbeiten?' %}">{% trans "Erneut versuchen" %}</button>
</form> </form>
{% endif %} {% endif %}
{% if can_delete_requests %} {% if can_delete_requests %}
<form method="post" action="/requests/" class="inline-delete" data-confirm="{% trans 'Eintrag wirklich löschen?' %}"> <form method="post" action="/requests/" class="inline-delete">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}">{% trans "Löschen" %}</button> <button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}" data-confirm="{% trans 'Eintrag wirklich löschen?' %}">{% trans "Löschen" %}</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
@@ -298,5 +298,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{% static 'workflows/js/requests_dashboard.js' %}"></script> <script src="{% static 'workflows/js/requests_dashboard.js' %}?v={{ static_asset_version }}"></script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,28 @@
from pathlib import Path
from unittest.mock import patch
from django.test import TestCase, override_settings
from workflows.email_workflows import send_workflow_email
class SendWorkflowEmailTests(TestCase):
@override_settings(EMAIL_TEST_MODE=True, EMAIL_TEST_REDIRECT='test@example.com')
@patch('workflows.email_workflows.send_system_email', side_effect=ConnectionRefusedError('[Errno 111] Connection refused'))
def test_test_mode_suppresses_smtp_connection_errors(self, _mock_send_system_email):
send_workflow_email(
subject='Test',
body='Body',
to=['real@example.com'],
attachments=[Path('/tmp/test.pdf')],
)
@override_settings(EMAIL_TEST_MODE=False)
@patch('workflows.email_workflows.send_system_email', side_effect=ConnectionRefusedError('[Errno 111] Connection refused'))
def test_non_test_mode_keeps_smtp_connection_errors(self, _mock_send_system_email):
with self.assertRaises(ConnectionRefusedError):
send_workflow_email(
subject='Test',
body='Body',
to=['real@example.com'],
)