From 86525d9ab0ea2476e1af37f4462a297d462e1776 Mon Sep 17 00:00:00 2001 From: Kevin Heyer Date: Wed, 10 Sep 2025 14:26:29 +0200 Subject: [PATCH] feat: Implement notification rules and email notifications for risk events --- db.sqlite3 | Bin 233472 -> 241664 bytes locale/de/LC_MESSAGES/django.mo | Bin 7178 -> 8412 bytes locale/de/LC_MESSAGES/django.po | 162 ++++++++++++++++++---- locale/en/LC_MESSAGES/django.po | 150 +++++++++++++++++--- risks/admin.py | 15 +- risks/apps.py | 13 +- risks/email_utils.py | 30 ++++ risks/migrations/0023_notificationrule.py | 86 ++++++++++++ risks/models.py | 60 +++++++- risks/signals.py | 93 ++++++++++++- risks/utils.py | 73 +++++++++- 11 files changed, 626 insertions(+), 56 deletions(-) create mode 100644 risks/email_utils.py create mode 100644 risks/migrations/0023_notificationrule.py diff --git a/db.sqlite3 b/db.sqlite3 index 6e58103f0933811eafc465d585010ce89b9ad83c..0f9671643ca9e13eeeafda2f2fdc146fd295709e 100644 GIT binary patch delta 3030 zcma)8eQX42!umIIFbyBNiiNw zj6|cc*m7X&hgRgQn}l67UTM^h;rf|iqTdE8Wukg}nFP{NyAWOV4}vGmzMWR)0I#Bb zXh;3v?kCY`UEQ5T{`&j72X|tMvXasd3;8*Yw`~=28h9TIB|A}ue_lACs2YOjg zpqZhwWGt{ABYFLgpKPnU$L=$u)|++9-Z{?pd7IgK*Lt&2J7Qhj&Y~E?r)6oOtH{wl z7Nro*O9iPab$L@wZ|+pKINj|{_Od97a5kUs3QX)`Nym<6%H_j{-(m%fZ&7d$Zo@C( zHFzGLgJ<9r9EUH#Ah_{A@U2Ge4g9DNvy>;G&$F}Joettk=&okBnR_-kLecm<1;_CD zM(r6~KVQ7zCIe+_0fedAn8dj{ny?F@-r|!J9#RY zZz~`a6(XTzD3lDxUN5r(Gm2lRTQ1FU_^0^C_!ay@qt=JLCI%rqs;?=1P3mi6`OWW* zrSyL?n*~#8v6=tnbTV-4o zpOpd+s%=MnI;=}TeaTO6S1){#_V0iC?AfQy9rr*yDTac>@kk;n7Q8&wvjjh z6*)hjEeMLF)22vs^Rgo4b+{8xNyR*&Gb>25S-H@!0pkwfSTZsaj7P+9bY#{;fXAA^ zVkdB}G+WlrW)nI1|09RRcw}XKfL=+m+bG!9sM*0?n?^lU_;aZ!LTJk_v?cwm^q?9o z(eFUBjZCYJt7Y2kWbcs!yWDf`W7y(i2Kmk{muU86ugyMA!5i>6{uh1~e-pbL*BsLh zm;D#^aYCYdKIQh zil80!r10gel3&OwQeeL1KWSZG+JpW-c4$pEa9czLEIq-UyxC-N+* zzgwdp3$%)ILC?&mKH!@gx#x zDNxN$79{-=?7DL|wN z^uy-v*08XMV=?A72`Gu8fUD1 z=mk4R?b&oo>!f;0|1+)RrB-P_`Y-e5IKaH9=YBfs|7%7vFG zxBwZb;P>#4@Xz272>4IHKosu7ui?93c@zGCuVC^X`5InyG6QJ63S4c20omhb0;rRF z=LVxu5^OLU;kb_(Lv5Nmxppk>VD@ymTl27MfbCdg9JUQcBeivd(MZj2VFpnLrQCWO w3I4XGam#w65p4D`G5s%S7l|e>6W!oxmCI_QThAQRHW+2nzzp#E4kOEd0gMKROaK4? delta 683 zcmYjNUr1A76#u?^?{?jt_d9pfZIIgTh(gqDcXy8ck($d2#KepqB^4VqYHli&7=cR= zdJ*MAZcoueNdyIX%Zm?+50y|4_7_bEj9^q`30esebrpgR{LaJgob&sgbB5Q*@C`Cv z;g~`Q#ZCN+k%_u6p1R+46WD5tpYl?>9+Ka1y5F@dV4xP+r-zi2gboJc79KR@n@CmB zSAkPjw^v|~+BDpRD`|7E700L+OyFwzGFV$BA|#jwBH2Rl6`sK=+=C=!pblJQORrSs z2+d1Xg3Nj1xIZ&+Zh*cox@mcGgN@mflyB@acUTnkWXof2uShl#tixM)3U}ceT!kSx z3+-?SYN4EL>W=&iV)u$1^2GmB>Rh**{tOvKaGZ>q7Yq?P+WSN*gfKycWXk3d#h}fW zqFuRcG*Dl!D$!{7nQW$aH#MdQyR-T8=|E2|t14>5uf+XIL{^oAq9j7mKsXeODC%^c zli3(~Ncr)deGd|C!OL>`?Dduu8?(Nq{MruPVJ%I?EC@HXiWf5Ji*%yJqyH=}eZ(c& z;u5YUNsYx67fO%sx?22oJKX`<71D)gQHvGbg|V&$?{8^>K#la`NIlf(LMz{2+J8Y2p;;P0!n+ zG5?!6=5+F~f?x%H!FTuupWp+$GkhnQM_{aa*_y^Zc6JPr1@Vh`r1X|u6t0v6`Dwm^ R9kZ_L-BO-4#(LQPy1(hf!RG(~ diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index b760d042f4cb4e9f6408ec787828a6e51543557c..bc955705e50cf86f24cf2a6b08dc71e59e94c032 100644 GIT binary patch literal 8412 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 delta 2641 zcmZA2drZ}39LMqR@sO8H5kW+di;9Y9l5&yIR18r=iM#~nB@hpKg3eJuU9RnDn=Gv5 z@~5P24RhIC)8#xEuGY#emd(|yx%E#ur)7U=OYOp3TD?E#JpJRd^ZmV^=XZJTzu)EaA`|2?fn{cpM#I}>64vXL;*crcIT{w_(x4Zs6>cLZ}hCatJc;0yxM^nCsRBS0j z3CzTiI1{;K3$Xxea0u<&u2{xwFAn3v0areX{Okk=b>uA0z|T+>+;->3(QC4n=PW{X zU_Pee5>yB3F%8>L1AZNoY2Uisi4Ray)Pw5j$Id>?p?uL@zm4ipKjz^-n1z$+{Rpf? zPFXFE!i}hz*@;?`{g{MZ7}1T#$!P7*AV2$#LjqnwHS{x1!JDY5Pi28+7HXuEP%}_~ z++wqlpDp2#iFMBHm`(YBvu8N-Pu?!M6W35{?B}2nC1VlJL^Zex)v+*YYTKQ!AwN6H zK{Iz6)sat8d*A|UX0M<+dL1?JJBiFcdHa`xDo$knbYlu?tv~sQ1!iyTKjJ2X;l4vsF}MEAyY`^5*FZK7UnT5MU6P<+=}HsvzJga@(H!- zxm#F*xykYJ8dL-OP$N8u>d;YChfZQ4o<`jlxlTqia1*sF@1Y(T$gs3DNvQKVsFCNR zMp}Y;uEJekiCXLRn1L_5@*z}xAEB1?9BS#VAoWG8pG*-a{A^V3{~$cS8jH8pkMF*yJT2aJBDln>qSk~c~nP! zL3QMJR7dZk_K2UaiAGlBoP)YvftsmmRL9q#>S;pN+lrcjH!-63@faDcT_5VkYp4&) zZ>Tj+<2##+c{m%Fp=P2JHS+zaksd}Ku^!YwzD9NAGHTDs&8 zsF^51jbt%ud$zMRoWNs)K(clV~X!%)c&7 zW6P=o#i)kLu@HBoHrW}}eSe@vbRT&et(wp$wT@8YbZif(ksLy4J;5Hg7gQi=09CP^ z*%sQj&8|?3T34-61ECR2Axemq1f8}pp}*r|LTL@Lf>@veX*rQiJWFV*mGa`)01OdY z3Z-#`+K;75j+$zXR#Q2N(5`)gm`x}>Lu?|}sz6#DCwAP*Q8T7gA3Meme4p%jLi^{z z#2$&*bDUlJz?By%uOpr&HW2!9X(u-lErixo8&+%me_BGOnFtayh$cem(KxYXo9Oa- zeG7\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 + )