feat: add branded error pages
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled

This commit is contained in:
Md Bayazid Bostame
2026-04-01 17:34:27 +02:00
parent baf53a3274
commit 6b305e930d
11 changed files with 233 additions and 6 deletions

View File

@@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str:
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1'
FORCE_BRANDED_ERROR_PAGES = os.getenv('FORCE_BRANDED_ERROR_PAGES', '0') == '1'
APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip()
APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
@@ -84,6 +85,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'workflows.middleware.FriendlyExceptionMiddleware',
'workflows.middleware.RequestIDMiddleware',
'workflows.middleware.RateLimitMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -95,6 +97,7 @@ MIDDLEWARE = [
]
ROOT_URLCONF = 'config.urls'
CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure'
TEMPLATES = [
{

View File

@@ -7,6 +7,11 @@ from django.urls import include, path
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
from workflows import views as workflow_views
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('admin/', admin.site.urls),
path('i18n/', include('django.conf.urls.i18n')),

View 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.'),
)

View File

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

View File

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

View 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 %}

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

View 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)

View File

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