From ab01841cf247ed527a040bfcef7d2f7e09260f3c Mon Sep 17 00:00:00 2001 From: Kevin Heyer Date: Wed, 10 Sep 2025 11:54:08 +0200 Subject: [PATCH] Add risk status and notification preferences - Introduced a new `status` field to the `Risk` model with choices for "open", "in_progress", "closed", and "review_required". - Created a `NotificationPreference` model to manage user notification settings for various events related to risks, controls, residual risks, reviews, users, and incidents. - Updated the admin interface to include `NotificationPreference` inline with the `User` admin. - Enhanced signal handlers to send notifications based on user preferences for created, updated, and deleted events for users, risks, controls, and incidents. - Modified the `check_risk_followups` utility function to update risk status and create notifications for follow-ups. - Updated serializers and views to accommodate the new `status` field and improved risk listing functionality. - Added a new section in the risk detail template to display related incidents. - Removed the unused statistics view from URLs. --- config/settings.py | 2 +- db.sqlite3 | Bin 221184 -> 233472 bytes locale/de/LC_MESSAGES/django.mo | Bin 3925 -> 5487 bytes locale/de/LC_MESSAGES/django.po | 431 +++++++++++------- locale/en/LC_MESSAGES/django.po | 430 ++++++++++------- risks/admin.py | 23 +- ...0021_risk_status_notificationpreference.py | 71 +++ risks/models.py | 64 ++- risks/serializers.py | 1 + risks/signals.py | 155 ++++++- risks/urls.py | 1 - risks/utils.py | 41 +- risks/views.py | 28 +- templates/risks/item_risk.html | 31 ++ 14 files changed, 910 insertions(+), 368 deletions(-) create mode 100644 risks/migrations/0021_risk_status_notificationpreference.py diff --git a/config/settings.py b/config/settings.py index 5b3769f..a8864c4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -216,5 +216,5 @@ if SSO_ENABLED: # --------------------------------------------------------------------------- CRONJOBS = [ - ("0 8 * * *", "risks.utils.check_risk_followups"), + ("0 8 * * *", "risks.utils.check_risk_followups", ">> /var/log/wira_followups.log 2>&1"), ] \ No newline at end of file diff --git a/db.sqlite3 b/db.sqlite3 index e831ec3321a44d1a68bd6db8d86fa7992fa2856e..deb65ca34a191a1f5419868d32e06a9027fb354c 100644 GIT binary patch delta 3080 zcmai04Qw0b8NTnk^PTO)_a<=?7p-c~4#+WS65EOY(yocydbBjnlB$SqX=WVzl3bJ8 z+1XCJW~qBco5nKoBM%*86WWki1!}2|K&=Th*rI{PGzlnZ0&UW?3TQP7xt@j z(R3!3$)=;pR7M(+;<1dBN>0k+h$xH6xaf6zeXZ_LtH&+4LtUO=m#?GE@AG>*JWDS$ zM9dE0A7S7*cn8kFIjDo5Kr{bWI0F0lOZ*K;^H1}SXiA0;HQq*hP!V?(4&J(nj+p*; zeFZ;%scq+Gw%$OuqQucyax@i{D z^RKtJ+(9TX6BaD|rxbhuzk|2oDd+|Xwm}2`DIA1p_&)qdQ(ouo0%Hr6&bK}dmV86^ zhjg&2JJ0n<#;PZ(zEc&e>eiH(%{e}C0uUfJKpY_ag4P;d?2f>+=?JPZ%ONf?6x*aQ_c-?r&c?o>DM#3* zaL_=Z-3K;eG5`5{4Q^qD)kuK2+%>QZ{5;mfn4D%U-$`&8eIl-N{fC8q$=np~9R8P4UJWbc5o)!d_5~J!mVPGap`5 z2Gm>+>ShdAQIDqVK!qD&bdAQ;ON+{{3m!KbLWSzR=pSbVE6h{y2lxd%M5_KedgukJ2sMh)Bth!|D)E|G2B-#p(J;dQAhL}A@3KGl=Z9-OE)D_MF zK3qdv**8^AASPTN`=+LxW$|&VI?;?JSE$SD@A3rNf?kh5Vm#yM5*+=c!K9m4dAl4lGehj^e&h9Zb%ff2 z9l?%Hk5W^s4!ZEE!r^9o&RNaEWeWZce}|9Za)Iy1m%z!;6k_$547yme+L1Lp9d1!W^LPOH)Y*A_yZYOC92i)0nj#GWZ_w}Y z%Or48@v`{MtR#yGi&>+LOh3iHXc;!Sj1RD{va`(lOuzoaJJ1iQ>--pXl)rCL*~M7? zL%*vjf1wNg860EDBi1zKJXVKi@S|vXQF)>8%O2E&%zEoWaE+AmQtj!>V6CHDOlx2- zE@0+9BZgfRJOjHl#SghjY@`%t*l0xeN@7Bkr&97nY*b8;9)C;_Wm%NsW0@|YUtWHG zB%2%+lFJt}(x@=9{Jbn2l+x08N^tIt$%*4JS!~@SiNj<(j7>P#FhS;kzpb;==MVVs z-H4-xbU@v`yMn(E9?xWB6U0K4Gix?0WtYbr3I*`RR}oV~BDzlL8BPMf3RQX{W6aRV zgf9v-H7^^ zEL;awMbGI;^uA?J_2X3*^BH41_cXVio@3wElu0`G-F>8KMk4434Vl>Rgt*b<+(;MP zeJ~O}6cG-EclAaD$3~_Oq1hoN9DhQiM?hC-8u} za1t}>`7o+c|9BkRRbdL>$+_;0vbOPvx;TZm*8`t^L&nrU2bE^Xv+O&?L0 zPU3Gj^$&73yB&RRysTt&?-c}R;ovlW3#t2OaHAf+pnmo(Y_)jZu3L%iQjbpJ*}~W< z{4gq9pTvW-xinFpA+48ud_M`jccTW%wB`zj(VE6{bE0+ne7*`AzbA-)E})wK0%B9Q Ao&W#< delta 858 zcmYL|U1(ED6vyYx+?#7}lR1e^R#t;eiNwaX`OtiMm<7$m%YBL?;j8!2iep%nXM) zb7qDv@}c{#(RrQ`LP$zzp%4faiuFr;BP8-O$ReWnUXaw{M|^L2b{$3T%ZS+PcL|2% z30-k-G_^k2n((bw11>%#;az-z!#Iw!@jTYzApZxq;W|FWOZ<+ho#hfQkspg$pFR~? z;g`KKYk(cSodezZj?Nvw<_0?Y^WC0mm94HYpc(x7x@a9Z^?lJANa#ki5kmUoXx%&~ zA*wil6MY2V;BWX0uVDsrSdTtyZtNK^js|sKW`-pyCv~MMXKGt2t;zL`lHZA5g46gE zC-Ewt!xMM_d$1K(U>$l*?O(2YJKj2u5!}F!nc4w9OKYZ_Xql{jW?-KZiKkP^bkb7u z9|A1FAL`1eNZrn@hZoXWmQY^m${%84zhb?7HGeIuB5^~B!6N(m8fXXmR2=qm3CTTT zIGdnaxhLEawXLYN8b_KSW8Xhao4_t*VVY4IG_`7KJJ-VhS3({fBlrYwiypkg8T=dn z#OL@sKENw@Nz`tMO2%-))OMpW_62gxgX3ou8WimsMtdA-o11x*Cx#LzXT7R0&FwL^ zhUsCut(J~2RAu~&;3xbK-`egLI)%j+Wr4&hKrTa)7y(uWWsFpaJO4I9mS<>2Y?fjNFT54f#x*SfEcf3a|PZzb7#&9n?X%D$oni zSJb*~S06hEYWYuW^j{ua9I)ns%oGrn>}bO%F|Xz8He(8!Q92M#mR=) z(uQ~}5c{by5o=7QBC&WXolF=P^E7>HluFxe=>|}@?RW$w{N@9!6y>s7Ab6dJop00^ OYC(Jg)h=vdBl1^%vhrjA diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index a2fc887ee41f904814dc4eabd7146dfab4f9a1d6..33d0ec2fad293bc33443a440c353f75b058f957d 100644 GIT binary patch literal 5487 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 diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 7befb6d..13d90de 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 08:16+0200\n" +"POT-Creation-Date: 2025-09-10 11:18+0200\n" "PO-Revision-Date: 2025-09-09 13:45+0200\n" "Last-Translator: Kevin Heyer \n" "Language-Team: German\n" @@ -20,15 +20,41 @@ msgstr "Verwaltung" msgid "Admin" msgstr "Admin" -#: risks/admin.py:13 +#: risks/admin.py:15 risks/models.py:36 templates/risks/dashboard.html:75 +#: templates/risks/dashboard.html:80 templates/risks/dashboard.html:85 +#: templates/risks/list_risks.html:76 +msgid "Risks" +msgstr "Risiken" + +#: risks/admin.py:16 risks/models.py:190 templates/risks/list_risks.html:37 +msgid "Controls" +msgstr "Maßnahmen" + +#: risks/admin.py:17 +msgid "Residual risks" +msgstr "Restrisiken" + +#: risks/admin.py:18 +msgid "Reviews" +msgstr "Prüfung" + +#: risks/admin.py:19 risks/models.py:258 +msgid "Incidents" +msgstr "Vorfälle" + +#: risks/admin.py:20 +msgid "Users" +msgstr "Benutzer" + +#: risks/admin.py:26 msgid "SSO Information" msgstr "SSO-Informationen" -#: risks/admin.py:20 +#: risks/admin.py:35 msgid "Risks Owned" msgstr "Eigene Risiken" -#: risks/admin.py:24 +#: risks/admin.py:39 msgid "Controls Responsible" msgstr "Verantwortlich für Maßnahmen" @@ -41,160 +67,241 @@ msgstr "Risikomanagement" msgid "Risk" msgstr "Risiko" -#: risks/models.py:36 templates/risks/dashboard.html:75 -#: templates/risks/dashboard.html:80 templates/risks/dashboard.html:85 -#: templates/risks/list_risks.html:76 -msgid "Risks" -msgstr "Risiken" - #: risks/models.py:39 -msgid "Very low – occurs less than once every 5 years" -msgstr "Sehr niedrig – tritt seltener als einmal in fünf Jahren auf" +msgid "Open" +msgstr "Offen" -#: risks/models.py:40 -msgid "Low – once every 1–5 years" -msgstr "Niedrig – einmal in 1–5 Jahren" - -#: risks/models.py:41 -msgid "Likely – once per year or more" -msgstr "Wahrscheinlich – einmal pro Jahr oder öfter" - -#: risks/models.py:42 -msgid "Very likely – multiple times per year/monthly" -msgstr "Sehr wahrscheinlich – mehrmals pro Jahr/monatlich" - -#: risks/models.py:45 -msgid "Very Low (< 1,000 € – minor operational impact)" -msgstr "Sehr Gering (< 1.000 € – geringe betriebliche Auswirkungen)" - -#: risks/models.py:46 -msgid "Low (1,000–5,000 € – local impact)" -msgstr "Gering (1.000–5.000 € – lokale Auswirkungen)" - -#: risks/models.py:47 -msgid "High (5,000–15,000 € – team-level impact)" -msgstr "Hoch (5.000–15.000 € – Auswirkungen auf Teamebene)" - -#: risks/models.py:48 -msgid "Severe (50,000–100,000 € – regional impact)" -msgstr "Schwerwiegend (50.000–100.000 € – regionale Auswirkungen)" - -#: risks/models.py:49 -msgid "Critical (> 100,000 € – existential threat)" -msgstr "Kritisch (> 100.000 € – existenzielle Bedrohung)" - -#: risks/models.py:52 templates/risks/dashboard.html:74 -msgid "Confidentiality" -msgstr "Vertraulichkeit" - -#: risks/models.py:53 templates/risks/dashboard.html:79 -msgid "Integrity" -msgstr "Integrität" - -#: risks/models.py:54 templates/risks/dashboard.html:84 -msgid "Availability" -msgstr "Verfügbarkeit" - -#: risks/models.py:58 risks/models.py:186 risks/models.py:251 -msgid "Title" -msgstr "Titel" - -#: risks/models.py:59 risks/models.py:252 -msgid "Description" -msgstr "Beschreibung" - -#: risks/models.py:60 -msgid "Asset" -msgstr "Asset" - -#: risks/models.py:61 -msgid "Process" -msgstr "Prozess" - -#: risks/models.py:62 templates/risks/list_risks.html:85 -msgid "Category" -msgstr "Kategorie" - -#: risks/models.py:63 -msgid "Created at" -msgstr "Erstellt am" - -#: risks/models.py:64 -msgid "Updated at" -msgstr "Aktualisiert am" - -#: risks/models.py:119 -msgid "Residual Risk" -msgstr "Restrisiko" - -#: risks/models.py:120 -msgid "Residual Risks" -msgstr "Restrisiken" - -#: risks/models.py:175 -msgid "Control" -msgstr "Maßnahme" - -#: risks/models.py:176 templates/risks/list_risks.html:37 -msgid "Controls" -msgstr "Maßnahmen" - -#: risks/models.py:179 -msgid "Planned" -msgstr "Geplant" - -#: risks/models.py:180 -msgid "In progress" -msgstr "In Bearbeitung" - -#: risks/models.py:181 -msgid "Completed" -msgstr "Abgeschlossen" - -#: risks/models.py:182 -msgid "Verified" -msgstr "Verifiziert" - -#: risks/models.py:183 -msgid "Rejected" -msgstr "Abgelehnt" - -#: risks/models.py:212 -msgid "Auditlog" -msgstr "Audit-Log" - -#: risks/models.py:213 -msgid "Auditlogs" -msgstr "Audit-Logs" - -#: risks/models.py:243 -msgid "Incident" -msgstr "Vorfall" - -#: risks/models.py:244 -msgid "Incidents" -msgstr "Vorfälle" - -#: risks/models.py:247 -msgid "Opened" -msgstr "Eröffnet" - -#: risks/models.py:248 +#: risks/models.py:40 risks/models.py:262 msgid "In Progress" msgstr "In Bearbeitung" -#: risks/models.py:249 +#: risks/models.py:41 risks/models.py:263 msgid "Closed" msgstr "Geschlossen" -#: risks/models.py:253 +#: risks/models.py:42 +msgid "Review required" +msgstr "Prüfung nötig" + +#: risks/models.py:45 +msgid "Very low – occurs less than once every 5 years" +msgstr "Sehr niedrig – tritt seltener als einmal in fünf Jahren auf" + +#: risks/models.py:46 +msgid "Low – once every 1–5 years" +msgstr "Niedrig – einmal in 1–5 Jahren" + +#: risks/models.py:47 +msgid "Likely – once per year or more" +msgstr "Wahrscheinlich – einmal pro Jahr oder öfter" + +#: risks/models.py:48 +msgid "Very likely – multiple times per year/monthly" +msgstr "Sehr wahrscheinlich – mehrmals pro Jahr/monatlich" + +#: risks/models.py:51 +msgid "Very Low (< 1,000 € – minor operational impact)" +msgstr "Sehr Gering (< 1.000 € – geringe betriebliche Auswirkungen)" + +#: risks/models.py:52 +msgid "Low (1,000–5,000 € – local impact)" +msgstr "Gering (1.000–5.000 € – lokale Auswirkungen)" + +#: risks/models.py:53 +msgid "High (5,000–15,000 € – team-level impact)" +msgstr "Hoch (5.000–15.000 € – Auswirkungen auf Teamebene)" + +#: risks/models.py:54 +msgid "Severe (50,000–100,000 € – regional impact)" +msgstr "Schwerwiegend (50.000–100.000 € – regionale Auswirkungen)" + +#: risks/models.py:55 +msgid "Critical (> 100,000 € – existential threat)" +msgstr "Kritisch (> 100.000 € – existenzielle Bedrohung)" + +#: risks/models.py:58 templates/risks/dashboard.html:74 +msgid "Confidentiality" +msgstr "Vertraulichkeit" + +#: risks/models.py:59 templates/risks/dashboard.html:79 +msgid "Integrity" +msgstr "Integrität" + +#: risks/models.py:60 templates/risks/dashboard.html:84 +msgid "Availability" +msgstr "Verfügbarkeit" + +#: risks/models.py:64 risks/models.py:200 risks/models.py:265 +msgid "Title" +msgstr "Titel" + +#: risks/models.py:65 risks/models.py:266 +msgid "Description" +msgstr "Beschreibung" + +#: risks/models.py:66 +msgid "Asset" +msgstr "Asset" + +#: risks/models.py:67 +msgid "Process" +msgstr "Prozess" + +#: risks/models.py:68 templates/risks/list_risks.html:85 +msgid "Category" +msgstr "Kategorie" + +#: risks/models.py:69 +msgid "Created at" +msgstr "Erstellt am" + +#: risks/models.py:70 +msgid "Updated at" +msgstr "Aktualisiert am" + +#: risks/models.py:73 +msgid "Status" +msgstr "Status" + +#: risks/models.py:133 +msgid "Residual Risk" +msgstr "Restrisiko" + +#: risks/models.py:134 +msgid "Residual Risks" +msgstr "Restrisiken" + +#: risks/models.py:189 +msgid "Control" +msgstr "Maßnahme" + +#: risks/models.py:193 +msgid "Planned" +msgstr "Geplant" + +#: risks/models.py:194 +msgid "In progress" +msgstr "In Bearbeitung" + +#: risks/models.py:195 +msgid "Completed" +msgstr "Abgeschlossen" + +#: risks/models.py:196 +msgid "Verified" +msgstr "Verifiziert" + +#: risks/models.py:197 +msgid "Rejected" +msgstr "Abgelehnt" + +#: risks/models.py:226 +msgid "Auditlog" +msgstr "Audit-Log" + +#: risks/models.py:227 +msgid "Auditlogs" +msgstr "Audit-Logs" + +#: risks/models.py:257 +msgid "Incident" +msgstr "Vorfall" + +#: risks/models.py:261 +msgid "Opened" +msgstr "Eröffnet" + +#: risks/models.py:267 msgid "Date reported" msgstr "Meldedatum" -#: risks/models.py:255 +#: risks/models.py:269 msgid "Reported by" msgstr "Gemeldet von" +#: risks/models.py:298 +msgid "User" +msgstr "Benutzer" + +#: risks/signals.py:57 +#, python-brace-format +msgid "User '{u}' created" +msgstr "Benutzer '{u}' erstellte" + +#: risks/signals.py:62 +#, python-brace-format +msgid "User '{u}' deleted" +msgstr "Benutzer '{u}' löschte" + +#: risks/signals.py:70 +#, python-brace-format +msgid "Risk '{title}' {state}" +msgstr "Risiko '{title}' {state}" + +#: risks/signals.py:72 risks/signals.py:147 risks/signals.py:240 +#: risks/signals.py:296 +msgid "created" +msgstr "erstellt" + +#: risks/signals.py:72 risks/signals.py:147 risks/signals.py:240 +#: risks/signals.py:296 +msgid "updated" +msgstr "Aktualisiert" + +#: risks/signals.py:78 +#, python-brace-format +msgid "Risk '{title}' deleted" +msgstr "Risiko '{title}' gelöscht" + +#: risks/signals.py:145 +#, python-brace-format +msgid "Control '{title}' {state}" +msgstr "Maßnahme '{title}' {state}" + +#: risks/signals.py:154 +#, python-brace-format +msgid "Control '{title}' deleted" +msgstr "Maßnahme '{title}' gelöscht" + +#: risks/signals.py:211 +#, python-brace-format +msgid "Review required for risk '{t}' due to control change" +msgstr "Prüfung nötig für: '{t}', da Maßnahmen geändert wurden" + +#: risks/signals.py:230 +#, python-brace-format +msgid "Review required for risk '{t}'" +msgstr "Prüfung benötigt für Risiko '{t}'" + +#: risks/signals.py:235 +#, python-brace-format +msgid "Review completed for risk '{t}'" +msgstr "Prüfung Abgeschlossen für Risiko '{t}'" + +#: risks/signals.py:239 +#, python-brace-format +msgid "Residual risk {state} for '{t}'" +msgstr "Restrisiko {state} für '{t}'" + +#: risks/signals.py:244 +#, python-brace-format +msgid "Residual risk deleted for '{t}'" +msgstr "Restrisiko für '{t}' gelöscht" + +#: risks/signals.py:296 +msgid "Incident '{t}' {s}" +msgstr "Vorfälle '{t}' {s}" + +#: risks/signals.py:301 +#, python-brace-format +msgid "Incident '{t}' deleted" +msgstr "Vorfall '{t}' gelöscht" + +#: risks/utils.py:48 +#, python-brace-format +msgid "Follow-up reached: review required for risk '{t}'" +msgstr "Wiedervorlagedatum erreicht: Prüfung nötig für Risiko '{t}'" + #: templates/risks/dashboard.html:9 msgid "Dashboard" msgstr "Dashboard" @@ -227,6 +334,26 @@ msgstr "Vorfälle nach Status" msgid "Risks by CIA" msgstr "CIA Risiken" +#: templates/risks/item_risk.html:68 templates/risks/item_risk.html:114 +#: templates/risks/list_risks.html:86 +msgid "Likelihood" +msgstr "Eintritt" + +#: templates/risks/item_risk.html:77 templates/risks/item_risk.html:123 +#: templates/risks/list_risks.html:87 +msgid "Impact" +msgstr "Schaden" + +#: templates/risks/item_risk.html:86 templates/risks/item_risk.html:132 +#: templates/risks/list_risks.html:89 +msgid "Level" +msgstr "Stufe" + +#: templates/risks/item_risk.html:95 templates/risks/item_risk.html:140 +#: templates/risks/list_risks.html:88 +msgid "Score" +msgstr "Score" + #: templates/risks/list_risks.html:4 msgid "Risk analysis" msgstr "Risikoanalyse" @@ -247,19 +374,3 @@ msgstr "Risikoeigner" #: templates/risks/list_risks.html:84 msgid "Asset / Process" msgstr "Asset / Prozess" - -#: templates/risks/list_risks.html:86 -msgid "Likelihood" -msgstr "Eintritt" - -#: templates/risks/list_risks.html:87 -msgid "Impact" -msgstr "Schaden" - -#: templates/risks/list_risks.html:88 -msgid "Score" -msgstr "Score" - -#: templates/risks/list_risks.html:89 -msgid "Level" -msgstr "Stufe" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 9ec2d19..0fe311a 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 08:16+0200\n" +"POT-Creation-Date: 2025-09-10 11:18+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -26,15 +26,41 @@ msgstr "" msgid "Admin" msgstr "" -#: risks/admin.py:13 -msgid "SSO Information" +#: risks/admin.py:15 risks/models.py:36 templates/risks/dashboard.html:75 +#: templates/risks/dashboard.html:80 templates/risks/dashboard.html:85 +#: templates/risks/list_risks.html:76 +msgid "Risks" +msgstr "" + +#: risks/admin.py:16 risks/models.py:190 templates/risks/list_risks.html:37 +msgid "Controls" +msgstr "" + +#: risks/admin.py:17 +msgid "Residual risks" +msgstr "" + +#: risks/admin.py:18 +msgid "Reviews" +msgstr "" + +#: risks/admin.py:19 risks/models.py:258 +msgid "Incidents" msgstr "" #: risks/admin.py:20 +msgid "Users" +msgstr "" + +#: risks/admin.py:26 +msgid "SSO Information" +msgstr "" + +#: risks/admin.py:35 msgid "Risks Owned" msgstr "" -#: risks/admin.py:24 +#: risks/admin.py:39 msgid "Controls Responsible" msgstr "" @@ -47,160 +73,242 @@ msgstr "" msgid "Risk" msgstr "" -#: risks/models.py:36 templates/risks/dashboard.html:75 -#: templates/risks/dashboard.html:80 templates/risks/dashboard.html:85 -#: templates/risks/list_risks.html:76 -msgid "Risks" -msgstr "" - #: risks/models.py:39 -msgid "Very low – occurs less than once every 5 years" +msgid "Open" msgstr "" -#: risks/models.py:40 -msgid "Low – once every 1–5 years" -msgstr "" - -#: risks/models.py:41 -msgid "Likely – once per year or more" -msgstr "" - -#: risks/models.py:42 -msgid "Very likely – multiple times per year/monthly" -msgstr "" - -#: risks/models.py:45 -msgid "Very Low (< 1,000 € – minor operational impact)" -msgstr "" - -#: risks/models.py:46 -msgid "Low (1,000–5,000 € – local impact)" -msgstr "" - -#: risks/models.py:47 -msgid "High (5,000–15,000 € – team-level impact)" -msgstr "" - -#: risks/models.py:48 -msgid "Severe (50,000–100,000 € – regional impact)" -msgstr "" - -#: risks/models.py:49 -msgid "Critical (> 100,000 € – existential threat)" -msgstr "" - -#: risks/models.py:52 templates/risks/dashboard.html:74 -msgid "Confidentiality" -msgstr "" - -#: risks/models.py:53 templates/risks/dashboard.html:79 -msgid "Integrity" -msgstr "" - -#: risks/models.py:54 templates/risks/dashboard.html:84 -msgid "Availability" -msgstr "" - -#: risks/models.py:58 risks/models.py:186 risks/models.py:251 -msgid "Title" -msgstr "" - -#: risks/models.py:59 risks/models.py:252 -msgid "Description" -msgstr "" - -#: risks/models.py:60 -msgid "Asset" -msgstr "" - -#: risks/models.py:61 -msgid "Process" -msgstr "" - -#: risks/models.py:62 templates/risks/list_risks.html:85 -msgid "Category" -msgstr "" - -#: risks/models.py:63 -msgid "Created at" -msgstr "" - -#: risks/models.py:64 -msgid "Updated at" -msgstr "" - -#: risks/models.py:119 -msgid "Residual Risk" -msgstr "" - -#: risks/models.py:120 -msgid "Residual Risks" -msgstr "" - -#: risks/models.py:175 -msgid "Control" -msgstr "" - -#: risks/models.py:176 templates/risks/list_risks.html:37 -msgid "Controls" -msgstr "" - -#: risks/models.py:179 -msgid "Planned" -msgstr "" - -#: risks/models.py:180 -msgid "In progress" -msgstr "" - -#: risks/models.py:181 -msgid "Completed" -msgstr "" - -#: risks/models.py:182 -msgid "Verified" -msgstr "" - -#: risks/models.py:183 -msgid "Rejected" -msgstr "" - -#: risks/models.py:212 -msgid "Auditlog" -msgstr "" - -#: risks/models.py:213 -msgid "Auditlogs" -msgstr "" - -#: risks/models.py:243 -msgid "Incident" -msgstr "" - -#: risks/models.py:244 -msgid "Incidents" -msgstr "" - -#: risks/models.py:247 -msgid "Opened" -msgstr "" - -#: risks/models.py:248 +#: risks/models.py:40 risks/models.py:262 msgid "In Progress" msgstr "" -#: risks/models.py:249 +#: risks/models.py:41 risks/models.py:263 msgid "Closed" msgstr "" -#: risks/models.py:253 +#: risks/models.py:42 +msgid "Review required" +msgstr "" + +#: risks/models.py:45 +msgid "Very low – occurs less than once every 5 years" +msgstr "" + +#: risks/models.py:46 +msgid "Low – once every 1–5 years" +msgstr "" + +#: risks/models.py:47 +msgid "Likely – once per year or more" +msgstr "" + +#: risks/models.py:48 +msgid "Very likely – multiple times per year/monthly" +msgstr "" + +#: risks/models.py:51 +msgid "Very Low (< 1,000 € – minor operational impact)" +msgstr "" + +#: risks/models.py:52 +msgid "Low (1,000–5,000 € – local impact)" +msgstr "" + +#: risks/models.py:53 +msgid "High (5,000–15,000 € – team-level impact)" +msgstr "" + +#: risks/models.py:54 +msgid "Severe (50,000–100,000 € – regional impact)" +msgstr "" + +#: risks/models.py:55 +msgid "Critical (> 100,000 € – existential threat)" +msgstr "" + +#: risks/models.py:58 templates/risks/dashboard.html:74 +msgid "Confidentiality" +msgstr "" + +#: risks/models.py:59 templates/risks/dashboard.html:79 +msgid "Integrity" +msgstr "" + +#: risks/models.py:60 templates/risks/dashboard.html:84 +msgid "Availability" +msgstr "" + +#: risks/models.py:64 risks/models.py:200 risks/models.py:265 +msgid "Title" +msgstr "" + +#: risks/models.py:65 risks/models.py:266 +msgid "Description" +msgstr "" + +#: risks/models.py:66 +msgid "Asset" +msgstr "" + +#: risks/models.py:67 +msgid "Process" +msgstr "" + +#: risks/models.py:68 templates/risks/list_risks.html:85 +msgid "Category" +msgstr "" + +#: risks/models.py:69 +msgid "Created at" +msgstr "" + +#: risks/models.py:70 +msgid "Updated at" +msgstr "" + +#: risks/models.py:73 +msgid "Status" +msgstr "" + +#: risks/models.py:133 +msgid "Residual Risk" +msgstr "" + +#: risks/models.py:134 +msgid "Residual Risks" +msgstr "" + +#: risks/models.py:189 +msgid "Control" +msgstr "" + +#: risks/models.py:193 +msgid "Planned" +msgstr "" + +#: risks/models.py:194 +msgid "In progress" +msgstr "" + +#: risks/models.py:195 +msgid "Completed" +msgstr "" + +#: risks/models.py:196 +msgid "Verified" +msgstr "" + +#: risks/models.py:197 +msgid "Rejected" +msgstr "" + +#: risks/models.py:226 +msgid "Auditlog" +msgstr "" + +#: risks/models.py:227 +msgid "Auditlogs" +msgstr "" + +#: risks/models.py:257 +msgid "Incident" +msgstr "" + +#: risks/models.py:261 +msgid "Opened" +msgstr "" + +#: risks/models.py:267 msgid "Date reported" msgstr "" -#: risks/models.py:255 +#: risks/models.py:269 msgid "Reported by" msgstr "" +#: risks/models.py:298 +msgid "User" +msgstr "" + +#: risks/signals.py:57 +#, python-brace-format +msgid "User '{u}' created" +msgstr "" + +#: risks/signals.py:62 +#, python-brace-format +msgid "User '{u}' deleted" +msgstr "" + +#: risks/signals.py:70 +#, python-brace-format +msgid "Risk '{title}' {state}" +msgstr "" + +#: risks/signals.py:72 risks/signals.py:147 risks/signals.py:240 +#: risks/signals.py:296 +msgid "created" +msgstr "" + +#: risks/signals.py:72 risks/signals.py:147 risks/signals.py:240 +#: risks/signals.py:296 +msgid "updated" +msgstr "" + +#: risks/signals.py:78 +#, python-brace-format +msgid "Risk '{title}' deleted" +msgstr "" + +#: risks/signals.py:145 +#, python-brace-format +msgid "Control '{title}' {state}" +msgstr "" + +#: risks/signals.py:154 +#, python-brace-format +msgid "Control '{title}' deleted" +msgstr "" + +#: risks/signals.py:211 +#, python-brace-format +msgid "Review required for risk '{t}' due to control change" +msgstr "" + +#: risks/signals.py:230 +#, python-brace-format +msgid "Review required for risk '{t}'" +msgstr "" + +#: risks/signals.py:235 +#, python-brace-format +msgid "Review completed for risk '{t}'" +msgstr "" + +#: risks/signals.py:239 +#, python-brace-format +msgid "Residual risk {state} for '{t}'" +msgstr "" + +#: risks/signals.py:244 +#, python-brace-format +msgid "Residual risk deleted for '{t}'" +msgstr "" + +#: risks/signals.py:296 +#, python-brace-format +msgid "Incident '{t}' {s}" +msgstr "" + +#: risks/signals.py:301 +#, python-brace-format +msgid "Incident '{t}' deleted" +msgstr "" + +#: risks/utils.py:48 +#, python-brace-format +msgid "Follow-up reached: review required for risk '{t}'" +msgstr "" + #: templates/risks/dashboard.html:9 msgid "Dashboard" msgstr "" @@ -233,6 +341,26 @@ msgstr "" msgid "Risks by CIA" msgstr "" +#: templates/risks/item_risk.html:68 templates/risks/item_risk.html:114 +#: templates/risks/list_risks.html:86 +msgid "Likelihood" +msgstr "" + +#: templates/risks/item_risk.html:77 templates/risks/item_risk.html:123 +#: templates/risks/list_risks.html:87 +msgid "Impact" +msgstr "" + +#: templates/risks/item_risk.html:86 templates/risks/item_risk.html:132 +#: templates/risks/list_risks.html:89 +msgid "Level" +msgstr "" + +#: templates/risks/item_risk.html:95 templates/risks/item_risk.html:140 +#: templates/risks/list_risks.html:88 +msgid "Score" +msgstr "" + #: templates/risks/list_risks.html:4 msgid "Risk analysis" msgstr "" @@ -253,19 +381,3 @@ msgstr "" #: templates/risks/list_risks.html:84 msgid "Asset / Process" msgstr "" - -#: templates/risks/list_risks.html:86 -msgid "Likelihood" -msgstr "" - -#: templates/risks/list_risks.html:87 -msgid "Impact" -msgstr "" - -#: templates/risks/list_risks.html:88 -msgid "Score" -msgstr "" - -#: templates/risks/list_risks.html:89 -msgid "Level" -msgstr "" diff --git a/risks/admin.py b/risks/admin.py index 0616cf8..b1a783f 100644 --- a/risks/admin.py +++ b/risks/admin.py @@ -1,12 +1,25 @@ 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 User, Risk, ResidualRisk, Control, Incident +from .models import Control, Incident, NotificationPreference , Risk, ResidualRisk, User admin.site.site_header = _("Administration") admin.site.site_title = _("Admin") admin.site.index_title = _("Administration") +class NotificationPreferenceInline(admin.StackedInline): + model = NotificationPreference + can_delete = False + extra = 0 + fieldsets = ( + (_("Risks"), {"fields": ("risk_created","risk_updated","risk_deleted")}), + (_("Controls"), {"fields": ("control_created","control_updated","control_deleted")}), + (_("Residual risks"), {"fields": ("residual_created","residual_updated","residual_deleted")}), + (_("Reviews"), {"fields": ("review_required","review_completed")}), + (_("Incidents"), {"fields": ("incident_created","incident_updated","incident_deleted")}), + (_("Users"), {"fields": ("user_created","user_deleted")}), + ) + @admin.register(User) class UserAdmin(BaseUserAdmin): fieldsets = BaseUserAdmin.fieldsets + ( @@ -14,6 +27,8 @@ class UserAdmin(BaseUserAdmin): ) list_display = ("username", "email", "is_staff", "is_superuser", "is_sso_user", "owned_risks_count", "responsible_controls_count") + + inlines = [NotificationPreferenceInline] def owned_risks_count(self, obj): return obj.risks_owned.count() @@ -44,6 +59,7 @@ class RiskAdmin(admin.ModelAdmin): list_display = ( "title", "owner_name", + "status", "score", "level", "likelihood", @@ -56,7 +72,7 @@ class RiskAdmin(admin.ModelAdmin): return "-" return obj.owner.get_full_name() or obj.owner.username - list_filter = ("level", "likelihood", "impact", "owner") + list_filter = ("status", "level", "likelihood", "impact", "owner") search_fields = ("title", "asset", "process", "category") inlines = [ResidualRiskInline, ControlRisksInline] @@ -117,4 +133,5 @@ class IncidentAdmin(admin.ModelAdmin): def delete_model(self, request, obj): obj._changed_by = request.user - super().delete_model(request, obj) \ No newline at end of file + super().delete_model(request, obj) + diff --git a/risks/migrations/0021_risk_status_notificationpreference.py b/risks/migrations/0021_risk_status_notificationpreference.py new file mode 100644 index 0000000..c1059b8 --- /dev/null +++ b/risks/migrations/0021_risk_status_notificationpreference.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.6 on 2025-09-10 09:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0020_alter_residualrisk_impact_alter_risk_impact"), + ] + + operations = [ + migrations.AddField( + model_name="risk", + name="status", + field=models.CharField( + choices=[ + ("open", "Open"), + ("in_progress", "In Progress"), + ("closed", "Closed"), + ("review_required", "Review required"), + ], + db_index=True, + default="open", + max_length=20, + verbose_name="Status", + ), + ), + migrations.CreateModel( + name="NotificationPreference", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("risk_created", models.BooleanField(default=True)), + ("risk_updated", models.BooleanField(default=True)), + ("risk_deleted", models.BooleanField(default=True)), + ("control_created", models.BooleanField(default=True)), + ("control_updated", models.BooleanField(default=True)), + ("control_deleted", models.BooleanField(default=True)), + ("residual_created", models.BooleanField(default=True)), + ("residual_updated", models.BooleanField(default=True)), + ("residual_deleted", models.BooleanField(default=True)), + ("review_required", models.BooleanField(default=True)), + ("review_completed", models.BooleanField(default=True)), + ("user_created", models.BooleanField(default=False)), + ("user_deleted", models.BooleanField(default=False)), + ("incident_created", models.BooleanField(default=True)), + ("incident_updated", models.BooleanField(default=True)), + ("incident_deleted", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="notification_preference", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + ), + ] diff --git a/risks/models.py b/risks/models.py index 07c739a..426dbd9 100644 --- a/risks/models.py +++ b/risks/models.py @@ -35,6 +35,12 @@ class Risk(models.Model): verbose_name = _("Risk") verbose_name_plural = _("Risks") + STATUS_CHOICES = [ + ("open", _("Open")), + ("in_progress", _("In Progress")), + ("closed", _("Closed")), + ("review_required", _("Review required")), + ] LIKELIHOOD_CHOICES = [ (1, _("Very low – occurs less than once every 5 years")), (2, _("Low – once every 1–5 years")), @@ -63,6 +69,14 @@ class Risk(models.Model): created_at = models.DateTimeField(_("Created at"), auto_now_add=True) updated_at = models.DateTimeField(_("Updated at"), auto_now=True) + status = models.CharField( + _("Status"), + max_length=20, + choices=STATUS_CHOICES, + default="open", + db_index=True, + ) + # CIA Protection Goals cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True) @@ -271,4 +285,52 @@ class Notification(models.Model): def __str__(self): user_display = self.user.username if self.user else "System" - return f"{user_display}: {self.message[:50]}..." \ No newline at end of file + return f"{user_display}: {self.message[:50]}..." + +class NotificationPreference(models.Model): + """ + Wich events does the user want to receive as notifications? + """ + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notification_preference", + verbose_name=_("User"), + ) + + # Risks + risk_created = models.BooleanField(default=True) + risk_updated = models.BooleanField(default=True) + risk_deleted = models.BooleanField(default=True) + + # Controls + control_created = models.BooleanField(default=True) + control_updated = models.BooleanField(default=True) + control_deleted = models.BooleanField(default=True) + + # Residual risks + residual_created = models.BooleanField(default=True) + residual_updated = models.BooleanField(default=True) + residual_deleted = models.BooleanField(default=True) + + # Reviews + review_required = models.BooleanField(default=True) + review_completed = models.BooleanField(default=True) + + # Users + user_created = models.BooleanField(default=False) + user_deleted = models.BooleanField(default=False) + + # Incidents + incident_created = models.BooleanField(default=True) + incident_updated = models.BooleanField(default=True) + incident_deleted = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + 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 diff --git a/risks/serializers.py b/risks/serializers.py index fb12561..91a1642 100644 --- a/risks/serializers.py +++ b/risks/serializers.py @@ -53,6 +53,7 @@ class RiskSerializer(serializers.ModelSerializer): "impact", "score", "level", + "status", "owner", "follow_up", "cia", diff --git a/risks/signals.py b/risks/signals.py index 599a838..99b2d38 100644 --- a/risks/signals.py +++ b/risks/signals.py @@ -1,15 +1,19 @@ from datetime import date, datetime +from django.contrib.auth import get_user_model from django.db.models import Model 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 +from .models import Control, Risk, ResidualRisk, AuditLog, Incident, Notification, NotificationPreference from .utils import model_diff # --------------------------------------------------------------------------- # General definitions # --------------------------------------------------------------------------- +User = get_user_model() + def serialize_value(value): if isinstance(value, Model): return value.pk # oder str(value), wenn du mehr Infos willst @@ -17,9 +21,65 @@ def serialize_value(value): return value.isoformat() return value +def _pref(user: User) -> NotificationPreference | None: + if not user: + return None + pref = getattr(user, "notification_preference", None) + if not pref: + pref = NotificationPreference.objects.create(user=user) + return pref + +def _notify(users, message: str, event_code: str): + """legt Notification für alle users an, die dieses Event wünschen.""" + for u in set(filter(None, users)): + pref = _pref(u) + if pref and pref.should_notify(event_code): + Notification.objects.create(user=u, message=message) + +def _risk_stakeholders(risk: Risk): + """Risikoeigner + alle Verantwortlichen zugehöriger Controls.""" + owners = [risk.owner] if risk.owner else [] + responsibles = list( + User.objects.filter(responsible_controls__risks=risk).distinct() + ) + return set(owners + responsibles) + +# --------------------------------------------------------------------------- +# Incidents +# --------------------------------------------------------------------------- +@receiver(post_save, sender=User) +def user_saved(sender, instance: User, created, **kwargs): + # Prefs automatisch anlegen + _pref(instance) + # An Staff, die dieses Event wollen + if created: + staff = User.objects.filter(is_staff=True, notification_preference__user_created=True) + _notify(staff, _("User '{u}' created").format(u=instance.username), "user_created") + +@receiver(post_delete, sender=User) +def user_deleted(sender, instance: User, **kwargs): + staff = User.objects.filter(is_staff=True, notification_preference__user_deleted=True) + _notify(staff, _("User '{u}' deleted").format(u=instance.username), "user_deleted") + # --------------------------------------------------------------------------- # Risks # --------------------------------------------------------------------------- +@receiver(post_save, sender=Risk) +def risk_saved(sender, instance: Risk, created, **kwargs): + event = "risk_created" if created else "risk_updated" + msg = _("Risk '{title}' {state}").format( + title=instance.title, + state=_("created") if created else _("updated"), + ) + _notify([instance.owner], msg, event) + +@receiver(post_delete, sender=Risk) +def risk_deleted(sender, instance: Risk, **kwargs): + msg = _("Risk '{title}' deleted").format(title=instance.title) + # Owner existiert evtl. nicht mehr -> kein Notify nötig + if instance.owner: + _notify([instance.owner], msg, "risk_deleted") + @receiver(post_save, sender=Risk) def log_risk_save(sender, instance, created, **kwargs): if created: @@ -68,6 +128,32 @@ def log_risk_delete(sender, instance, **kwargs): # --------------------------------------------------------------------------- # Controls # --------------------------------------------------------------------------- +@receiver(post_save, sender=Control) +def control_saved(sender, instance: Control, created, **kwargs): + # Review-Flag für alle betroffenen Residuals setzen + for risk in instance.risks.all(): + resid, created = ResidualRisk.objects.get_or_create(risk=risk) + # Statuswechsel auf Review Required + if not resid.review_required: + resid.review_required = True + resid.save() + if risk.status != "review_required": + Risk.objects.filter(pk=risk.pk).update(status="review_required") + + # Notifications + event = "control_created" if created else "control_updated" + msg = _("Control '{title}' {state}").format( + title=instance.title, + state=_("created") if created else _("updated"), + ) + stakeholders = {instance.responsible} | set(r.owner for r in instance.risks.all() if r.owner) + _notify(stakeholders, msg, event) + +@receiver(post_delete, sender=Control) +def control_deleted(sender, instance: Control, **kwargs): + msg = _("Control '{title}' deleted").format(title=instance.title) + stakeholders = {instance.responsible} | set(r.owner for r in instance.risks.all() if r.owner) + _notify(stakeholders, msg, "control_deleted") @receiver(post_save, sender=Control) def log_control_save(sender, instance, created, **kwargs): @@ -112,28 +198,51 @@ def log_control_delete(sender, instance, **kwargs): ) @receiver(m2m_changed, sender=Control.risks.through) -def control_risks_changed(sender, instance, 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 == "post_clear": - affected_risks = instance.risks.all() - elif pk_set: - if reverse: - from .models import Risk - affected_risks = Risk.objects.filter(pk__in=pk_set) - else: - affected_risks = Risk.objects.filter(pk__in=pk_set) - else: - affected_risks = instance.risks.all() - - from .models import ResidualRisk - for risk in affected_risks: - residual, _ = ResidualRisk.objects.get_or_create(risk=risk) - residual.review_required = True - residual.save() + affected = instance.risks.all() if not pk_set else Risk.objects.filter(pk__in=pk_set) + for risk in affected: + resid, created = ResidualRisk.objects.get_or_create(risk=risk) + if not resid.review_required: + resid.review_required = True + resid.save() + if risk.status != "review_required": + Risk.objects.filter(pk=risk.pk).update(status="review_required") + _notify(_risk_stakeholders(risk), _("Review required for risk '{t}' due to control change").format(t=risk.title), "review_required") # --------------------------------------------------------------------------- # Residual risks # --------------------------------------------------------------------------- +@receiver(post_save, sender=ResidualRisk) +def residual_saved(sender, instance: ResidualRisk, created, **kwargs): + # AuditLog erstellst du bereits anderswo – hier Fokus auf Status/Notify + risk = instance.risk + old = None + if not created: + try: + old = ResidualRisk.objects.get(pk=instance.pk) + except ResidualRisk.DoesNotExist: + pass + + # Review-Logik: wenn review_required=True -> Risk.status = review_required + if instance.review_required and risk.status != "review_required": + Risk.objects.filter(pk=risk.pk).update(status="review_required") + _notify(_risk_stakeholders(risk), _("Review required for risk '{t}'").format(t=risk.title), "review_required") + elif old and old.review_required and not instance.review_required: + # Review abgeschlossen + if risk.status == "review_required": + Risk.objects.filter(pk=risk.pk).update(status="open") + _notify(_risk_stakeholders(risk), _("Review completed for risk '{t}'").format(t=risk.title), "review_completed") + + # Standard-Events + event = "residual_created" if created else "residual_updated" + _notify(_risk_stakeholders(risk), _("Residual risk {state} for '{t}'").format( + state=_("created") if created else _("updated"), t=risk.title), event) + +@receiver(post_delete, sender=ResidualRisk) +def residual_deleted(sender, instance: ResidualRisk, **kwargs): + _notify(_risk_stakeholders(instance.risk), _("Residual risk deleted for '{t}'").format(t=instance.risk.title), "residual_deleted") + @receiver(post_save, sender=ResidualRisk) def log_residual_save(sender, instance, created, **kwargs): @@ -180,6 +289,16 @@ def log_residual_delete(sender, instance, **kwargs): # --------------------------------------------------------------------------- # Incidents # --------------------------------------------------------------------------- +@receiver(post_save, sender=Incident) +def incident_saved(sender, instance: Incident, created, **kwargs): + event = "incident_created" if created else "incident_updated" + stakeholders = set([instance.reported_by]) | set(r.owner for r in instance.related_risks.all() if r.owner) + _notify(stakeholders, _("Incident '{t}' {s}").format(t=instance.title, s=_("created") if created else _("updated")), event) + +@receiver(post_delete, sender=Incident) +def incident_deleted(sender, instance: Incident, **kwargs): + stakeholders = set([instance.reported_by]) | set(r.owner for r in instance.related_risks.all() if r.owner) + _notify(stakeholders, _("Incident '{t}' deleted").format(t=instance.title), "incident_deleted") @receiver(post_save, sender=Incident) def log_incident_save(sender, instance, created, **kwargs): diff --git a/risks/urls.py b/risks/urls.py index 6fbc91a..b450e3f 100644 --- a/risks/urls.py +++ b/risks/urls.py @@ -6,7 +6,6 @@ app_name = "risks" urlpatterns = [ path("", views.dashboard, name="dashboard"), path("risks/index", views.dashboard, name="index"), - path("risks/stats", views.stats, name="statistics"), path("risks/list_risks", views.list_risks, name="list_risks"), path("risks/risks/", views.show_risk, name="show_risk"), path("risks/list_controls", views.list_controls, name="list_controls"), diff --git a/risks/utils.py b/risks/utils.py index 36b1cec..c71dff4 100644 --- a/risks/utils.py +++ b/risks/utils.py @@ -1,5 +1,7 @@ +from django.contrib.auth import get_user_model from django.utils.timezone import now -from .models import AuditLog, Risk, Notification +from django.utils.translation import gettext_lazy as _ +from .models import AuditLog, Notification, Risk, ResidualRisk def model_diff(old, new, fields=None): """ @@ -29,21 +31,28 @@ def check_risk_followups(): Ensures no duplicate notifications per risk per day """ today = now().date() - risks = Risk.objects.filter(follow_up__lte=today) + risks = Risk.objects.filter(follow_up__lte=today).select_related("owner") for risk in risks: - if risk.owner: - notification, created = Notification.objects.get_or_create( - user=risk.owner, - message=f"Follow-up required for risk: {risk.title}", - defaults={"read": False, "sent": False}, - ) + # Risk-Status auf review_required setzen (nicht überschreiben, wenn bereits closed) + if risk.status != "closed" and risk.status != "review_required": + Risk.objects.filter(pk=risk.pk).update(status="review_required") - if created: - AuditLog.objects.create( - user=None, # system action - action="create", - model="Notification", - object_id=notification.pk, - changes={"message": notification.message, "user": risk.owner.username}, - ) \ No newline at end of file + # ResidualRisk-Objekt sicherstellen und Review-Flag setzen + resid, created = ResidualRisk.objects.get_or_create(risk=risk) + if not resid.review_required: + resid.review_required = True + resid.save() + + # Notification an Stakeholder + message = _("Follow-up reached: review required for risk '{t}'").format(t=risk.title) + notification, created = Notification.objects.get_or_create( + user=risk.owner, + message=message, + defaults={"read": False, "sent": False}, + ) + if created: + 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 diff --git a/risks/views.py b/risks/views.py index 1a994e7..e7d3569 100644 --- a/risks/views.py +++ b/risks/views.py @@ -50,6 +50,9 @@ class ControlViewSet(viewsets.ModelViewSet): instance._changed_by = self.request.user class ResidualRiskViewSet(viewsets.ModelViewSet): + """ + API endpoint for Residual risks. + """ queryset = ResidualRisk.objects.all() serializer_class = ResidualRiskSerializer permission_classes = [IsAuthenticated] @@ -98,12 +101,11 @@ class IncidentViewSet(viewsets.ModelViewSet): # Web => Risks, Controls, Incidents # --------------------------------------------------------------------------- -@login_required -def stats(request): - return render(request, "risks/statistics.html") - @login_required def list_risks(request): + """ + View for listing all Risks + """ qs = Risk.objects.all().select_related("owner") # GET-Parameter lesen @@ -131,10 +133,14 @@ def list_risks(request): @login_required def show_risk(request, id): + """ + View for single risk + """ risk = get_object_or_404( Risk.objects.select_related("residual_risk", "owner").prefetch_related("controls"), pk=id, ) + ct = ContentType.objects.get_for_model(Risk) logs = LogEntry.objects.filter(content_type=ct, object_id=risk.pk).order_by("-action_time") @@ -142,6 +148,9 @@ def show_risk(request, id): @login_required def list_controls(request): + """ + View for listing all Controls + """ qs = Control.objects.all().select_related("responsible") control_id = request.GET.get("control") @@ -183,6 +192,9 @@ def show_control(request, id): @login_required def list_incidents(request): + """ + View for listing all Incidents + """ qs = Incident.objects.all().select_related("reported_by").prefetch_related("related_risks") risk_id = request.GET.get("risk") @@ -218,13 +230,11 @@ def show_incident(request, id): ).order_by("-action_time") return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs}) - -# --------------------------------------------------------------------------- -# Web => Dashboard -# --------------------------------------------------------------------------- - @login_required def dashboard(request): + """ + Dashboardview with KPIs + """ # Risikoübersicht risks_total = Risk.objects.count() risks_by_level = Risk.objects.values('level').annotate(count=Count('id')) diff --git a/templates/risks/item_risk.html b/templates/risks/item_risk.html index 2a3267b..20ba0fa 100644 --- a/templates/risks/item_risk.html +++ b/templates/risks/item_risk.html @@ -209,6 +209,37 @@ + +
+
+

Vorfälle

+
+
+ {% if risk.incidents.exists %} + + + + + + + + + + {% for i in risk.incidents.all %} + + + + + + {% endfor %} + +
VorfallStatusgemeldet am
{{ i.title }}{{ i.status }}{{ i.created_at|date:"d.m.Y H:i" }}
+ {% else %} +

Keine Vorfälle bekannt.

+ {% endif %} +
+
+