diff --git a/db.sqlite3 b/db.sqlite3 index 6e58103..0f96716 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index b760d04..bc95570 100644 Binary files a/locale/de/LC_MESSAGES/django.mo and b/locale/de/LC_MESSAGES/django.mo differ diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 8db2f14..3f16f2b 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: wira-risk-management\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" "Last-Translator: Kevin Heyer \n" "Language-Team: German\n" @@ -40,6 +40,7 @@ msgid "Reviews" msgstr "Prüfung" #: risks/admin.py:19 risks/models.py:258 templates/base.html:37 +#: templates/risks/item_risk.html:248 msgid "Incidents" msgstr "Vorfälle" @@ -47,7 +48,7 @@ msgstr "Vorfälle" msgid "Users" 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" msgstr "Benutzer" @@ -60,6 +61,7 @@ msgid "Mark selected as read" msgstr "Alle als gelesen Markieren" #: risks/admin.py:150 +#, python-format msgid "%(n)d notifications marked as read." msgstr "%(n)d Benachrichtigungen wurden als gelesen Markiert" @@ -68,6 +70,7 @@ msgid "Mark selected as unread" msgstr "Alle als gelesen Markieren" #: risks/admin.py:155 +#, python-format msgid "%(n)d notifications marked as unread." msgstr "%(n)d Benachrichtigungen wurden als ungelesen Markiert" @@ -77,15 +80,15 @@ msgstr "" #: risks/admin.py:160 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 msgid "Mark selected as unsent" -msgstr "" +msgstr "Auswahl als ungesendet markieren" #: risks/admin.py:165 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 msgid "SSO Information" @@ -104,15 +107,17 @@ msgid "Risk Management" msgstr "Risikomanagement" #: 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" 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" msgstr "Prüfung nötig" -#: risks/models.py:35 templates/risks/list_risks.html:18 -#: templates/risks/list_risks.html:83 +#: risks/models.py:35 templates/risks/item_risk.html:11 +#: templates/risks/list_risks.html:18 templates/risks/list_risks.html:83 msgid "Risk" msgstr "Risiko" @@ -177,6 +182,7 @@ msgid "Availability" msgstr "Verfügbarkeit" #: risks/models.py:64 risks/models.py:200 risks/models.py:265 +#: templates/risks/item_risk.html:201 msgid "Title" msgstr "Titel" @@ -184,19 +190,20 @@ msgstr "Titel" msgid "Description" msgstr "Beschreibung" -#: risks/models.py:66 +#: risks/models.py:66 templates/risks/item_risk.html:53 msgid "Asset" msgstr "Asset" -#: risks/models.py:67 +#: risks/models.py:67 templates/risks/item_risk.html:54 msgid "Process" 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" msgstr "Kategorie" -#: risks/models.py:69 +#: risks/models.py:69 templates/risks/item_risk.html:68 msgid "Created at" msgstr "Erstellt am" @@ -244,7 +251,7 @@ msgstr "Audit-Log" msgid "Auditlogs" msgstr "Audit-Logs" -#: risks/models.py:257 +#: risks/models.py:257 templates/risks/item_risk.html:255 msgid "Incident" msgstr "Vorfall" @@ -264,7 +271,7 @@ msgstr "Gemeldet von" msgid "Notification" msgstr "Benachrichtigung" -#: risks/models.py:280 templates/base.html:88 +#: risks/models.py:280 templates/base.html:78 #: templates/risks/notifications.html:4 msgid "Notifications" msgstr "Nachrichten" @@ -377,31 +384,32 @@ msgstr "Restrisiko geprüft" msgid "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" msgstr "Risikoanalyse" -#: templates/base.html:70 +#: templates/base.html:73 msgid "AdminCP" msgstr "Adminbereich" -#: templates/base.html:76 +#: templates/base.html:86 msgid "Derk Mode" msgstr "Dark Mode" -#: templates/base.html:82 +#: templates/base.html:92 msgid "Logout" msgstr "Logout" -#: templates/base.html:104 +#: templates/base.html:107 msgid "Login" msgstr "Login" -#: templates/base.html:139 templates/base.html:146 +#: templates/base.html:142 templates/base.html:149 msgid "Light Mode" msgstr "Light Mode" -#: templates/base.html:149 +#: templates/base.html:152 msgid "Dark Mode" msgstr "Dark Mode" @@ -433,34 +441,132 @@ msgstr "Vorfälle nach Status" msgid "Risks by CIA" msgstr "CIA Risiken" +#: templates/risks/item_risk.html:18 +#, fuzzy +#| msgid "Reviews" +msgid "Overview" +msgstr "Prüfung" + #: templates/risks/item_risk.html:34 msgid "Update status" 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 msgid "Likelihood" 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 msgid "Impact" 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 msgid "Level" 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 msgid "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" 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 msgid "Filter" msgstr "Filter" @@ -478,6 +584,10 @@ msgstr "Risikoeigner" msgid "Asset / Process" msgstr "Asset / Prozess" +#: templates/risks/list_risks.html:152 +msgid "No risks present" +msgstr "Aktuell keine Risiken" + #: templates/risks/notifications.html:12 msgid "Unread" msgstr "Ungelesen" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 8c3a626..d06e20b 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\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" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,6 +46,7 @@ msgid "Reviews" msgstr "" #: risks/admin.py:19 risks/models.py:258 templates/base.html:37 +#: templates/risks/item_risk.html:248 msgid "Incidents" msgstr "" @@ -53,7 +54,7 @@ msgstr "" msgid "Users" 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" msgstr "" @@ -114,15 +115,17 @@ msgid "Risk Management" msgstr "" #: 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" 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" msgstr "" -#: risks/models.py:35 templates/risks/list_risks.html:18 -#: templates/risks/list_risks.html:83 +#: risks/models.py:35 templates/risks/item_risk.html:11 +#: templates/risks/list_risks.html:18 templates/risks/list_risks.html:83 msgid "Risk" msgstr "" @@ -187,6 +190,7 @@ msgid "Availability" msgstr "" #: risks/models.py:64 risks/models.py:200 risks/models.py:265 +#: templates/risks/item_risk.html:201 msgid "Title" msgstr "" @@ -194,19 +198,20 @@ msgstr "" msgid "Description" msgstr "" -#: risks/models.py:66 +#: risks/models.py:66 templates/risks/item_risk.html:53 msgid "Asset" msgstr "" -#: risks/models.py:67 +#: risks/models.py:67 templates/risks/item_risk.html:54 msgid "Process" 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" msgstr "" -#: risks/models.py:69 +#: risks/models.py:69 templates/risks/item_risk.html:68 msgid "Created at" msgstr "" @@ -254,7 +259,7 @@ msgstr "" msgid "Auditlogs" msgstr "" -#: risks/models.py:257 +#: risks/models.py:257 templates/risks/item_risk.html:255 msgid "Incident" msgstr "" @@ -274,7 +279,7 @@ msgstr "" msgid "Notification" msgstr "" -#: risks/models.py:280 templates/base.html:88 +#: risks/models.py:280 templates/base.html:78 #: templates/risks/notifications.html:4 msgid "Notifications" msgstr "" @@ -387,31 +392,32 @@ msgstr "" msgid "Dashboard" 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" msgstr "" -#: templates/base.html:70 +#: templates/base.html:73 msgid "AdminCP" msgstr "" -#: templates/base.html:76 +#: templates/base.html:86 msgid "Derk Mode" msgstr "" -#: templates/base.html:82 +#: templates/base.html:92 msgid "Logout" msgstr "" -#: templates/base.html:104 +#: templates/base.html:107 msgid "Login" msgstr "" -#: templates/base.html:139 templates/base.html:146 +#: templates/base.html:142 templates/base.html:149 msgid "Light Mode" msgstr "" -#: templates/base.html:149 +#: templates/base.html:152 msgid "Dark Mode" msgstr "" @@ -443,34 +449,128 @@ msgstr "" msgid "Risks by CIA" msgstr "" +#: templates/risks/item_risk.html:18 +msgid "Overview" +msgstr "" + #: templates/risks/item_risk.html:34 msgid "Update status" 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 msgid "Likelihood" 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 msgid "Impact" 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 msgid "Level" 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 msgid "Score" 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" 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 msgid "Filter" msgstr "" @@ -488,6 +588,10 @@ msgstr "" msgid "Asset / Process" msgstr "" +#: templates/risks/list_risks.html:152 +msgid "No risks present" +msgstr "" + #: templates/risks/notifications.html:12 msgid "Unread" msgstr "" diff --git a/risks/admin.py b/risks/admin.py index 1d5f0de..d4c472b 100644 --- a/risks/admin.py +++ b/risks/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 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_title = _("Admin") @@ -171,6 +171,19 @@ class NotificationInline(admin.TabularInline): extra = 0 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) class UserAdmin(BaseUserAdmin): fieldsets = BaseUserAdmin.fieldsets + ( diff --git a/risks/apps.py b/risks/apps.py index 8f3dd84..095f206 100644 --- a/risks/apps.py +++ b/risks/apps.py @@ -7,4 +7,15 @@ class RisksConfig(AppConfig): verbose_name = _("Risk Management") def ready(self): - import risks.signals \ No newline at end of file + 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) \ No newline at end of file diff --git a/risks/email_utils.py b/risks/email_utils.py new file mode 100644 index 0000000..27963a8 --- /dev/null +++ b/risks/email_utils.py @@ -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 diff --git a/risks/migrations/0023_notificationrule.py b/risks/migrations/0023_notificationrule.py new file mode 100644 index 0000000..24ec4b7 --- /dev/null +++ b/risks/migrations/0023_notificationrule.py @@ -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", + }, + ), + ] diff --git a/risks/models.py b/risks/models.py index 9995a56..5261802 100644 --- a/risks/models.py +++ b/risks/models.py @@ -291,6 +291,30 @@ class Notification(models.Model): user_display = self.user.username if self.user else "System" 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): """ Wich events does the user want to receive as notifications? @@ -337,4 +361,38 @@ class NotificationPreference(models.Model): return f"Prefs({self.user})" def should_notify(self, event_code: str) -> bool: - return bool(getattr(self, event_code, False)) \ No newline at end of file + 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 diff --git a/risks/signals.py b/risks/signals.py index 99b2d38..a2065d8 100644 --- a/risks/signals.py +++ b/risks/signals.py @@ -5,8 +5,8 @@ from django.db.models.signals import post_save, post_delete, m2m_changed from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from .audit_context import get_current_user -from .models import Control, Risk, ResidualRisk, AuditLog, Incident, Notification, NotificationPreference -from .utils import model_diff +from .models import Control, Risk, ResidualRisk, AuditLog, Incident, Notification, NotificationKind, NotificationPreference +from .utils import model_diff, notify_event # --------------------------------------------------------------------------- # General definitions @@ -111,6 +111,19 @@ def log_risk_save(sender, instance, created, **kwargs): 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) 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 ) + notify_event( + NotificationKind.RISK_DELETED, + message=_("Risk deleted: {t}").format(t=instance.title), + users=[instance.owner] if instance.owner_id else None, + ) + # --------------------------------------------------------------------------- # Controls # --------------------------------------------------------------------------- @@ -186,6 +205,16 @@ def log_control_save(sender, instance, created, **kwargs): 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) def log_control_delete(sender, instance, **kwargs): user = getattr(instance, "_changed_by", None) or get_current_user() @@ -197,6 +226,12 @@ def log_control_delete(sender, instance, **kwargs): 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) def control_risks_changed(sender, instance: Control, action, reverse, pk_set, **kwargs): if action in {"post_add", "post_remove", "post_clear"}: @@ -275,6 +310,38 @@ def log_residual_save(sender, instance, created, **kwargs): 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) def log_residual_delete(sender, instance, **kwargs): user = getattr(instance, "_changed_by", None) or get_current_user() @@ -286,6 +353,12 @@ def log_residual_delete(sender, instance, **kwargs): 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 # --------------------------------------------------------------------------- @@ -331,6 +404,16 @@ def log_incident_save(sender, instance, created, **kwargs): 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) def log_incident_risks_change(sender, instance, action, reverse, model, pk_set, **kwargs): if action in ["post_add", "post_remove", "post_clear"]: @@ -352,4 +435,10 @@ def log_incident_delete(sender, instance, **kwargs): model="Incident", object_id=instance.pk, 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, ) \ No newline at end of file diff --git a/risks/utils.py b/risks/utils.py index c71dff4..d76c4e9 100644 --- a/risks/utils.py +++ b/risks/utils.py @@ -1,7 +1,12 @@ +from django.conf import settings from django.contrib.auth import get_user_model +from django.core.mail import send_mail from django.utils.timezone import now 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): """ @@ -55,4 +60,68 @@ def check_risk_followups(): AuditLog.objects.create( user=None, action="create", model="Notification", object_id=notification.pk, changes={"message": notification.message, "user": risk.owner.username if risk.owner else None}, - ) \ No newline at end of file + ) + + 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 + )