feat: add branded error pages
This commit is contained in:
71
backend/workflows/error_views.py
Normal file
71
backend/workflows/error_views.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
def _render_error(request, *, status: int, title: str, code: str, heading: str, message: str):
|
||||
return render(
|
||||
request,
|
||||
'workflows/errors/error_page.html',
|
||||
{
|
||||
'error_title': title,
|
||||
'error_code': code,
|
||||
'error_heading': heading,
|
||||
'error_message': message,
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def bad_request(request, exception=None):
|
||||
return _render_error(
|
||||
request,
|
||||
status=400,
|
||||
title=_('Ungültige Anfrage'),
|
||||
code='400',
|
||||
heading=_('Die Anfrage konnte nicht verarbeitet werden'),
|
||||
message=_('Die übermittelten Daten waren unvollständig oder ungültig. Bitte gehen Sie zurück und versuchen Sie es erneut.'),
|
||||
)
|
||||
|
||||
|
||||
def permission_denied(request, exception=None):
|
||||
return _render_error(
|
||||
request,
|
||||
status=403,
|
||||
title=_('Kein Zugriff'),
|
||||
code='403',
|
||||
heading=_('Für diese Seite fehlt die Berechtigung'),
|
||||
message=_('Sie sind angemeldet, aber für diesen Bereich nicht freigeschaltet. Wenn das nicht erwartet ist, wenden Sie sich an die Administration.'),
|
||||
)
|
||||
|
||||
|
||||
def not_found(request, exception=None):
|
||||
return _render_error(
|
||||
request,
|
||||
status=404,
|
||||
title=_('Seite nicht gefunden'),
|
||||
code='404',
|
||||
heading=_('Diese Seite gibt es nicht'),
|
||||
message=_('Die gewünschte Adresse ist nicht vorhanden oder wurde verschoben. Nutzen Sie die Startseite oder das Dashboard, um weiterzugehen.'),
|
||||
)
|
||||
|
||||
|
||||
def server_error(request):
|
||||
return _render_error(
|
||||
request,
|
||||
status=500,
|
||||
title=_('Serverfehler'),
|
||||
code='500',
|
||||
heading=_('Etwas ist schiefgelaufen'),
|
||||
message=_('Der Fehler wurde nicht sauber verarbeitet. Bitte laden Sie die Seite neu. Wenn das Problem bleibt, prüfen Sie die Server-Logs.'),
|
||||
)
|
||||
|
||||
|
||||
def csrf_failure(request, reason=''):
|
||||
return _render_error(
|
||||
request,
|
||||
status=400,
|
||||
title=_('Sicherheitsprüfung fehlgeschlagen'),
|
||||
code='400',
|
||||
heading=_('Die Sitzung konnte nicht bestätigt werden'),
|
||||
message=_('Bitte laden Sie die Seite neu und senden Sie das Formular erneut. Wenn das weiter passiert, prüfen Sie Host-, HTTPS- und CSRF-Einstellungen.'),
|
||||
)
|
||||
@@ -5,17 +5,37 @@ from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.messages.api import MessageFailure
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
from django.core.exceptions import PermissionDenied, SuspiciousOperation
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from .branding import is_trial_expired, is_trial_mode_enabled
|
||||
from .error_views import bad_request, not_found, permission_denied, server_error
|
||||
from .logging_utils import clear_request_id, set_request_id
|
||||
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
||||
|
||||
|
||||
class FriendlyExceptionMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not settings.FORCE_BRANDED_ERROR_PAGES:
|
||||
return self.get_response(request)
|
||||
|
||||
try:
|
||||
return self.get_response(request)
|
||||
except Http404 as exc:
|
||||
return not_found(request, exc)
|
||||
except PermissionDenied as exc:
|
||||
return permission_denied(request, exc)
|
||||
except SuspiciousOperation as exc:
|
||||
return bad_request(request, exc)
|
||||
|
||||
|
||||
class RequestIDMiddleware:
|
||||
HEADER_NAME = 'X-Request-ID'
|
||||
|
||||
|
||||
@@ -185,9 +185,10 @@ docker compose exec -T web python manage.py check</code></pre>
|
||||
<ol>
|
||||
<li>Preserve behavior while refactoring.</li>
|
||||
<li>Prefer shared components over page-local special cases.</li>
|
||||
<li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li>
|
||||
<li>Keep code-driven behavior and data-driven behavior mentally separate.</li>
|
||||
<li>Update documentation in the same branch when operational workflow changes.</li>
|
||||
<li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li>
|
||||
<li>Keep code-driven behavior and data-driven behavior mentally separate.</li>
|
||||
<li>Update documentation in the same branch when operational workflow changes.</li>
|
||||
<li>Keep branded error handling wired through the root URL handlers so production does not fall back to Django default error pages.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="box">
|
||||
@@ -557,6 +558,9 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
||||
<div class="note">
|
||||
The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and <code>DEBUG=0</code>.
|
||||
</div>
|
||||
<div class="note">
|
||||
If you still want branded wrong-URL and permission pages on the LAN test server while keeping <code>DJANGO_DEBUG=1</code>, enable <code>FORCE_BRANDED_ERROR_PAGES=1</code> in <code>.env.test</code>. Full branded <code>500</code> behavior still requires <code>DEBUG=0</code>, which remains the correct production-style setup.
|
||||
</div>
|
||||
|
||||
<h2 id="deploy">18) Deployment</h2>
|
||||
<h3>Test server stack</h3>
|
||||
|
||||
31
backend/workflows/templates/workflows/errors/error_page.html
Normal file
31
backend/workflows/templates/workflows/errors/error_page.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends 'workflows/base_shell.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ error_title }}{% endblock %}
|
||||
|
||||
{% block shell_body %}
|
||||
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %}
|
||||
<div class="page-stack">
|
||||
<section class="surface-card" style="max-width: 760px; margin: 0 auto;">
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<div class="eyebrow">{% trans "Fehlerseite" %}</div>
|
||||
<h1>{{ error_heading }}</h1>
|
||||
<p class="sub">{{ error_message }}</p>
|
||||
</div>
|
||||
<div class="app-stat-card" style="min-width: 120px; text-align: center;">
|
||||
<strong style="font-size: 2rem; line-height: 1;">{{ error_code }}</strong>
|
||||
<span>{% trans "Status" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-action-row">
|
||||
<a class="btn btn-primary" href="/">{% trans "Zur Startseite" %}</a>
|
||||
{% if request.user.is_authenticated %}
|
||||
<a class="btn btn-secondary" href="/requests/">{% trans "Zum Dashboard" %}</a>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary" href="/accounts/login/">{% trans "Anmelden" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
backend/workflows/tests/error_test_urls.py
Normal file
28
backend/workflows/tests/error_test_urls.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
def ok_view(request):
|
||||
return HttpResponse('ok')
|
||||
|
||||
|
||||
def deny_view(request):
|
||||
raise PermissionDenied('forbidden')
|
||||
|
||||
|
||||
def explode_view(request):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
|
||||
handler400 = 'workflows.error_views.bad_request'
|
||||
handler403 = 'workflows.error_views.permission_denied'
|
||||
handler404 = 'workflows.error_views.not_found'
|
||||
handler500 = 'workflows.error_views.server_error'
|
||||
|
||||
urlpatterns = [
|
||||
path('healthz/', ok_view, name='healthz'),
|
||||
path('raise-403/', deny_view, name='raise_403'),
|
||||
path('raise-500/', explode_view, name='raise_500'),
|
||||
path('', include('workflows.urls')),
|
||||
]
|
||||
62
backend/workflows/tests/test_error_pages.py
Normal file
62
backend/workflows/tests/test_error_pages.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from django.test import Client, RequestFactory, TestCase, override_settings
|
||||
|
||||
from workflows.error_views import csrf_failure
|
||||
|
||||
|
||||
@override_settings(
|
||||
DEBUG=False,
|
||||
ROOT_URLCONF='workflows.tests.error_test_urls',
|
||||
ALLOWED_HOSTS=['testserver'],
|
||||
)
|
||||
class ErrorPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.client.raise_request_exception = False
|
||||
|
||||
def test_custom_404_page_is_rendered(self):
|
||||
response = self.client.get('/missing-page/')
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||
self.assertContains(response, '404', status_code=404)
|
||||
|
||||
def test_custom_403_page_is_rendered(self):
|
||||
response = self.client.get('/raise-403/')
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||
self.assertContains(response, '403', status_code=403)
|
||||
|
||||
def test_custom_500_page_is_rendered(self):
|
||||
response = self.client.get('/raise-500/')
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||
self.assertContains(response, '500', status_code=500)
|
||||
|
||||
def test_csrf_failure_view_uses_custom_400_page(self):
|
||||
request = RequestFactory().post('/test/')
|
||||
|
||||
response = csrf_failure(request)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertContains(response, '400', status_code=400)
|
||||
|
||||
|
||||
@override_settings(
|
||||
DEBUG=True,
|
||||
FORCE_BRANDED_ERROR_PAGES=True,
|
||||
ROOT_URLCONF='workflows.tests.error_test_urls',
|
||||
ALLOWED_HOSTS=['testserver'],
|
||||
)
|
||||
class ForcedBrandedErrorPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.client.raise_request_exception = False
|
||||
|
||||
def test_missing_url_uses_branded_404_even_with_debug_enabled(self):
|
||||
response = self.client.get('/missing-page/')
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||
self.assertContains(response, '404', status_code=404)
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
|
||||
from . import views
|
||||
from . import error_views, views
|
||||
|
||||
urlpatterns = [
|
||||
path('healthz/', views.healthz, name='healthz'),
|
||||
@@ -65,4 +65,5 @@ urlpatterns = [
|
||||
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
|
||||
path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
|
||||
path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'),
|
||||
re_path(r'^.*$', error_views.not_found, name='not_found_fallback'),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user