feat: Implement notification rules and email notifications for risk events

This commit is contained in:
Kevin Heyer 2025-09-10 14:26:29 +02:00
parent ebfcbddd5c
commit 86525d9ab0
11 changed files with 626 additions and 56 deletions

Binary file not shown.

Binary file not shown.

View file

@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: wira-risk-management\n" "Project-Id-Version: wira-risk-management\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-10 12:51+0200\n" "POT-Creation-Date: 2025-09-10 13:44+0200\n"
"PO-Revision-Date: 2025-09-09 13:45+0200\n" "PO-Revision-Date: 2025-09-09 13:45+0200\n"
"Last-Translator: Kevin Heyer <kevin@example.com>\n" "Last-Translator: Kevin Heyer <kevin@example.com>\n"
"Language-Team: German\n" "Language-Team: German\n"
@ -40,6 +40,7 @@ msgid "Reviews"
msgstr "Prüfung" msgstr "Prüfung"
#: risks/admin.py:19 risks/models.py:258 templates/base.html:37 #: risks/admin.py:19 risks/models.py:258 templates/base.html:37
#: templates/risks/item_risk.html:248
msgid "Incidents" msgid "Incidents"
msgstr "Vorfälle" msgstr "Vorfälle"
@ -47,7 +48,7 @@ msgstr "Vorfälle"
msgid "Users" msgid "Users"
msgstr "Benutzer" msgstr "Benutzer"
#: risks/admin.py:133 risks/models.py:302 #: risks/admin.py:133 risks/models.py:302 templates/risks/item_risk.html:287
msgid "User" msgid "User"
msgstr "Benutzer" msgstr "Benutzer"
@ -60,6 +61,7 @@ msgid "Mark selected as read"
msgstr "Alle als gelesen Markieren" msgstr "Alle als gelesen Markieren"
#: risks/admin.py:150 #: risks/admin.py:150
#, python-format
msgid "%(n)d notifications marked as read." msgid "%(n)d notifications marked as read."
msgstr "%(n)d Benachrichtigungen wurden als gelesen Markiert" msgstr "%(n)d Benachrichtigungen wurden als gelesen Markiert"
@ -68,6 +70,7 @@ msgid "Mark selected as unread"
msgstr "Alle als gelesen Markieren" msgstr "Alle als gelesen Markieren"
#: risks/admin.py:155 #: risks/admin.py:155
#, python-format
msgid "%(n)d notifications marked as unread." msgid "%(n)d notifications marked as unread."
msgstr "%(n)d Benachrichtigungen wurden als ungelesen Markiert" msgstr "%(n)d Benachrichtigungen wurden als ungelesen Markiert"
@ -77,15 +80,15 @@ msgstr ""
#: risks/admin.py:160 #: risks/admin.py:160
msgid "%(n)d notifications marked as sent." msgid "%(n)d notifications marked as sent."
msgstr "Alle Benachrichtigungen wurden als gelesen Markiert" msgstr "%(n)d Benachrichtigungen wurden als gelesen Markiert"
#: risks/admin.py:162 #: risks/admin.py:162
msgid "Mark selected as unsent" msgid "Mark selected as unsent"
msgstr "" msgstr "Auswahl als ungesendet markieren"
#: risks/admin.py:165 #: risks/admin.py:165
msgid "%(n)d notifications marked as unsent." msgid "%(n)d notifications marked as unsent."
msgstr "Alle Benachrichtigungen wurden als gelesen Markiert" msgstr "%(n)d Benachrichtigungen wurden als gelesen Markiert"
#: risks/admin.py:177 #: risks/admin.py:177
msgid "SSO Information" msgid "SSO Information"
@ -104,15 +107,17 @@ msgid "Risk Management"
msgstr "Risikomanagement" msgstr "Risikomanagement"
#: risks/forms.py:9 risks/forms.py:16 risks/forms.py:23 risks/models.py:73 #: risks/forms.py:9 risks/forms.py:16 risks/forms.py:23 risks/models.py:73
#: templates/risks/item_risk.html:64 templates/risks/item_risk.html:202
#: templates/risks/item_risk.html:256
msgid "Status" msgid "Status"
msgstr "Status" msgstr "Status"
#: risks/forms.py:30 risks/models.py:42 templates/risks/item_risk.html:136 #: risks/forms.py:30 risks/models.py:42 templates/risks/item_risk.html:177
msgid "Review required" msgid "Review required"
msgstr "Prüfung nötig" msgstr "Prüfung nötig"
#: risks/models.py:35 templates/risks/list_risks.html:18 #: risks/models.py:35 templates/risks/item_risk.html:11
#: templates/risks/list_risks.html:83 #: templates/risks/list_risks.html:18 templates/risks/list_risks.html:83
msgid "Risk" msgid "Risk"
msgstr "Risiko" msgstr "Risiko"
@ -177,6 +182,7 @@ msgid "Availability"
msgstr "Verfügbarkeit" msgstr "Verfügbarkeit"
#: risks/models.py:64 risks/models.py:200 risks/models.py:265 #: risks/models.py:64 risks/models.py:200 risks/models.py:265
#: templates/risks/item_risk.html:201
msgid "Title" msgid "Title"
msgstr "Titel" msgstr "Titel"
@ -184,19 +190,20 @@ msgstr "Titel"
msgid "Description" msgid "Description"
msgstr "Beschreibung" msgstr "Beschreibung"
#: risks/models.py:66 #: risks/models.py:66 templates/risks/item_risk.html:53
msgid "Asset" msgid "Asset"
msgstr "Asset" msgstr "Asset"
#: risks/models.py:67 #: risks/models.py:67 templates/risks/item_risk.html:54
msgid "Process" msgid "Process"
msgstr "Prozess" msgstr "Prozess"
#: risks/models.py:68 templates/risks/list_risks.html:85 #: risks/models.py:68 templates/risks/item_risk.html:55
#: templates/risks/list_risks.html:85
msgid "Category" msgid "Category"
msgstr "Kategorie" msgstr "Kategorie"
#: risks/models.py:69 #: risks/models.py:69 templates/risks/item_risk.html:68
msgid "Created at" msgid "Created at"
msgstr "Erstellt am" msgstr "Erstellt am"
@ -244,7 +251,7 @@ msgstr "Audit-Log"
msgid "Auditlogs" msgid "Auditlogs"
msgstr "Audit-Logs" msgstr "Audit-Logs"
#: risks/models.py:257 #: risks/models.py:257 templates/risks/item_risk.html:255
msgid "Incident" msgid "Incident"
msgstr "Vorfall" msgstr "Vorfall"
@ -264,7 +271,7 @@ msgstr "Gemeldet von"
msgid "Notification" msgid "Notification"
msgstr "Benachrichtigung" msgstr "Benachrichtigung"
#: risks/models.py:280 templates/base.html:88 #: risks/models.py:280 templates/base.html:78
#: templates/risks/notifications.html:4 #: templates/risks/notifications.html:4
msgid "Notifications" msgid "Notifications"
msgstr "Nachrichten" msgstr "Nachrichten"
@ -377,31 +384,32 @@ msgstr "Restrisiko geprüft"
msgid "Dashboard" msgid "Dashboard"
msgstr "Dashboard" msgstr "Dashboard"
#: templates/base.html:35 templates/risks/list_risks.html:4 #: templates/base.html:35 templates/risks/item_risk.html:4
#: templates/risks/list_risks.html:4
msgid "Risk analysis" msgid "Risk analysis"
msgstr "Risikoanalyse" msgstr "Risikoanalyse"
#: templates/base.html:70 #: templates/base.html:73
msgid "AdminCP" msgid "AdminCP"
msgstr "Adminbereich" msgstr "Adminbereich"
#: templates/base.html:76 #: templates/base.html:86
msgid "Derk Mode" msgid "Derk Mode"
msgstr "Dark Mode" msgstr "Dark Mode"
#: templates/base.html:82 #: templates/base.html:92
msgid "Logout" msgid "Logout"
msgstr "Logout" msgstr "Logout"
#: templates/base.html:104 #: templates/base.html:107
msgid "Login" msgid "Login"
msgstr "Login" msgstr "Login"
#: templates/base.html:139 templates/base.html:146 #: templates/base.html:142 templates/base.html:149
msgid "Light Mode" msgid "Light Mode"
msgstr "Light Mode" msgstr "Light Mode"
#: templates/base.html:149 #: templates/base.html:152
msgid "Dark Mode" msgid "Dark Mode"
msgstr "Dark Mode" msgstr "Dark Mode"
@ -433,34 +441,132 @@ msgstr "Vorfälle nach Status"
msgid "Risks by CIA" msgid "Risks by CIA"
msgstr "CIA Risiken" msgstr "CIA Risiken"
#: templates/risks/item_risk.html:18
#, fuzzy
#| msgid "Reviews"
msgid "Overview"
msgstr "Prüfung"
#: templates/risks/item_risk.html:34 #: templates/risks/item_risk.html:34
msgid "Update status" msgid "Update status"
msgstr "Status Aktualisiert" msgstr "Status Aktualisiert"
#: templates/risks/item_risk.html:89 templates/risks/item_risk.html:147 #: templates/risks/item_risk.html:57
msgid "Protection goals"
msgstr "Schutzziele"
#: templates/risks/item_risk.html:61
msgid "Not yet assigned"
msgstr "Keine Zugewiesenen Ziele"
#: templates/risks/item_risk.html:67
msgid "Risk owner"
msgstr "Risikoeigner"
#: templates/risks/item_risk.html:69
msgid "updated at"
msgstr "Aktualisiert am"
#: templates/risks/item_risk.html:70
msgid "Resubmission"
msgstr "Wiedervorlagedatum"
#: templates/risks/item_risk.html:76
msgid "Risk assessment"
msgstr "Risikomanagement"
#: templates/risks/item_risk.html:84
msgid "Gross (before measures)"
msgstr "Brutto (vor Maßnahmen)"
#: templates/risks/item_risk.html:89 templates/risks/item_risk.html:135
#: templates/risks/list_risks.html:86 #: templates/risks/list_risks.html:86
msgid "Likelihood" msgid "Likelihood"
msgstr "Eintritt" msgstr "Eintritt"
#: templates/risks/item_risk.html:98 templates/risks/item_risk.html:156 #: templates/risks/item_risk.html:90 templates/risks/item_risk.html:136
msgid "Probability of occurrence"
msgstr "Eintrittswahrscheinlichkeit"
#: templates/risks/item_risk.html:98 templates/risks/item_risk.html:144
#: templates/risks/list_risks.html:87 #: templates/risks/list_risks.html:87
msgid "Impact" msgid "Impact"
msgstr "Schaden" msgstr "Schaden"
#: templates/risks/item_risk.html:107 templates/risks/item_risk.html:165 #: templates/risks/item_risk.html:99 templates/risks/item_risk.html:145
msgid "Extent of damage"
msgstr "Schadensausmaß"
#: templates/risks/item_risk.html:107 templates/risks/item_risk.html:108
#: templates/risks/item_risk.html:153 templates/risks/item_risk.html:154
#: templates/risks/list_risks.html:89 #: templates/risks/list_risks.html:89
msgid "Level" msgid "Level"
msgstr "Stufe" msgstr "Stufe"
#: templates/risks/item_risk.html:116 templates/risks/item_risk.html:173 #: templates/risks/item_risk.html:116 templates/risks/item_risk.html:117
#: templates/risks/item_risk.html:161 templates/risks/item_risk.html:163
#: templates/risks/list_risks.html:88 #: templates/risks/list_risks.html:88
msgid "Score" msgid "Score"
msgstr "Score" msgstr "Score"
#: templates/risks/item_risk.html:139 #: templates/risks/item_risk.html:130
msgid "Net (after measures)"
msgstr "Netto (nach Maßnahmen)"
#: templates/risks/item_risk.html:169
msgid "No net risk recorded yet."
msgstr "Kein Restrisiko vergeben"
#: templates/risks/item_risk.html:180
msgid "Save" msgid "Save"
msgstr "Speichern" msgstr "Speichern"
#: templates/risks/item_risk.html:194
msgid "Measures"
msgstr "Maßnahmen"
#: templates/risks/item_risk.html:203
msgid "Deadline"
msgstr "Frist"
#: templates/risks/item_risk.html:204
msgid "Responsible"
msgstr "Verantwortliche/r"
#: templates/risks/item_risk.html:205
msgid "Link"
msgstr "Link"
#: templates/risks/item_risk.html:239
msgid "No measures recorded."
msgstr "Keine Maßnahmen gefunden."
#: templates/risks/item_risk.html:257
#, fuzzy
#| msgid "Reported by"
msgid "Reported on"
msgstr "Gemeldet von"
#: templates/risks/item_risk.html:271
msgid "No incidents recorded."
msgstr "Keine Vorfälle gefunden."
#: templates/risks/item_risk.html:279
msgid "History"
msgstr ""
#: templates/risks/item_risk.html:286
msgid "Time"
msgstr "Zeitpunkt"
#: templates/risks/item_risk.html:288
msgid "Action"
msgstr "Aktion"
#: templates/risks/item_risk.html:302
msgid "No History found."
msgstr "Keine Historie vorhanden"
#: templates/risks/list_risks.html:9 #: templates/risks/list_risks.html:9
msgid "Filter" msgid "Filter"
msgstr "Filter" msgstr "Filter"
@ -478,6 +584,10 @@ msgstr "Risikoeigner"
msgid "Asset / Process" msgid "Asset / Process"
msgstr "Asset / Prozess" msgstr "Asset / Prozess"
#: templates/risks/list_risks.html:152
msgid "No risks present"
msgstr "Aktuell keine Risiken"
#: templates/risks/notifications.html:12 #: templates/risks/notifications.html:12
msgid "Unread" msgid "Unread"
msgstr "Ungelesen" msgstr "Ungelesen"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-10 12:51+0200\n" "POT-Creation-Date: 2025-09-10 13:44+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -46,6 +46,7 @@ msgid "Reviews"
msgstr "" msgstr ""
#: risks/admin.py:19 risks/models.py:258 templates/base.html:37 #: risks/admin.py:19 risks/models.py:258 templates/base.html:37
#: templates/risks/item_risk.html:248
msgid "Incidents" msgid "Incidents"
msgstr "" msgstr ""
@ -53,7 +54,7 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" msgstr ""
#: risks/admin.py:133 risks/models.py:302 #: risks/admin.py:133 risks/models.py:302 templates/risks/item_risk.html:287
msgid "User" msgid "User"
msgstr "" msgstr ""
@ -114,15 +115,17 @@ msgid "Risk Management"
msgstr "" msgstr ""
#: risks/forms.py:9 risks/forms.py:16 risks/forms.py:23 risks/models.py:73 #: risks/forms.py:9 risks/forms.py:16 risks/forms.py:23 risks/models.py:73
#: templates/risks/item_risk.html:64 templates/risks/item_risk.html:202
#: templates/risks/item_risk.html:256
msgid "Status" msgid "Status"
msgstr "" msgstr ""
#: risks/forms.py:30 risks/models.py:42 templates/risks/item_risk.html:136 #: risks/forms.py:30 risks/models.py:42 templates/risks/item_risk.html:177
msgid "Review required" msgid "Review required"
msgstr "" msgstr ""
#: risks/models.py:35 templates/risks/list_risks.html:18 #: risks/models.py:35 templates/risks/item_risk.html:11
#: templates/risks/list_risks.html:83 #: templates/risks/list_risks.html:18 templates/risks/list_risks.html:83
msgid "Risk" msgid "Risk"
msgstr "" msgstr ""
@ -187,6 +190,7 @@ msgid "Availability"
msgstr "" msgstr ""
#: risks/models.py:64 risks/models.py:200 risks/models.py:265 #: risks/models.py:64 risks/models.py:200 risks/models.py:265
#: templates/risks/item_risk.html:201
msgid "Title" msgid "Title"
msgstr "" msgstr ""
@ -194,19 +198,20 @@ msgstr ""
msgid "Description" msgid "Description"
msgstr "" msgstr ""
#: risks/models.py:66 #: risks/models.py:66 templates/risks/item_risk.html:53
msgid "Asset" msgid "Asset"
msgstr "" msgstr ""
#: risks/models.py:67 #: risks/models.py:67 templates/risks/item_risk.html:54
msgid "Process" msgid "Process"
msgstr "" msgstr ""
#: risks/models.py:68 templates/risks/list_risks.html:85 #: risks/models.py:68 templates/risks/item_risk.html:55
#: templates/risks/list_risks.html:85
msgid "Category" msgid "Category"
msgstr "" msgstr ""
#: risks/models.py:69 #: risks/models.py:69 templates/risks/item_risk.html:68
msgid "Created at" msgid "Created at"
msgstr "" msgstr ""
@ -254,7 +259,7 @@ msgstr ""
msgid "Auditlogs" msgid "Auditlogs"
msgstr "" msgstr ""
#: risks/models.py:257 #: risks/models.py:257 templates/risks/item_risk.html:255
msgid "Incident" msgid "Incident"
msgstr "" msgstr ""
@ -274,7 +279,7 @@ msgstr ""
msgid "Notification" msgid "Notification"
msgstr "" msgstr ""
#: risks/models.py:280 templates/base.html:88 #: risks/models.py:280 templates/base.html:78
#: templates/risks/notifications.html:4 #: templates/risks/notifications.html:4
msgid "Notifications" msgid "Notifications"
msgstr "" msgstr ""
@ -387,31 +392,32 @@ msgstr ""
msgid "Dashboard" msgid "Dashboard"
msgstr "" msgstr ""
#: templates/base.html:35 templates/risks/list_risks.html:4 #: templates/base.html:35 templates/risks/item_risk.html:4
#: templates/risks/list_risks.html:4
msgid "Risk analysis" msgid "Risk analysis"
msgstr "" msgstr ""
#: templates/base.html:70 #: templates/base.html:73
msgid "AdminCP" msgid "AdminCP"
msgstr "" msgstr ""
#: templates/base.html:76 #: templates/base.html:86
msgid "Derk Mode" msgid "Derk Mode"
msgstr "" msgstr ""
#: templates/base.html:82 #: templates/base.html:92
msgid "Logout" msgid "Logout"
msgstr "" msgstr ""
#: templates/base.html:104 #: templates/base.html:107
msgid "Login" msgid "Login"
msgstr "" msgstr ""
#: templates/base.html:139 templates/base.html:146 #: templates/base.html:142 templates/base.html:149
msgid "Light Mode" msgid "Light Mode"
msgstr "" msgstr ""
#: templates/base.html:149 #: templates/base.html:152
msgid "Dark Mode" msgid "Dark Mode"
msgstr "" msgstr ""
@ -443,34 +449,128 @@ msgstr ""
msgid "Risks by CIA" msgid "Risks by CIA"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:18
msgid "Overview"
msgstr ""
#: templates/risks/item_risk.html:34 #: templates/risks/item_risk.html:34
msgid "Update status" msgid "Update status"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:89 templates/risks/item_risk.html:147 #: templates/risks/item_risk.html:57
msgid "Protection goals"
msgstr ""
#: templates/risks/item_risk.html:61
msgid "Not yet assigned"
msgstr ""
#: templates/risks/item_risk.html:67
msgid "Risk owner"
msgstr ""
#: templates/risks/item_risk.html:69
msgid "updated at"
msgstr ""
#: templates/risks/item_risk.html:70
msgid "Resubmission"
msgstr ""
#: templates/risks/item_risk.html:76
msgid "Risk assessment"
msgstr ""
#: templates/risks/item_risk.html:84
msgid "Gross (before measures)"
msgstr ""
#: templates/risks/item_risk.html:89 templates/risks/item_risk.html:135
#: templates/risks/list_risks.html:86 #: templates/risks/list_risks.html:86
msgid "Likelihood" msgid "Likelihood"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:98 templates/risks/item_risk.html:156 #: templates/risks/item_risk.html:90 templates/risks/item_risk.html:136
msgid "Probability of occurrence"
msgstr ""
#: templates/risks/item_risk.html:98 templates/risks/item_risk.html:144
#: templates/risks/list_risks.html:87 #: templates/risks/list_risks.html:87
msgid "Impact" msgid "Impact"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:107 templates/risks/item_risk.html:165 #: templates/risks/item_risk.html:99 templates/risks/item_risk.html:145
msgid "Extent of damage"
msgstr ""
#: templates/risks/item_risk.html:107 templates/risks/item_risk.html:108
#: templates/risks/item_risk.html:153 templates/risks/item_risk.html:154
#: templates/risks/list_risks.html:89 #: templates/risks/list_risks.html:89
msgid "Level" msgid "Level"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:116 templates/risks/item_risk.html:173 #: templates/risks/item_risk.html:116 templates/risks/item_risk.html:117
#: templates/risks/item_risk.html:161 templates/risks/item_risk.html:163
#: templates/risks/list_risks.html:88 #: templates/risks/list_risks.html:88
msgid "Score" msgid "Score"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:139 #: templates/risks/item_risk.html:130
msgid "Net (after measures)"
msgstr ""
#: templates/risks/item_risk.html:169
msgid "No net risk recorded yet."
msgstr ""
#: templates/risks/item_risk.html:180
msgid "Save" msgid "Save"
msgstr "" msgstr ""
#: templates/risks/item_risk.html:194
msgid "Measures"
msgstr ""
#: templates/risks/item_risk.html:203
msgid "Deadline"
msgstr ""
#: templates/risks/item_risk.html:204
msgid "Responsible"
msgstr ""
#: templates/risks/item_risk.html:205
msgid "Link"
msgstr ""
#: templates/risks/item_risk.html:239
msgid "No measures recorded."
msgstr ""
#: templates/risks/item_risk.html:257
msgid "Reported on"
msgstr ""
#: templates/risks/item_risk.html:271
msgid "No incidents recorded."
msgstr ""
#: templates/risks/item_risk.html:279
msgid "History"
msgstr ""
#: templates/risks/item_risk.html:286
msgid "Time"
msgstr ""
#: templates/risks/item_risk.html:288
msgid "Action"
msgstr ""
#: templates/risks/item_risk.html:302
msgid "No History found."
msgstr ""
#: templates/risks/list_risks.html:9 #: templates/risks/list_risks.html:9
msgid "Filter" msgid "Filter"
msgstr "" msgstr ""
@ -488,6 +588,10 @@ msgstr ""
msgid "Asset / Process" msgid "Asset / Process"
msgstr "" msgstr ""
#: templates/risks/list_risks.html:152
msgid "No risks present"
msgstr ""
#: templates/risks/notifications.html:12 #: templates/risks/notifications.html:12
msgid "Unread" msgid "Unread"
msgstr "" msgstr ""

View file

@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Control, Incident, Notification, NotificationPreference , Risk, ResidualRisk, User from .models import Control, Incident, Notification, NotificationPreference, NotificationRule, Risk, ResidualRisk, User
admin.site.site_header = _("Administration") admin.site.site_header = _("Administration")
admin.site.site_title = _("Admin") admin.site.site_title = _("Admin")
@ -171,6 +171,19 @@ class NotificationInline(admin.TabularInline):
extra = 0 extra = 0
ordering = ("-created_at",) ordering = ("-created_at",)
@admin.register(NotificationRule)
class NotificationRuleAdmin(admin.ModelAdmin):
list_display = ("kind", "enabled_in_app", "enabled_email", "to_owner", "to_staff", "short_extras")
list_editable = ("enabled_in_app", "enabled_email", "to_owner", "to_staff")
list_filter = ("enabled_in_app", "enabled_email", "to_owner", "to_staff")
search_fields = ("kind", "extra_recipients")
ordering = ("kind",)
@admin.display(description=_("Extra recipients"))
def short_extras(self, obj):
txt = (obj.extra_recipients or "").replace("\n", ", ")
return (txt[:50] + "") if len(txt) > 50 else txt
@admin.register(User) @admin.register(User)
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + ( fieldsets = BaseUserAdmin.fieldsets + (

View file

@ -8,3 +8,14 @@ class RisksConfig(AppConfig):
def ready(self): def ready(self):
import risks.signals import risks.signals
try:
from django.db.utils import OperationalError, ProgrammingError
from .models import NotificationRule, NotificationKind
NotificationRule.objects.count()
except (OperationalError, ProgrammingError):
return
existing = set(NotificationRule.objects.values_list("kind", flat=True))
for kind, _label in NotificationKind.choices:
if kind not in existing:
NotificationRule.objects.create(kind=kind)

30
risks/email_utils.py Normal file
View file

@ -0,0 +1,30 @@
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
def send_notification_email(user, subject, template_txt, context, template_html=None):
"""
Versendet nur, wenn EMAIL_ENABLED=True und user.email vorhanden.
template_txt: Pfad zu Plaintext-Template
template_html: optional Pfad zu HTML-Template
"""
if not settings.EMAIL_ENABLED:
return False
if not user or not user.email:
return False
subject_full = f"{settings.EMAIL_SUBJECT_PREFIX}{subject}"
body_txt = render_to_string(template_txt, context)
msg = EmailMultiAlternatives(
subject=subject_full,
body=body_txt,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
)
if template_html:
body_html = render_to_string(template_html, context)
msg.attach_alternative(body_html, "text/html")
msg.send(fail_silently=False)
return True

View file

@ -0,0 +1,86 @@
# Generated by Django 5.2.6 on 2025-09-10 12:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0022_alter_notification_options"),
]
operations = [
migrations.CreateModel(
name="NotificationRule",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kind",
models.CharField(
choices=[
("risk.created", "Risk created"),
("risk.updated", "Risk updated"),
("risk.deleted", "Risk deleted"),
("risk.review_required", "Risk review required"),
("risk.review_completed", "Risk review completed"),
("control.created", "Control created"),
("control.updated", "Control updated"),
("control.deleted", "Control deleted"),
("residual.created", "Residual created"),
("residual.updated", "Residual updated"),
("residual.deleted", "Residual deleted"),
("residual.review_required", "Residual review required"),
("residual.review_completed", "Residual review completed"),
("incident.created", "Incident created"),
("incident.updated", "Incident updated"),
("incident.deleted", "Incident deleted"),
("user.created", "User created"),
("user.deleted", "User deleted"),
],
max_length=40,
unique=True,
verbose_name="Event",
),
),
(
"enabled_in_app",
models.BooleanField(default=True, verbose_name="Show in app"),
),
(
"enabled_email",
models.BooleanField(default=False, verbose_name="Send via email"),
),
(
"to_owner",
models.BooleanField(
default=True,
verbose_name="Send to owner/responsible/reporter (if available)",
),
),
(
"to_staff",
models.BooleanField(
default=False, verbose_name="Send to all staff"
),
),
(
"extra_recipients",
models.TextField(
blank=True,
verbose_name="Extra recipients (emails, comma or newline separated)",
),
),
],
options={
"verbose_name": "Notification rule",
"verbose_name_plural": "Notification rules",
},
),
]

View file

@ -291,6 +291,30 @@ class Notification(models.Model):
user_display = self.user.username if self.user else "System" user_display = self.user.username if self.user else "System"
return f"{user_display}: {self.message[:50]}..." return f"{user_display}: {self.message[:50]}..."
class NotificationKind(models.TextChoices):
RISK_CREATED = "risk.created", _("Risk created")
RISK_UPDATED = "risk.updated", _("Risk updated")
RISK_DELETED = "risk.deleted", _("Risk deleted")
RISK_REVIEW_REQUIRED = "risk.review_required", _("Risk review required")
RISK_REVIEW_COMPLETED = "risk.review_completed", _("Risk review completed")
CONTROL_CREATED = "control.created", _("Control created")
CONTROL_UPDATED = "control.updated", _("Control updated")
CONTROL_DELETED = "control.deleted", _("Control deleted")
RESIDUAL_CREATED = "residual.created", _("Residual created")
RESIDUAL_UPDATED = "residual.updated", _("Residual updated")
RESIDUAL_DELETED = "residual.deleted", _("Residual deleted")
RESIDUAL_REVIEW_REQUIRED = "residual.review_required", _("Residual review required")
RESIDUAL_REVIEW_COMPLETED = "residual.review_completed", _("Residual review completed")
INCIDENT_CREATED = "incident.created", _("Incident created")
INCIDENT_UPDATED = "incident.updated", _("Incident updated")
INCIDENT_DELETED = "incident.deleted", _("Incident deleted")
USER_CREATED = "user.created", _("User created")
USER_DELETED = "user.deleted", _("User deleted")
class NotificationPreference(models.Model): class NotificationPreference(models.Model):
""" """
Wich events does the user want to receive as notifications? Wich events does the user want to receive as notifications?
@ -338,3 +362,37 @@ class NotificationPreference(models.Model):
def should_notify(self, event_code: str) -> bool: def should_notify(self, event_code: str) -> bool:
return bool(getattr(self, event_code, False)) return bool(getattr(self, event_code, False))
class NotificationRule(models.Model):
"""
Global Rules: Wich Event sends In-App- and/or Mail-Notifications?
"""
class Meta:
verbose_name = _("Notification rule")
verbose_name_plural = _("Notification rules")
kind = models.CharField(
_("Event"),
max_length=40,
choices=NotificationKind.choices,
unique=True,
)
enabled_in_app = models.BooleanField(_("Show in app"), default=True)
enabled_email = models.BooleanField(_("Send via email"), default=False)
# Empfängerkreise
to_owner = models.BooleanField(
_("Send to owner/responsible/reporter (if available)"),
default=True
)
to_staff = models.BooleanField(
_("Send to all staff"),
default=False
)
extra_recipients = models.TextField(
_("Extra recipients (emails, comma or newline separated)"),
blank=True
)
def __str__(self):
return self.get_kind_display() or self.kind

View file

@ -5,8 +5,8 @@ from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .audit_context import get_current_user from .audit_context import get_current_user
from .models import Control, Risk, ResidualRisk, AuditLog, Incident, Notification, NotificationPreference from .models import Control, Risk, ResidualRisk, AuditLog, Incident, Notification, NotificationKind, NotificationPreference
from .utils import model_diff from .utils import model_diff, notify_event
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# General definitions # General definitions
@ -111,6 +111,19 @@ def log_risk_save(sender, instance, created, **kwargs):
changes=clean_changes, changes=clean_changes,
) )
if created:
notify_event(
NotificationKind.RISK_CREATED,
message=_("Risk created: {t}").format(t=instance.title),
users=[instance.owner] if instance.owner_id else None,
)
else:
notify_event(
NotificationKind.RISK_UPDATED,
message=_("Risk updated: {t}").format(t=instance.title),
users=[instance.owner] if instance.owner_id else None,
)
@receiver(post_delete, sender=Risk) @receiver(post_delete, sender=Risk)
def log_risk_delete(sender, instance, **kwargs): def log_risk_delete(sender, instance, **kwargs):
""" """
@ -125,6 +138,12 @@ def log_risk_delete(sender, instance, **kwargs):
changes=None, # no fields to track on deletion changes=None, # no fields to track on deletion
) )
notify_event(
NotificationKind.RISK_DELETED,
message=_("Risk deleted: {t}").format(t=instance.title),
users=[instance.owner] if instance.owner_id else None,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Controls # Controls
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -186,6 +205,16 @@ def log_control_save(sender, instance, created, **kwargs):
changes=clean_changes, changes=clean_changes,
) )
kind = NotificationKind.CONTROL_CREATED if created else NotificationKind.CONTROL_UPDATED
notify_event(
kind,
message=_("Control {event}: {t}").format(
event=_("created") if created else _("updated"),
t=instance.title,
),
users=[instance.responsible] if instance.responsible_id else None,
)
@receiver(post_delete, sender=Control) @receiver(post_delete, sender=Control)
def log_control_delete(sender, instance, **kwargs): def log_control_delete(sender, instance, **kwargs):
user = getattr(instance, "_changed_by", None) or get_current_user() user = getattr(instance, "_changed_by", None) or get_current_user()
@ -197,6 +226,12 @@ def log_control_delete(sender, instance, **kwargs):
changes=None, changes=None,
) )
notify_event(
NotificationKind.CONTROL_DELETED,
message=_("Control deleted: {t}").format(t=instance.title),
users=[instance.responsible] if instance.responsible_id else None,
)
@receiver(m2m_changed, sender=Control.risks.through) @receiver(m2m_changed, sender=Control.risks.through)
def control_risks_changed(sender, instance: Control, action, reverse, pk_set, **kwargs): def control_risks_changed(sender, instance: Control, action, reverse, pk_set, **kwargs):
if action in {"post_add", "post_remove", "post_clear"}: if action in {"post_add", "post_remove", "post_clear"}:
@ -275,6 +310,38 @@ def log_residual_save(sender, instance, created, **kwargs):
changes=clean_changes, changes=clean_changes,
) )
if created:
notify_event(
NotificationKind.RESIDUAL_CREATED,
message=_("Residual created for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
else:
# Änderungen prüfen
old = ResidualRisk.objects.get(pk=instance.pk)
changes = model_diff(old, instance)
# Review-Flag Wechsel gezielt melden:
if "review_required" in changes:
if getattr(instance, "review_required", False):
notify_event(
NotificationKind.RESIDUAL_REVIEW_REQUIRED,
message=_("Residual review required for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
else:
notify_event(
NotificationKind.RESIDUAL_REVIEW_COMPLETED,
message=_("Residual review completed for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
else:
notify_event(
NotificationKind.RESIDUAL_UPDATED,
message=_("Residual updated for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
@receiver(post_delete, sender=ResidualRisk) @receiver(post_delete, sender=ResidualRisk)
def log_residual_delete(sender, instance, **kwargs): def log_residual_delete(sender, instance, **kwargs):
user = getattr(instance, "_changed_by", None) or get_current_user() user = getattr(instance, "_changed_by", None) or get_current_user()
@ -286,6 +353,12 @@ def log_residual_delete(sender, instance, **kwargs):
changes=None, changes=None,
) )
notify_event(
NotificationKind.RESIDUAL_DELETED,
message=_("Residual deleted for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Incidents # Incidents
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -331,6 +404,16 @@ def log_incident_save(sender, instance, created, **kwargs):
changes=clean_changes, changes=clean_changes,
) )
kind = NotificationKind.INCIDENT_CREATED if created else NotificationKind.INCIDENT_UPDATED
notify_event(
kind,
message=_("Incident {event}: {t}").format(
event=_("created") if created else _("updated"),
t=instance.title,
),
users=[instance.reported_by] if instance.reported_by_id else None,
)
@receiver(m2m_changed, sender=Incident.related_risks.through) @receiver(m2m_changed, sender=Incident.related_risks.through)
def log_incident_risks_change(sender, instance, action, reverse, model, pk_set, **kwargs): def log_incident_risks_change(sender, instance, action, reverse, model, pk_set, **kwargs):
if action in ["post_add", "post_remove", "post_clear"]: if action in ["post_add", "post_remove", "post_clear"]:
@ -353,3 +436,9 @@ def log_incident_delete(sender, instance, **kwargs):
object_id=instance.pk, object_id=instance.pk,
changes=None, changes=None,
) )
notify_event(
NotificationKind.INCIDENT_DELETED,
message=_("Incident deleted: {t}").format(t=instance.title),
users=[instance.reported_by] if instance.reported_by_id else None,
)

View file

@ -1,7 +1,12 @@
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import AuditLog, Notification, Risk, ResidualRisk from .models import AuditLog, Notification,NotificationRule, NotificationKind, Risk, ResidualRisk
from typing import Iterable, Optional
User = get_user_model()
def model_diff(old, new, fields=None): def model_diff(old, new, fields=None):
""" """
@ -56,3 +61,67 @@ def check_risk_followups():
user=None, action="create", model="Notification", object_id=notification.pk, user=None, action="create", model="Notification", object_id=notification.pk,
changes={"message": notification.message, "user": risk.owner.username if risk.owner else None}, changes={"message": notification.message, "user": risk.owner.username if risk.owner else None},
) )
notify_event(
NotificationKind.RISK_REVIEW_REQUIRED,
message=_("Follow-up reached: review required for risk '{t}'").format(t=risk.title),
users=[risk.owner] if risk.owner_id else None,
)
def _split_emails(value: str) -> list[str]:
if not value:
return []
raw = value.replace("\n", ",").split(",")
return [e.strip() for e in raw if "@" in e and e.strip()]
def notify_event(kind: str, *, message: str, users: Optional[Iterable[User]] = None):
"""
Generates in-app notifications and/or emails depending on the rule.
- users: Basic recipients (owner/responsible/reporter) can be None.
- staff/extra recipients are added from the rule.
"""
rule = NotificationRule.objects.filter(kind=kind).first()
# Fallback: without rule → only in-app
enabled_in_app = True
enabled_email = False
to_staff = False
extra_emails = []
recipients_users = set()
if users:
for u in users:
if u and getattr(u, "is_active", False):
recipients_users.add(u)
if rule:
enabled_in_app = rule.enabled_in_app
enabled_email = rule.enabled_email
if rule.to_staff:
to_staff = True
extra_emails = _split_emails(rule.extra_recipients)
if to_staff:
for u in User.objects.filter(is_staff=True, is_active=True):
recipients_users.add(u)
# In-App
if enabled_in_app:
for u in recipients_users:
Notification.objects.create(user=u, message=message)
# E-Mail
if enabled_email:
emails = [u.email for u in recipients_users if u and u.email] + extra_emails
emails = list(dict.fromkeys(emails)) # de-dupe, Reihenfolge erhalten
if emails:
subject = _("Notification")
body = message
send_mail(
subject,
body,
getattr(settings, "DEFAULT_FROM_EMAIL", "webmaster@localhost"),
emails,
fail_silently=True, # im Zweifel nicht crashen
)