snapshot: preserve audit log and filtering phase

This commit is contained in:
Md Bayazid Bostame
2026-03-25 20:28:28 +01:00
parent 965531d155
commit a8f7eadbc6
9 changed files with 316 additions and 3 deletions

View File

@@ -3,7 +3,7 @@ from django.conf import settings
from django import forms from django import forms
from .emailing import send_system_email from .emailing import send_system_email
from .models import EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
@admin.register(EmployeeProfile) @admin.register(EmployeeProfile)
@@ -12,6 +12,14 @@ class EmployeeProfileAdmin(admin.ModelAdmin):
search_fields = ('full_name', 'work_email', 'department') search_fields = ('full_name', 'work_email', 'department')
@admin.register(AdminAuditLog)
class AdminAuditLogAdmin(admin.ModelAdmin):
list_display = ('created_at', 'actor_display', 'action', 'target_type', 'target_id', 'target_label')
search_fields = ('actor_display', 'action', 'target_type', 'target_label')
list_filter = ('action', 'target_type', 'created_at')
ordering = ('-created_at', '-id')
@admin.register(OnboardingRequest) @admin.register(OnboardingRequest)
class OnboardingRequestAdmin(admin.ModelAdmin): class OnboardingRequestAdmin(admin.ModelAdmin):
list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at') list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at')

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.1.5 on 2026-03-25 19:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0031_alter_offboardingrequest_preferred_language_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AdminAuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_display', models.CharField(blank=True, max_length=255)),
('action', models.CharField(max_length=120)),
('target_type', models.CharField(blank=True, max_length=80)),
('target_id', models.PositiveIntegerField(blank=True, null=True)),
('target_label', models.CharField(blank=True, max_length=255)),
('details', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admin_audit_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Admin Audit Log',
'verbose_name_plural': 'Admin Audit Logs',
'ordering': ['-created_at', '-id'],
},
),
]

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.db import models from django.db import models
from django.utils.translation import get_language from django.utils.translation import get_language
@@ -22,6 +23,32 @@ class EmployeeProfile(models.Model):
return f"{self.full_name} <{self.work_email}>" return f"{self.full_name} <{self.work_email}>"
class AdminAuditLog(models.Model):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='admin_audit_logs',
)
actor_display = models.CharField(max_length=255, blank=True)
action = models.CharField(max_length=120)
target_type = models.CharField(max_length=80, blank=True)
target_id = models.PositiveIntegerField(null=True, blank=True)
target_label = models.CharField(max_length=255, blank=True)
details = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at', '-id']
verbose_name = 'Admin Audit Log'
verbose_name_plural = 'Admin Audit Logs'
def __str__(self) -> str:
actor = self.actor_display or 'Unbekannt'
return f'{self.created_at:%Y-%m-%d %H:%M} | {actor} | {self.action}'
class OnboardingRequest(models.Model): class OnboardingRequest(models.Model):
full_name = models.CharField(max_length=255, verbose_name='Vorname und Nachname') full_name = models.CharField(max_length=255, verbose_name='Vorname und Nachname')
gender = models.CharField( gender = models.CharField(

View File

@@ -0,0 +1,88 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Audit Log" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %}
<div class="toolbar">
<div>
<h1>{% trans "Audit Log" %}</h1>
<p class="sub">{% trans "Nachvollziehbarkeit aller wichtigen Admin-Aktionen im Portal." %}</p>
</div>
</div>
<div class="card">
<form method="get" class="grid" style="margin-bottom:12px;">
<div class="field">
<label for="action">{% trans "Aktion" %}</label>
<select id="action" name="action">
<option value="">{% trans "Alle" %}</option>
{% for value in action_choices %}
<option value="{{ value }}" {% if selected_action == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="user">{% trans "Nutzer" %}</label>
<input id="user" type="text" name="user" value="{{ user_query }}" placeholder="{% trans 'Name, Benutzername oder E-Mail' %}" />
</div>
<div class="field">
<label for="date_from">{% trans "Von Datum" %}</label>
<input id="date_from" type="date" name="date_from" value="{{ date_from }}" />
</div>
<div class="field">
<label for="date_to">{% trans "Bis Datum" %}</label>
<input id="date_to" type="date" name="date_to" value="{{ date_to }}" />
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Filtern" %}</button>
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Zurücksetzen" %}</a>
</div>
</form>
<div class="table-wrap">
<table class="table-controls">
<thead>
<tr>
<th>{% trans "Zeit" %}</th>
<th>{% trans "Nutzer" %}</th>
<th>{% trans "Aktion" %}</th>
<th>{% trans "Typ" %}</th>
<th>{% trans "Ziel" %}</th>
<th>{% trans "Details" %}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.created_at|date:"Y-m-d H:i:s" }}</td>
<td>{{ row.actor_display|default:"-" }}</td>
<td><code>{{ row.action }}</code></td>
<td>{{ row.target_type|default:"-" }}</td>
<td>
{% if row.target_label %}
{{ row.target_label }}
{% if row.target_id %}<div class="hint">#{{ row.target_id }}</div>{% endif %}
{% elif row.target_id %}
#{{ row.target_id }}
{% else %}
-
{% endif %}
</td>
<td><code>{{ row.details|default:"{}" }}</code></td>
</tr>
{% empty %}
<tr>
<td colspan="6">{% trans "Noch keine Audit-Einträge vorhanden." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -169,6 +169,14 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<div class="note"> <div class="note">
Dynamic content should use explicit DE/EN fields with German fallback, not machine translation at runtime. Dynamic content should use explicit DE/EN fields with German fallback, not machine translation at runtime.
</div> </div>
<h3>Audit Trail</h3>
<ul>
<li>Model: <code>AdminAuditLog</code></li>
<li>Purpose: record staff-side mutations that affect operations or configuration.</li>
<li>Current hooks include builder edits, PDF generation, welcome-email actions, integration changes, mode toggles, tests, and request deletions.</li>
<li>Staff UI page: <code>/admin-tools/audit-log/</code></li>
<li>The current UI supports filtering by action, user, and date range. Keep filters server-side to avoid loading unbounded audit rows into the browser.</li>
</ul>
<h2 id="testing">11) Testing and Validation</h2> <h2 id="testing">11) Testing and Validation</h2>
<pre><code>docker compose exec -T web python manage.py check <pre><code>docker compose exec -T web python manage.py check

View File

@@ -131,6 +131,11 @@
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section> </section>
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Audit Log" %}</h3>
<p>{% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}</p>
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
</section>
<section class="admin-card">
<h3>{% trans "Integrationen" %}</h3> <h3>{% trans "Integrationen" %}</h3>
<p>{% trans "Nextcloud- und E-Mail-Setup." %}</p> <p>{% trans "Nextcloud- und E-Mail-Setup." %}</p>
<a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a>
@@ -192,4 +197,3 @@
</div> </div>
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -176,6 +176,7 @@
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li> <li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li> <li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li> <li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li> <li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</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>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>

View File

@@ -31,6 +31,7 @@ urlpatterns = [
path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'), path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'),
path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'), path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'),
path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'), path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'),
path('admin-tools/audit-log/', views.audit_log_page, name='audit_log_page'),
path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'), path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'),
path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'), path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'),
path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'), path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'),

View File

@@ -27,7 +27,7 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER, ONBOARDING_PAGE_ORDER,
ensure_form_field_configs, ensure_form_field_configs,
) )
from .models import EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .emailing import send_system_email from .emailing import send_system_email
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import ( from .tasks import (
@@ -117,6 +117,28 @@ def _display_user_name(user) -> str:
return (getattr(user, 'email', '') or '').strip() return (getattr(user, 'email', '') or '').strip()
def _audit(
request,
action: str,
*,
target_type: str = '',
target_id: int | None = None,
target_label: str = '',
details: dict | None = None,
) -> None:
if not getattr(request, 'user', None) or not request.user.is_authenticated:
return
AdminAuditLog.objects.create(
actor=request.user,
actor_display=_display_user_name(request.user),
action=action,
target_type=target_type,
target_id=target_id,
target_label=target_label,
details=details or {},
)
def _form_field_labels(form_type: str) -> dict[str, str]: def _form_field_labels(form_type: str) -> dict[str, str]:
if form_type == 'onboarding': if form_type == 'onboarding':
return {name: str(field.label or name) for name, field in OnboardingRequestForm.base_fields.items()} return {name: str(field.label or name) for name, field in OnboardingRequestForm.base_fields.items()}
@@ -238,6 +260,47 @@ def release_checklist_page(request):
return render(request, 'workflows/release_checklist.html') return render(request, 'workflows/release_checklist.html')
@login_required
@user_passes_test(_is_staff)
def audit_log_page(request):
action = (request.GET.get('action') or '').strip()
user_query = (request.GET.get('user') or '').strip()
date_from = (request.GET.get('date_from') or '').strip()
date_to = (request.GET.get('date_to') or '').strip()
rows_qs = AdminAuditLog.objects.select_related('actor').all()
if action:
rows_qs = rows_qs.filter(action=action)
if user_query:
rows_qs = rows_qs.filter(
Q(actor_display__icontains=user_query)
| Q(actor__username__icontains=user_query)
| Q(actor__email__icontains=user_query)
)
if date_from:
rows_qs = rows_qs.filter(created_at__date__gte=date_from)
if date_to:
rows_qs = rows_qs.filter(created_at__date__lte=date_to)
rows = list(rows_qs[:300])
action_choices = (
AdminAuditLog.objects.order_by('action').values_list('action', flat=True).distinct()
)
return render(
request,
'workflows/audit_log.html',
{
'rows': rows,
'action_choices': action_choices,
'selected_action': action,
'user_query': user_query,
'date_from': date_from,
'date_to': date_to,
},
)
@login_required @login_required
def requests_dashboard(request): def requests_dashboard(request):
if request.method == 'POST': if request.method == 'POST':
@@ -280,6 +343,13 @@ def requests_dashboard(request):
deleted_count += 1 deleted_count += 1
if deleted_count: if deleted_count:
_audit(
request,
'requests_deleted',
target_type='request',
target_label='Dashboard bulk/single delete',
details={'deleted_count': deleted_count, 'invalid_count': invalid_count, 'selected': selected},
)
messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count}) messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count})
if invalid_count: if invalid_count:
messages.warning(request, _('%(count)s Auswahl(en) konnten nicht verarbeitet werden.') % {'count': invalid_count}) messages.warning(request, _('%(count)s Auswahl(en) konnten nicht verarbeitet werden.') % {'count': invalid_count})
@@ -443,6 +513,7 @@ def generate_onboarding_intro_pdf(request, request_id: int):
pdf_path = _generate_onboarding_intro_pdf(obj, language_code=get_language()) pdf_path = _generate_onboarding_intro_pdf(obj, language_code=get_language())
obj.intro_pdf_path = str(pdf_path) obj.intro_pdf_path = str(pdf_path)
obj.save(update_fields=['intro_pdf_path']) obj.save(update_fields=['intro_pdf_path'])
_audit(request, 'intro_pdf_generated', target_type='onboarding', target_id=obj.id, target_label=obj.full_name)
messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.')) messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.'))
return redirect('requests_dashboard') return redirect('requests_dashboard')
@@ -460,6 +531,7 @@ def generate_onboarding_intro_session_pdf(request, request_id: int):
) )
session.exported_pdf_path = str(pdf_path) session.exported_pdf_path = str(pdf_path)
session.save(update_fields=['exported_pdf_path']) session.save(update_fields=['exported_pdf_path'])
_audit(request, 'intro_live_pdf_generated', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name)
messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.')) messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.'))
return redirect('onboarding_intro_session_page', request_id=request_id) return redirect('onboarding_intro_session_page', request_id=request_id)
@@ -489,17 +561,34 @@ def onboarding_intro_session_page(request, request_id: int):
session.completed_by_name = '' session.completed_by_name = ''
session.exported_pdf_path = '' session.exported_pdf_path = ''
session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path']) session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path'])
_audit(request, 'intro_session_reset', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name)
messages.success(request, _('Einweisung wurde zurückgesetzt.')) messages.success(request, _('Einweisung wurde zurückgesetzt.'))
return redirect('onboarding_intro_session_page', request_id=request_id) return redirect('onboarding_intro_session_page', request_id=request_id)
if action == 'complete': if action == 'complete':
session.status = 'completed' session.status = 'completed'
session.completed_at = timezone.now() session.completed_at = timezone.now()
session.completed_by_name = _display_user_name(request.user) session.completed_by_name = _display_user_name(request.user)
_audit(
request,
'intro_session_completed',
target_type='onboarding',
target_id=onboarding.id,
target_label=onboarding.full_name,
details={'checked_count': len([value for value in checklist_state.values() if value])},
)
messages.success(request, _('Einweisung wurde als abgeschlossen gespeichert.')) messages.success(request, _('Einweisung wurde als abgeschlossen gespeichert.'))
else: else:
session.status = 'draft' session.status = 'draft'
session.completed_at = None session.completed_at = None
session.completed_by_name = '' session.completed_by_name = ''
_audit(
request,
'intro_session_saved',
target_type='onboarding',
target_id=onboarding.id,
target_label=onboarding.full_name,
details={'checked_count': len([value for value in checklist_state.values() if value])},
)
messages.success(request, _('Einweisung wurde als Entwurf gespeichert.')) messages.success(request, _('Einweisung wurde als Entwurf gespeichert.'))
session.save() session.save()
return redirect('onboarding_intro_session_page', request_id=request_id) return redirect('onboarding_intro_session_page', request_id=request_id)
@@ -622,7 +711,10 @@ def form_builder_page(request):
messages.error(request, 'Option nicht gefunden.') messages.error(request, 'Option nicht gefunden.')
else: else:
option_category = option.category option_category = option.category
deleted_label = option.label
deleted_id = option.id
option.delete() option.delete()
_audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label)
messages.success(request, 'Option wurde gelöscht.') messages.success(request, 'Option wurde gelöscht.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}")
@@ -641,6 +733,7 @@ def form_builder_page(request):
FormOption.objects.filter(category=category).order_by('-sort_order').values_list('sort_order', flat=True).first() FormOption.objects.filter(category=category).order_by('-sort_order').values_list('sort_order', flat=True).first()
) )
FormOption.objects.create( FormOption.objects.create(
# Global form option catalog entry
category=category, category=category,
label=label, label=label,
label_en=label_en, label_en=label_en,
@@ -648,6 +741,13 @@ def form_builder_page(request):
sort_order=(next_sort + 1) if next_sort is not None else 0, sort_order=(next_sort + 1) if next_sort is not None else 0,
is_active=True, is_active=True,
) )
_audit(
request,
'form_option_added',
target_type='form_option',
target_label=label,
details={'category': category, 'label_en': label_en, 'value': value or label},
)
messages.success(request, 'Option wurde hinzugefügt.') messages.success(request, 'Option wurde hinzugefügt.')
option_category = category option_category = category
@@ -669,6 +769,7 @@ def form_builder_page(request):
messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}') messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}") return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}")
option_category = option.category option_category = option.category
_audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)})
messages.success(request, 'Optionen wurden gespeichert.') messages.success(request, 'Optionen wurden gespeichert.')
elif action == 'save_field_texts': elif action == 'save_field_texts':
@@ -682,6 +783,7 @@ def form_builder_page(request):
cfg.help_text_override = (request.POST.get(f'help_text_override_{cfg.id}') or '').strip() cfg.help_text_override = (request.POST.get(f'help_text_override_{cfg.id}') or '').strip()
cfg.help_text_override_en = (request.POST.get(f'help_text_override_en_{cfg.id}') or '').strip() cfg.help_text_override_en = (request.POST.get(f'help_text_override_en_{cfg.id}') or '').strip()
cfg.save(update_fields=['label_override', 'label_override_en', 'help_text_override', 'help_text_override_en']) cfg.save(update_fields=['label_override', 'label_override_en', 'help_text_override', 'help_text_override_en'])
_audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)})
messages.success(request, 'Feldtexte wurden gespeichert.') messages.success(request, 'Feldtexte wurden gespeichert.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}")
@@ -773,7 +875,10 @@ def intro_builder_page(request):
if delete_id: if delete_id:
item = IntroChecklistItem.objects.filter(id=delete_id).first() item = IntroChecklistItem.objects.filter(id=delete_id).first()
if item: if item:
deleted_label = item.label
deleted_id_int = item.id
item.delete() item.delete()
_audit(request, 'intro_checklist_item_deleted', target_type='intro_checklist_item', target_id=deleted_id_int, target_label=deleted_label)
messages.success(request, 'Checklistenpunkt wurde gelöscht.') messages.success(request, 'Checklistenpunkt wurde gelöscht.')
else: else:
messages.error(request, 'Checklistenpunkt nicht gefunden.') messages.error(request, 'Checklistenpunkt nicht gefunden.')
@@ -801,6 +906,7 @@ def intro_builder_page(request):
is_active=True, is_active=True,
condition_operator='always', condition_operator='always',
) )
_audit(request, 'intro_checklist_item_added', target_type='intro_checklist_item', target_label=label, details={'section': section, 'label_en': label_en})
messages.success(request, 'Checklistenpunkt wurde hinzugefügt.') messages.success(request, 'Checklistenpunkt wurde hinzugefügt.')
return redirect('intro_builder_page') return redirect('intro_builder_page')
@@ -838,6 +944,7 @@ def intro_builder_page(request):
'sort_order', 'sort_order',
] ]
) )
_audit(request, 'intro_checklist_saved', target_type='intro_checklist_item', details={'count': len(item_ids)})
messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.') messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.')
return redirect('intro_builder_page') return redirect('intro_builder_page')
@@ -942,6 +1049,7 @@ def trigger_welcome_email_now(request, schedule_id: int):
scheduled.status = 'scheduled' scheduled.status = 'scheduled'
scheduled.last_error = '' scheduled.last_error = ''
scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at'])
_audit(request, 'welcome_email_triggered_now', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde sofort angestoßen.') messages.success(request, f'Welcome E-Mail #{schedule_id} wurde sofort angestoßen.')
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@@ -1005,6 +1113,17 @@ def save_welcome_email_settings(request):
if changes: if changes:
template.save(update_fields=changes) template.save(update_fields=changes)
_audit(
request,
'welcome_email_settings_saved',
target_type='welcome_email_settings',
target_label='onboarding_welcome',
details={
'delay_days': config.welcome_email_delay_days,
'sender_email': config.welcome_sender_email,
'include_pdf': config.welcome_include_pdf,
},
)
messages.success(request, 'Welcome-E-Mail Einstellungen wurden gespeichert.') messages.success(request, 'Welcome-E-Mail Einstellungen wurden gespeichert.')
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@@ -1096,6 +1215,13 @@ def bulk_welcome_email_action(request):
'delete': 'gelöscht', 'delete': 'gelöscht',
}[action] }[action]
if success_count: if success_count:
_audit(
request,
'welcome_email_bulk_action',
target_type='welcome_email',
target_label=action,
details={'selected_ids': selected_ids, 'success_count': success_count, 'skipped_count': skipped_count},
)
messages.success(request, f'{success_count} Welcome-Eintrag/Einträge {action_label}.') messages.success(request, f'{success_count} Welcome-Eintrag/Einträge {action_label}.')
if skipped_count: if skipped_count:
messages.warning(request, f'{skipped_count} Eintrag/Einträge wurden übersprungen (Status nicht geeignet).') messages.warning(request, f'{skipped_count} Eintrag/Einträge wurden übersprungen (Status nicht geeignet).')
@@ -1117,6 +1243,7 @@ def pause_welcome_email(request, schedule_id: int):
_revoke_celery_task(scheduled.celery_task_id) _revoke_celery_task(scheduled.celery_task_id)
scheduled.status = 'paused' scheduled.status = 'paused'
scheduled.save(update_fields=['status', 'updated_at']) scheduled.save(update_fields=['status', 'updated_at'])
_audit(request, 'welcome_email_paused', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde pausiert.') messages.success(request, f'Welcome E-Mail #{schedule_id} wurde pausiert.')
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@@ -1139,6 +1266,7 @@ def resume_welcome_email(request, schedule_id: int):
scheduled.status = 'scheduled' scheduled.status = 'scheduled'
scheduled.last_error = '' scheduled.last_error = ''
scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at'])
_audit(request, 'welcome_email_resumed', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde fortgesetzt.') messages.success(request, f'Welcome E-Mail #{schedule_id} wurde fortgesetzt.')
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@@ -1159,6 +1287,7 @@ def cancel_welcome_email(request, schedule_id: int):
scheduled.status = 'cancelled' scheduled.status = 'cancelled'
scheduled.last_error = '' scheduled.last_error = ''
scheduled.save(update_fields=['status', 'last_error', 'updated_at']) scheduled.save(update_fields=['status', 'last_error', 'updated_at'])
_audit(request, 'welcome_email_cancelled', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde abgebrochen.') messages.success(request, f'Welcome E-Mail #{schedule_id} wurde abgebrochen.')
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@@ -1224,6 +1353,7 @@ def form_builder_save_order(request):
cfg.page_key = '' cfg.page_key = ''
FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key'])
_audit(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': len(configs)})
return JsonResponse({'ok': True, 'saved_count': len(configs)}) return JsonResponse({'ok': True, 'saved_count': len(configs)})
@@ -1242,6 +1372,7 @@ def send_test_email(request):
), ),
to=[settings.TEST_NOTIFICATION_EMAIL], to=[settings.TEST_NOTIFICATION_EMAIL],
) )
_audit(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()})
messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).') messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).')
return redirect('home') return redirect('home')
@@ -1265,8 +1396,10 @@ def nextcloud_test_upload(request):
ok = upload_to_nextcloud(temp_path, filename) ok = upload_to_nextcloud(temp_path, filename)
if ok: if ok:
_audit(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'success'})
messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}') messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}')
else: else:
_audit(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'error'})
messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.') messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.')
except Exception as exc: except Exception as exc:
messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}') messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}')
@@ -1285,6 +1418,7 @@ def toggle_nextcloud_enabled(request):
currently_enabled = is_nextcloud_enabled() currently_enabled = is_nextcloud_enabled()
config.nextcloud_enabled_override = not currently_enabled config.nextcloud_enabled_override = not currently_enabled
config.save(update_fields=['nextcloud_enabled_override']) config.save(update_fields=['nextcloud_enabled_override'])
_audit(request, 'nextcloud_mode_toggled', target_type='workflow_config', target_label='nextcloud', details={'enabled': config.nextcloud_enabled_override})
state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert' state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert'
messages.success(request, f'Nextcloud Upload wurde {state}.') messages.success(request, f'Nextcloud Upload wurde {state}.')
@@ -1299,6 +1433,7 @@ def toggle_email_mode(request):
currently_test_mode = is_email_test_mode() currently_test_mode = is_email_test_mode()
config.email_test_mode_override = not currently_test_mode config.email_test_mode_override = not currently_test_mode
config.save(update_fields=['email_test_mode_override']) config.save(update_fields=['email_test_mode_override'])
_audit(request, 'email_mode_toggled', target_type='workflow_config', target_label='email_mode', details={'test_mode': config.email_test_mode_override})
state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus' state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus'
messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.') messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.')
@@ -1339,6 +1474,7 @@ def save_integrations_settings(request):
config.email_password = email_password config.email_password = email_password
config.save() config.save()
_audit(request, 'integrations_saved', target_type='workflow_config', target_label='all_integrations')
messages.success(request, 'Integrations-Einstellungen wurden gespeichert.') messages.success(request, 'Integrations-Einstellungen wurden gespeichert.')
return redirect('home') return redirect('home')
@@ -1364,6 +1500,7 @@ def save_nextcloud_settings(request):
config.nextcloud_password_override = nextcloud_password config.nextcloud_password_override = nextcloud_password
config.save() config.save()
_audit(request, 'nextcloud_settings_saved', target_type='workflow_config', target_label='nextcloud')
messages.success(request, 'Nextcloud-Einstellungen wurden gespeichert.') messages.success(request, 'Nextcloud-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=nextcloud') return redirect('/admin-tools/integrations/?kind=nextcloud')
@@ -1392,6 +1529,7 @@ def save_mail_settings(request):
config.email_password = email_password config.email_password = email_password
config.save() config.save()
_audit(request, 'mail_settings_saved', target_type='workflow_config', target_label='mail')
messages.success(request, 'Mail-Einstellungen wurden gespeichert.') messages.success(request, 'Mail-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=mail') return redirect('/admin-tools/integrations/?kind=mail')
@@ -1459,6 +1597,7 @@ def save_email_routing_settings(request):
if changed: if changed:
obj.save(update_fields=changed) obj.save(update_fields=changed)
_audit(request, 'email_routing_saved', target_type='workflow_config', target_label='email_routing')
messages.success(request, 'E-Mail Routing und Vorlagen wurden gespeichert.') messages.success(request, 'E-Mail Routing und Vorlagen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=emails') return redirect('/admin-tools/integrations/?kind=emails')
@@ -1522,6 +1661,7 @@ def save_notification_rules(request):
sort_order=NotificationRule.objects.filter(event_type=new_event).count() + 1, sort_order=NotificationRule.objects.filter(event_type=new_event).count() + 1,
) )
_audit(request, 'notification_rules_saved', target_type='notification_rule')
messages.success(request, 'Benachrichtigungsregeln wurden gespeichert.') messages.success(request, 'Benachrichtigungsregeln wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=emails') return redirect('/admin-tools/integrations/?kind=emails')
@@ -1539,5 +1679,6 @@ def delete_request_from_dashboard(request, kind: str, request_id: int):
return redirect('requests_dashboard') return redirect('requests_dashboard')
obj.delete() obj.delete()
_audit(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=str(obj))
messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.') messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.')
return redirect('requests_dashboard') return redirect('requests_dashboard')