Merge branch 'develop'
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +45,7 @@ def send_workflow_email(
|
|||||||
f"Originale Empfänger: {', '.join(recipients)}\n\n{body}"
|
f"Originale Empfänger: {', '.join(recipients)}\n\n{body}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
send_system_email(
|
send_system_email(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=effective_body,
|
body=effective_body,
|
||||||
@@ -49,6 +53,11 @@ def send_workflow_email(
|
|||||||
attachments=[str(a) for a in (attachments or [])],
|
attachments=[str(a) for a in (attachments or [])],
|
||||||
from_email=from_email,
|
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]:
|
||||||
|
|||||||
BIN
backend/workflows/pdf_assets/templates.pdf
Normal file
BIN
backend/workflows/pdf_assets/templates.pdf
Normal file
Binary file not shown.
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
28
backend/workflows/tests/test_email_workflows.py
Normal file
28
backend/workflows/tests/test_email_workflows.py
Normal 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'],
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user