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/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

View File

@@ -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'

View File

@@ -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

View File

@@ -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]:

Binary file not shown.

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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();
}

View File

@@ -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();
})();

View File

@@ -7,9 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{% endblock %}</title>
<link rel="icon" href="{{ portal_favicon_url }}" />
<link rel="stylesheet" href="{% static 'workflows/css/design_system.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/app_chrome.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/design_system.css' %}?v={{ static_asset_version }}" />
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}?v={{ static_asset_version }}" />
<link rel="stylesheet" href="{% static 'workflows/css/app_chrome.css' %}?v={{ static_asset_version }}" />
{% block extra_css %}{% endblock %}
{% block extra_head %}{% endblock %}
</head>
@@ -93,8 +93,8 @@
</div>
</div>
</div>
<script src="{% static 'workflows/js/confirm_dialog.js' %}"></script>
<script src="{% static 'workflows/js/action_progress.js' %}"></script>
<script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</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/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><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/locale/</code>: translation catalogs</li>
<li><code>/docker-compose.yml</code>: local runtime orchestration</li>

View File

@@ -119,9 +119,9 @@
<h2 id="pdfs">7) PDF Engine</h2>
<ul>
<li>Template source: <code>/backend/media/templates/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>Letterhead defaults to <code>/backend/media/templates/templates.pdf</code>, but can be overridden from Admin Apps → <code>Branding</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/workflows/pdf_assets/onboarding_intro_template.html</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>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>
@@ -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>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>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>
</ul>

View File

@@ -10,7 +10,7 @@
{% 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 %}
{% block shell_body %}
@@ -267,15 +267,15 @@
<a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a>
{% endif %}
{% 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 %}
<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>
{% endif %}
{% 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 %}
<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>
{% endif %}
</td>
@@ -298,5 +298,5 @@
{% endblock %}
{% 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 %}

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'],
)