From ebfcbddd5c7392f1b9df7661b02980d8fd3f343d Mon Sep 17 00:00:00 2001 From: Kevin Heyer Date: Wed, 10 Sep 2025 13:44:03 +0200 Subject: [PATCH] Implement notification system and status update forms - Added Notification model with admin interface for managing notifications. - Created context processor to count unread notifications for the user. - Introduced forms for updating the status of Risk, Control, Incident, and ResidualRisk. - Added views for displaying and managing notifications, including marking them as read. - Updated URLs to include routes for notifications and status updates. - Enhanced templates to support notifications display and status update forms. - Improved CSS for avatar and badge display in the navbar. - Translated various static texts to support internationalization. --- config/settings.py | 1 + db.sqlite3 | Bin 233472 -> 233472 bytes locale/de/LC_MESSAGES/django.mo | Bin 5487 -> 7178 bytes locale/de/LC_MESSAGES/django.po | 177 +++++++++++++++--- locale/en/LC_MESSAGES/django.po | 176 ++++++++++++++--- risks/admin.py | 91 +++++++-- risks/context_processors.py | 7 + risks/forms.py | 31 +++ .../0022_alter_notification_options.py | 19 ++ risks/models.py | 4 + risks/urls.py | 12 ++ risks/views.py | 111 ++++++++++- static/css/design.css | 33 +++- templates/base.html | 55 +++--- templates/risks/item_control.html | 22 ++- templates/risks/item_incident.html | 21 ++- templates/risks/item_risk.html | 119 +++++++----- templates/risks/list_risks.html | 3 +- templates/risks/notifications.html | 57 ++++++ 19 files changed, 797 insertions(+), 142 deletions(-) create mode 100644 risks/context_processors.py create mode 100644 risks/forms.py create mode 100644 risks/migrations/0022_alter_notification_options.py create mode 100644 templates/risks/notifications.html diff --git a/config/settings.py b/config/settings.py index a8864c4..8b6b5ef 100644 --- a/config/settings.py +++ b/config/settings.py @@ -76,6 +76,7 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "risks.context_processors.unread_notifications_count", ], }, }, diff --git a/db.sqlite3 b/db.sqlite3 index deb65ca34a191a1f5419868d32e06a9027fb354c..6e58103f0933811eafc465d585010ce89b9ad83c 100644 GIT binary patch delta 1527 zcmb_cU2NM_6!yK25}`kPw#c z=PTcH@An;_@7&|(*yHEeg)Ve{v3n4muOEB|$N?%+qx~b8iqx&IJc+>pinw!cykwZu zCeMrFL^@y2mL>|tvXL_mrOQUKFj1UwU#7^5N`%)V0v{Imm?FhQh0`TX)8(a7@KnoA z_5%Xgss}!TAK+zp9|CX&2H2nB03_MV?C)T*RrbxgHO5lmPBMY)vxms;9vs?E3aCkc z6DbWg(YqM$sa@tC_l9<~kO{^{gQ&Zf-2EyVSh}=Z=DjBg7-mn_tvS|b4PutC_QY$a z3(p5O1j=emQ8=ELHD0W>6qh$YYcWCLWLe}@^~6fCu~I~96Ub+O{w3N4PF|uGlVnbn zL{U{PtRPRA48goj|AzX#@EV+e`MQ;a07W9SE!4zd6Fclv>uA=At(~T^6M7cjfLh&} z0Pv6u3b_z;p`-CO;7fOe=cCXnq{Xb#&j_p zpDxd)^Uge5Du;8Qeq0LoKawtGD(O--GGt^Y&2oAw-_LQJ{nmZB>(~=!wsg<<{QUT~ zO))q8qQnHAlLa9f74kusu^qL!jMLd8M&Y^AY}Ryk{x@d)%%5zTO?%2QNq6k2N{Vi^ zc5Lu9GyEU;+)R7=(wST4DaQnnQ*>PuA`n3J{iG7eorp}y>$zq`zL}Rt~kRwfZ7P*-Uk$fP7rzJNUk z19a;}0+!(+c8z_HeU=ThUaVX1w(cCGX`&cse9S-D#eQWShdZ}AOHoCQPkCu#+}Vlj zm)3EYy}FJ&w{NLactw_Qin?t~lvART5Y=$XbL*I>+r+x6tCEaUGtw5(mj2RfHk<@ QXbYoXz{eo$ArXWB0KdPUF#rGn delta 691 zcmaixO-K}R7{=%SpV<#rnRj(;>0{N6jFrT7zW1ZRR+F+!4JD)xbkS8t9cSGyt&p6$ z1Y&AS42TDjAc!v0E>6&`LmfIKO1J0|9U=rTb}6!nI(g~%JUs9F{vO`BHF|E1UOI;6 zm;G&M-W+@aWYCc=xV+t_?spW^hZa32!61NMq2LvKfqU>As$dlw=x=ZXI_Pcs7i8!c z^oprx=^Eb=l0#`FGoBfk)UwKmGL*P0iZW(DwmUX1slmP8ru5u*V@rC7IvYL{xxeV9{{xqM)fKz*yWzIonx(?83of zWhx8SI^eDHz=%9GB;B4FnqkFoIFZkdPpRrHPE*;4I-FBv&iGV=OBeE5e@q?YCUV1C zw6*KJ)HmIk5fk~Vg4We7^>$nvxuo?gqiSETs$S9BM|qZ)n%HO)FZ?O-4>}@Bg0u+P!JWcxLJ!8> z#>IV{LT zGDcl4g0F^`L54Epa5cOVs@)CnOxT1kgU2C5m=D5N!aKwFd*B(APlWm}LiPJH)I8sU zZ-Czkd>Wod`EO9;p8EmOJQNAmb?}PlzL%g((ufQ?* zO{o6A58qd_XtHJ`a5L2WSHai8NvQd6fUk!I)Vv>q=fHbH`Abmpc@%1$Uk!W`TFO5N z^}mN&&vS4D{sUeJ*Rc54z&(&p<`{e}Y(UB7W+;7p3Z4b;fok_4l-?hM{LGJdoerOZ zn&%gAEqoSA?yp0+;)PKAyaa0h8z4>0HptIR^11+C8+aVPnettMk3xRt$D#ZTlzva+ zrTv@(H^Yrk^R=MXl|jk14E!kMXYS)Axjh24j&DHe|GQ9feF|!wzl7TNA0R*TPhJ}L zOoXR)=R)cEjZphP5WY`C^?z6R?x4m`pysykO3&BB^Wg_V`Ch2;9*5HB zx1sd*6x4Xn!OieAj8yY%4cr0M??5OYgW697rH>R|3A=DBd=yGvzkyo!p94>4GJQV> z%Kp!XY|(6jZ-jfH=ADIfF$vVV?tqfd$D!777nHtE!1eIKQ2!IC_52iWgg=LGf#)%q z#vg|o_h=~31%3$X9DV_!Qu6@B1k4jqa{3O`I(`MUjz2=J^Dj{LaT-oU``R3M1yubm zC^;R3TK9EO<20eh&7t=HNvL!90F>UIglhK;lt1|`lzv~2bA2luf!p8|lpOAa+V`iR z_W4;z7xO68KE4OFj-Nr<%da6n^A}!TA9RwQ&WD=!QYblG4z-VcQ2Rat-vO_Mn(yB5 z{RGrF4?wkl7$SP}1l0b%54HZMq1O8csP+97B0_U6Myl`YpyuBKHO~ax1aF73ug9R; z{RwJ6e}^1HbC4wcT}#r#XRm+FVM%(fCt=>^{VMQi{ri2+n0eR^`D0Kzm3|_U*1wi? zIcbK(l1)ash_r>I=Q`3fX@?3tM@Z7=H6+Pd&qxLHVwjSo2R*AR`1c;(lC$2IMBk6f3sYQB^3Ow(s;NLg$mb~=b;NR5?w`AT&lI^T~WFPM)9avG(q5NLbA<`^K z{z^7nCnY55Q?@ES_nt{IbEKFwMrxAu)GBzLT^#Z{w;M=~Bweo|T}qPv_mN~z6QsA1 z){(9z?IKD4SCI6`c63i(rUK7y(pI0V{8-4EDKfI}agyv>KH(^7CuuK9&sC(iE5p+u z9aiSzk#udtrdb&`<9bxaSz6dml+U?_jS8E)sImSOZ3>r`r)twp|C5orX*JeFqZ6mb zzjq$47#C&kS7#=YMClYuo+t`e8vnK%?BP7CyP_}?-9}s{S*xGn)A1-yqS-i!%SE#@ zDqSnf$s}3f8fItKnNM8lb7?bfu+uoIT4rgPX9+Dl?V4LkMs~NZu?^Snp%z0mw-hDg z-`ek^Vz;o}`39d6a~P}WXY3JI%%hNaHgSWGvx|1x_cS{ZDm`sfl8sCBm)Maj?bzt( zy3x^5`}7?T*{5&&v~@Qjy=v*UY^%>&vkMj5sjT71746w9${XZLhsNi#Tsz+7`Yl{h z&*OR7hS?J*rOVBpEJ?D3T6bO+Qg6G)IGN+ITL3q9;pHM}B^guX}ayvC34_^rgSz^DV`# zgO)4h-fOEFs=*375b|V-O(5Mxb3h9;2iQniiS9r==aRUcWevWPUG(D4(z>(rF1L#= z$}LjpWVtg3vKBVYJL{HU!H$dtSu7Vtl1Ym_DX5`T=*MLGe9?|k-r~nD%#^ekaTvl; z4Iip=TNMh-vF?rJg}zp2(;RdQ=3ut`Sgq&Fz%eW=AFAgW6!M%_M3K&R>6EZ?@qadpoX7czJy|%`a51rT9(Pdp>04i3s(*1Ji5YE1#nI zifY^FI$LJ89{f?g9i=T7#$+vCRN*wPa8#;0iI?)9r=m1!xem_54`B~2q?~?VMNP6; z#MRh#<(X2Gs;cmv8orZ1S34&s%=Gjj)Q*ejR8D<5I_}JLT?b`aX6|qm-igUqtZ-+! zYvGB84oUEaGuoG#$;x0Fg*obPodCPsK#zK_8`f>AlJ}%X3wEd%GU?*a>y?uo{?v2v zYE_tPT+ZEz)sk7X{%Bue{o(HWH?A}MK!#KB+Z|fF?~@FiN2i;VF?WtFREVgglW~=P>k<_lxUem}F0qW2gN|KJB$A-h|FTaNn*IAun)&}Q)lkGW%jsiR zvb2RLDVe}8{(suR^P;eu`%zeptFQ{gl=G*kx%BnctU}MYH2X0vBC*(+y+j{v!ZE~Y zCT3ehC00mK*iBfi?=uxGok*j0$8qz8+>l>GPD_t7RC&_cUToIynFfzGF;{ZeVDHl6 zpjP6E(sF@VN%LFQo}=wBZ93G_%1(L_+wPo5EJ;cmb~@C2x*ZY}nw*saL)3N*FU3`yeI z&!S+hYDMvyEN>Fan80dMour^PeP6m?^6=NORZJJ!o=jK$=DtzYaaa|-xNhnG(oC1# zCL!7&vfCXK*2iUOjz#SpLAp5gYE~>pzJmuR6t!1VYYC!ryYzTdaq+S!4;QA`V+2R# zCFUj9zY~&dE@Cgka$9Q-qM;n&56y$qtU^9brZXT3oW#fYs)6Uxe>iORR2^P$tb?v= z_On%I5BB^VW36;XreC>iNn!GGP)*-M%_i^NIihN&{5f0tJg%mY1I4%J)|;A3*A1N_ z+a)?2=y9*Zw-bqQg;jn8Z;-e)U-lBBa0W_yoWCf9Y3NRNJIFF||1UZn0uQe zmdhCX)fpx?ud_hN(;uli4VB#uEOMy69}UV6^uxt!9ZcRoT+2MzNzPcmVRZ@z9XUz< zT$ZQqQA8f%!{ml;zO7?wB40eJ*Qc99mEb{EEl&z zw8+2&7KDpLq<)Z-9avu@3yE_@8t~`K)Sdzbby%inpU9yCdGC8iI<;SAxWtZI*!v(o}j!w6{86VNh zpdiJUoB@9&xRn*b?+w1|&IuNa%W}A$o`)gcT;_~h-t#y+>##sKE7$eBjjI2`^SxV# z@3=QY&*0w_dPe?|tL99HT$J~bmcN(;VH{y~3*Hg0#g&*gy^E}F1k>B885WKC7pTy3 A)Bpeg delta 2096 zcmZA2eQ4EH9LMo9pQhfshurSnymNZh+=FfNPOVLQa-~yiZkc&a6xMalZL%h<+rz^6 z{gFYK3GQfxNlg%)1g5(}q_~A4`bQ!nLKu{aMfitehEEjw{`~GC#KZmmUgvjy_jew? z=iF^`?e*#2Z{tSnFqAkkf%titF&=Il$%XQMrZKAaU^aR<9JjjmUd-YCOB{{;I0g@4 z5Rba{Q#gwIv+nsX$a|i-Oofm6o6C5-=gg-!by&qEA8T+NHsDxn#u>OCHQ-Ln!aW$k z0n~tlSdAx8-@onJOL-}vrB?YBW9A~yOg)CN2{qt4yZb z#i*4wx%N2fz166#Y)AF~VUYZ5CZEutRDb5|Lv@%!W#k8(g~w5Qcnf1#!tAu770za? z%P^)5_5Oa+rh&dgjdKLmK7^Y1iEQ%E_L%eTi@zfubCrwQ@1kZtoNv>o$w9qX%tg;D zQCm`jMYtR_;9IEFZ$xGGD^x!NsI3@8y?@iAq8I){7G*}~rc)Qf=eVDMwYUnk((R}T z?s9(X+K*rX?Z;7v^a5&tYZ$^is0n00md-!~m02%JrHV>9&cxSo3Vw=8)d5sThcSkO zsEJ&6-geLLqZTlRebj=&r~xOVGEs>-v<;|?b*7(tW-}H2Ts}vVV21EFA1Y)0s0n?K%+>saBlY_~PvvEn-1c9SAUcjXwdd*L9+?3gp2;{s!S&)6Vr4< z(Y~uFy((J8V!vk00@VKt6>Z0Bg#IrqBIXe)bBRWxNjH?231z09P=@(Qdj1~_>i!{o z1$B6|j|+)1;z?pA@n}&hwH?|5m2yJo?AX9\n" "Language-Team: German\n" @@ -26,7 +26,8 @@ msgstr "Admin" msgid "Risks" msgstr "Risiken" -#: risks/admin.py:16 risks/models.py:190 templates/risks/list_risks.html:37 +#: risks/admin.py:16 risks/models.py:190 templates/base.html:36 +#: templates/risks/list_risks.html:37 msgid "Controls" msgstr "Maßnahmen" @@ -38,7 +39,7 @@ msgstr "Restrisiken" msgid "Reviews" msgstr "Prüfung" -#: risks/admin.py:19 risks/models.py:258 +#: risks/admin.py:19 risks/models.py:258 templates/base.html:37 msgid "Incidents" msgstr "Vorfälle" @@ -46,22 +47,70 @@ msgstr "Vorfälle" msgid "Users" msgstr "Benutzer" -#: risks/admin.py:26 +#: risks/admin.py:133 risks/models.py:302 +msgid "User" +msgstr "Benutzer" + +#: risks/admin.py:139 +msgid "Message" +msgstr "" + +#: risks/admin.py:147 +msgid "Mark selected as read" +msgstr "Alle als gelesen Markieren" + +#: risks/admin.py:150 +msgid "%(n)d notifications marked as read." +msgstr "%(n)d Benachrichtigungen wurden als gelesen Markiert" + +#: risks/admin.py:152 +msgid "Mark selected as unread" +msgstr "Alle als gelesen Markieren" + +#: risks/admin.py:155 +msgid "%(n)d notifications marked as unread." +msgstr "%(n)d Benachrichtigungen wurden als ungelesen Markiert" + +#: risks/admin.py:157 +msgid "Mark selected as sent" +msgstr "" + +#: risks/admin.py:160 +msgid "%(n)d notifications marked as sent." +msgstr "Alle Benachrichtigungen wurden als gelesen Markiert" + +#: risks/admin.py:162 +msgid "Mark selected as unsent" +msgstr "" + +#: risks/admin.py:165 +msgid "%(n)d notifications marked as unsent." +msgstr "Alle Benachrichtigungen wurden als gelesen Markiert" + +#: risks/admin.py:177 msgid "SSO Information" msgstr "SSO-Informationen" -#: risks/admin.py:35 +#: risks/admin.py:186 msgid "Risks Owned" msgstr "Eigene Risiken" -#: risks/admin.py:39 +#: risks/admin.py:190 msgid "Controls Responsible" msgstr "Verantwortlich für Maßnahmen" -#: risks/apps.py:7 +#: risks/apps.py:7 templates/base.html:7 templates/base.html:32 msgid "Risk Management" msgstr "Risikomanagement" +#: risks/forms.py:9 risks/forms.py:16 risks/forms.py:23 risks/models.py:73 +msgid "Status" +msgstr "Status" + +#: risks/forms.py:30 risks/models.py:42 templates/risks/item_risk.html:136 +msgid "Review required" +msgstr "Prüfung nötig" + #: risks/models.py:35 templates/risks/list_risks.html:18 #: templates/risks/list_risks.html:83 msgid "Risk" @@ -79,10 +128,6 @@ msgstr "In Bearbeitung" msgid "Closed" msgstr "Geschlossen" -#: 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" @@ -159,10 +204,6 @@ msgstr "Erstellt am" msgid "Updated at" msgstr "Aktualisiert am" -#: risks/models.py:73 -msgid "Status" -msgstr "Status" - #: risks/models.py:133 msgid "Residual Risk" msgstr "Restrisiko" @@ -219,9 +260,14 @@ msgstr "Meldedatum" msgid "Reported by" msgstr "Gemeldet von" -#: risks/models.py:298 -msgid "User" -msgstr "Benutzer" +#: risks/models.py:279 +msgid "Notification" +msgstr "Benachrichtigung" + +#: risks/models.py:280 templates/base.html:88 +#: templates/risks/notifications.html:4 +msgid "Notifications" +msgstr "Nachrichten" #: risks/signals.py:57 #, python-brace-format @@ -289,6 +335,7 @@ msgid "Residual risk deleted for '{t}'" msgstr "Restrisiko für '{t}' gelöscht" #: risks/signals.py:296 +#, python-brace-format msgid "Incident '{t}' {s}" msgstr "Vorfälle '{t}' {s}" @@ -302,10 +349,62 @@ msgstr "Vorfall '{t}' gelöscht" 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 +#: risks/views.py:315 +msgid "Notification marked as read." +msgstr "Nachricht als gelesen markiert" + +#: risks/views.py:323 +msgid "All notifications marked as read." +msgstr "Alle Benachrichtigungen wurden als gelesen Markiert" + +#: risks/views.py:340 +msgid "Risk status updated." +msgstr "Risikostatus Aktualisiert" + +#: risks/views.py:354 +msgid "Control status updated." +msgstr "Maßnahmenstatus Aktualisiert" + +#: risks/views.py:368 +msgid "Incident status updated." +msgstr "Vorfallstatus Aktualisiert" + +#: risks/views.py:384 +msgid "Residual review flag updated." +msgstr "Restrisiko geprüft" + +#: templates/base.html:34 templates/risks/dashboard.html:9 msgid "Dashboard" msgstr "Dashboard" +#: templates/base.html:35 templates/risks/list_risks.html:4 +msgid "Risk analysis" +msgstr "Risikoanalyse" + +#: templates/base.html:70 +msgid "AdminCP" +msgstr "Adminbereich" + +#: templates/base.html:76 +msgid "Derk Mode" +msgstr "Dark Mode" + +#: templates/base.html:82 +msgid "Logout" +msgstr "Logout" + +#: templates/base.html:104 +msgid "Login" +msgstr "Login" + +#: templates/base.html:139 templates/base.html:146 +msgid "Light Mode" +msgstr "Light Mode" + +#: templates/base.html:149 +msgid "Dark Mode" +msgstr "Dark Mode" + #: templates/risks/dashboard.html:12 msgid "Overview of Risks, Controls and Incidents" msgstr "Übersicht der Risiken, Maßnahmen und Vorfälle" @@ -334,36 +433,40 @@ 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/item_risk.html:34 +msgid "Update status" +msgstr "Status Aktualisiert" + +#: templates/risks/item_risk.html:89 templates/risks/item_risk.html:147 #: templates/risks/list_risks.html:86 msgid "Likelihood" msgstr "Eintritt" -#: templates/risks/item_risk.html:77 templates/risks/item_risk.html:123 +#: templates/risks/item_risk.html:98 templates/risks/item_risk.html:156 #: templates/risks/list_risks.html:87 msgid "Impact" msgstr "Schaden" -#: templates/risks/item_risk.html:86 templates/risks/item_risk.html:132 +#: templates/risks/item_risk.html:107 templates/risks/item_risk.html:165 #: templates/risks/list_risks.html:89 msgid "Level" msgstr "Stufe" -#: templates/risks/item_risk.html:95 templates/risks/item_risk.html:140 +#: templates/risks/item_risk.html:116 templates/risks/item_risk.html:173 #: templates/risks/list_risks.html:88 msgid "Score" msgstr "Score" -#: templates/risks/list_risks.html:4 -msgid "Risk analysis" -msgstr "Risikoanalyse" +#: templates/risks/item_risk.html:139 +msgid "Save" +msgstr "Speichern" #: templates/risks/list_risks.html:9 msgid "Filter" msgstr "Filter" #: templates/risks/list_risks.html:22 templates/risks/list_risks.html:41 -#: templates/risks/list_risks.html:60 +#: templates/risks/list_risks.html:60 templates/risks/notifications.html:13 msgid "All" msgstr "Alle" @@ -374,3 +477,23 @@ msgstr "Risikoeigner" #: templates/risks/list_risks.html:84 msgid "Asset / Process" msgstr "Asset / Prozess" + +#: templates/risks/notifications.html:12 +msgid "Unread" +msgstr "Ungelesen" + +#: templates/risks/notifications.html:20 +msgid "Mark all as read" +msgstr "Alle als gelesen Markieren" + +#: templates/risks/notifications.html:33 +msgid "New" +msgstr "Neu" + +#: templates/risks/notifications.html:43 +msgid "Mark as read" +msgstr "Als gelesen markieren" + +#: templates/risks/notifications.html:53 +msgid "No notifications." +msgstr "Keine Nachrichten" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 0fe311a..8c3a626 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 11:18+0200\n" +"POT-Creation-Date: 2025-09-10 12:51+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -32,7 +32,8 @@ msgstr "" msgid "Risks" msgstr "" -#: risks/admin.py:16 risks/models.py:190 templates/risks/list_risks.html:37 +#: risks/admin.py:16 risks/models.py:190 templates/base.html:36 +#: templates/risks/list_risks.html:37 msgid "Controls" msgstr "" @@ -44,7 +45,7 @@ msgstr "" msgid "Reviews" msgstr "" -#: risks/admin.py:19 risks/models.py:258 +#: risks/admin.py:19 risks/models.py:258 templates/base.html:37 msgid "Incidents" msgstr "" @@ -52,22 +53,74 @@ msgstr "" msgid "Users" msgstr "" -#: risks/admin.py:26 +#: risks/admin.py:133 risks/models.py:302 +msgid "User" +msgstr "" + +#: risks/admin.py:139 +msgid "Message" +msgstr "" + +#: risks/admin.py:147 +msgid "Mark selected as read" +msgstr "" + +#: risks/admin.py:150 +#, python-format +msgid "%(n)d notifications marked as read." +msgstr "" + +#: risks/admin.py:152 +msgid "Mark selected as unread" +msgstr "" + +#: risks/admin.py:155 +#, python-format +msgid "%(n)d notifications marked as unread." +msgstr "" + +#: risks/admin.py:157 +msgid "Mark selected as sent" +msgstr "" + +#: risks/admin.py:160 +#, python-format +msgid "%(n)d notifications marked as sent." +msgstr "" + +#: risks/admin.py:162 +msgid "Mark selected as unsent" +msgstr "" + +#: risks/admin.py:165 +#, python-format +msgid "%(n)d notifications marked as unsent." +msgstr "" + +#: risks/admin.py:177 msgid "SSO Information" msgstr "" -#: risks/admin.py:35 +#: risks/admin.py:186 msgid "Risks Owned" msgstr "" -#: risks/admin.py:39 +#: risks/admin.py:190 msgid "Controls Responsible" msgstr "" -#: risks/apps.py:7 +#: risks/apps.py:7 templates/base.html:7 templates/base.html:32 msgid "Risk Management" msgstr "" +#: risks/forms.py:9 risks/forms.py:16 risks/forms.py:23 risks/models.py:73 +msgid "Status" +msgstr "" + +#: risks/forms.py:30 risks/models.py:42 templates/risks/item_risk.html:136 +msgid "Review required" +msgstr "" + #: risks/models.py:35 templates/risks/list_risks.html:18 #: templates/risks/list_risks.html:83 msgid "Risk" @@ -85,10 +138,6 @@ msgstr "" msgid "Closed" msgstr "" -#: risks/models.py:42 -msgid "Review required" -msgstr "" - #: risks/models.py:45 msgid "Very low – occurs less than once every 5 years" msgstr "" @@ -165,10 +214,6 @@ msgstr "" msgid "Updated at" msgstr "" -#: risks/models.py:73 -msgid "Status" -msgstr "" - #: risks/models.py:133 msgid "Residual Risk" msgstr "" @@ -225,8 +270,13 @@ msgstr "" msgid "Reported by" msgstr "" -#: risks/models.py:298 -msgid "User" +#: risks/models.py:279 +msgid "Notification" +msgstr "" + +#: risks/models.py:280 templates/base.html:88 +#: templates/risks/notifications.html:4 +msgid "Notifications" msgstr "" #: risks/signals.py:57 @@ -309,10 +359,62 @@ msgstr "" msgid "Follow-up reached: review required for risk '{t}'" msgstr "" -#: templates/risks/dashboard.html:9 +#: risks/views.py:315 +msgid "Notification marked as read." +msgstr "" + +#: risks/views.py:323 +msgid "All notifications marked as read." +msgstr "" + +#: risks/views.py:340 +msgid "Risk status updated." +msgstr "" + +#: risks/views.py:354 +msgid "Control status updated." +msgstr "" + +#: risks/views.py:368 +msgid "Incident status updated." +msgstr "" + +#: risks/views.py:384 +msgid "Residual review flag updated." +msgstr "" + +#: templates/base.html:34 templates/risks/dashboard.html:9 msgid "Dashboard" msgstr "" +#: templates/base.html:35 templates/risks/list_risks.html:4 +msgid "Risk analysis" +msgstr "" + +#: templates/base.html:70 +msgid "AdminCP" +msgstr "" + +#: templates/base.html:76 +msgid "Derk Mode" +msgstr "" + +#: templates/base.html:82 +msgid "Logout" +msgstr "" + +#: templates/base.html:104 +msgid "Login" +msgstr "" + +#: templates/base.html:139 templates/base.html:146 +msgid "Light Mode" +msgstr "" + +#: templates/base.html:149 +msgid "Dark Mode" +msgstr "" + #: templates/risks/dashboard.html:12 msgid "Overview of Risks, Controls and Incidents" msgstr "" @@ -341,28 +443,32 @@ msgstr "" msgid "Risks by CIA" msgstr "" -#: templates/risks/item_risk.html:68 templates/risks/item_risk.html:114 +#: templates/risks/item_risk.html:34 +msgid "Update status" +msgstr "" + +#: templates/risks/item_risk.html:89 templates/risks/item_risk.html:147 #: templates/risks/list_risks.html:86 msgid "Likelihood" msgstr "" -#: templates/risks/item_risk.html:77 templates/risks/item_risk.html:123 +#: templates/risks/item_risk.html:98 templates/risks/item_risk.html:156 #: templates/risks/list_risks.html:87 msgid "Impact" msgstr "" -#: templates/risks/item_risk.html:86 templates/risks/item_risk.html:132 +#: templates/risks/item_risk.html:107 templates/risks/item_risk.html:165 #: templates/risks/list_risks.html:89 msgid "Level" msgstr "" -#: templates/risks/item_risk.html:95 templates/risks/item_risk.html:140 +#: templates/risks/item_risk.html:116 templates/risks/item_risk.html:173 #: templates/risks/list_risks.html:88 msgid "Score" msgstr "" -#: templates/risks/list_risks.html:4 -msgid "Risk analysis" +#: templates/risks/item_risk.html:139 +msgid "Save" msgstr "" #: templates/risks/list_risks.html:9 @@ -370,7 +476,7 @@ msgid "Filter" msgstr "" #: templates/risks/list_risks.html:22 templates/risks/list_risks.html:41 -#: templates/risks/list_risks.html:60 +#: templates/risks/list_risks.html:60 templates/risks/notifications.html:13 msgid "All" msgstr "" @@ -381,3 +487,23 @@ msgstr "" #: templates/risks/list_risks.html:84 msgid "Asset / Process" msgstr "" + +#: templates/risks/notifications.html:12 +msgid "Unread" +msgstr "" + +#: templates/risks/notifications.html:20 +msgid "Mark all as read" +msgstr "" + +#: templates/risks/notifications.html:33 +msgid "New" +msgstr "" + +#: templates/risks/notifications.html:43 +msgid "Mark as read" +msgstr "" + +#: templates/risks/notifications.html:53 +msgid "No notifications." +msgstr "" diff --git a/risks/admin.py b/risks/admin.py index b1a783f..1d5f0de 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, NotificationPreference , Risk, ResidualRisk, User +from .models import Control, Incident, Notification, NotificationPreference , Risk, ResidualRisk, User admin.site.site_header = _("Administration") admin.site.site_title = _("Admin") @@ -20,24 +20,6 @@ class NotificationPreferenceInline(admin.StackedInline): (_("Users"), {"fields": ("user_created","user_deleted")}), ) -@admin.register(User) -class UserAdmin(BaseUserAdmin): - fieldsets = BaseUserAdmin.fieldsets + ( - (_("SSO Information"), {"fields": ("is_sso_user",)}), - ) - 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() - owned_risks_count.short_description = _("Risks Owned") - - def responsible_controls_count(self, obj): - return obj.controls_responsible.count() - responsible_controls_count.short_description = _("Controls Responsible") - class ResidualRiskInline(admin.StackedInline): """ Inline editor for ResidualRisk, linked one-to-one with Risk @@ -135,3 +117,74 @@ class IncidentAdmin(admin.ModelAdmin): obj._changed_by = request.user super().delete_model(request, obj) +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + + date_hierarchy = "created_at" + list_display = ("id", "created_at", "user_display", "short_message", "read", "sent") + list_display_links = ("id", "short_message") + list_filter = ("read", "sent", "created_at") + search_fields = ("message", "user__username", "user__first_name", "user__last_name", "user__email") + list_select_related = ("user",) + list_editable = ("read", "sent") + ordering = ("-created_at",) + autocomplete_fields = ("user",) + + @admin.display(description=_("User")) + def user_display(self, obj): + if not obj.user: + return "—" + return obj.user.get_full_name() or obj.user.username + + @admin.display(description=_("Message")) + def short_message(self, obj): + msg = obj.message or "" + return (msg[:80] + "…") if len(msg) > 80 else msg + + # Bulk-Aktionen + actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"] + + @admin.action(description=_("Mark selected as read")) + def mark_as_read(self, request, queryset): + n = queryset.update(read=True) + self.message_user(request, _("%(n)d notifications marked as read.") % {"n": n}) + + @admin.action(description=_("Mark selected as unread")) + def mark_as_unread(self, request, queryset): + n = queryset.update(read=False) + self.message_user(request, _("%(n)d notifications marked as unread.") % {"n": n}) + + @admin.action(description=_("Mark selected as sent")) + def mark_as_sent(self, request, queryset): + n = queryset.update(sent=True) + self.message_user(request, _("%(n)d notifications marked as sent.") % {"n": n}) + + @admin.action(description=_("Mark selected as unsent")) + def mark_as_unsent(self, request, queryset): + n = queryset.update(sent=False) + self.message_user(request, _("%(n)d notifications marked as unsent.") % {"n": n}) + +class NotificationInline(admin.TabularInline): + model = Notification + fields = ("created_at", "message", "read", "sent") + readonly_fields = ("created_at", "message") + extra = 0 + ordering = ("-created_at",) + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + fieldsets = BaseUserAdmin.fieldsets + ( + (_("SSO Information"), {"fields": ("is_sso_user",)}), + ) + list_display = ("username", "email", "is_staff", "is_superuser", "is_sso_user", + "owned_risks_count", "responsible_controls_count") + + inlines = [NotificationInline, NotificationPreferenceInline] + + def owned_risks_count(self, obj): + return obj.risks_owned.count() + owned_risks_count.short_description = _("Risks Owned") + + def responsible_controls_count(self, obj): + return obj.controls_responsible.count() + responsible_controls_count.short_description = _("Controls Responsible") \ No newline at end of file diff --git a/risks/context_processors.py b/risks/context_processors.py new file mode 100644 index 0000000..aecaaf1 --- /dev/null +++ b/risks/context_processors.py @@ -0,0 +1,7 @@ +def unread_notifications_count(request): + if not request.user.is_authenticated: + return {"notifications_unread_count": 0} + from .models import Notification + return { + "notifications_unread_count": Notification.objects.filter(user=request.user, read=False).count() + } \ No newline at end of file diff --git a/risks/forms.py b/risks/forms.py new file mode 100644 index 0000000..7ec45d0 --- /dev/null +++ b/risks/forms.py @@ -0,0 +1,31 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from .models import Risk, Control, Incident, ResidualRisk + +class RiskStatusForm(forms.ModelForm): + class Meta: + model = Risk + fields = ["status"] + labels = {"status": _("Status")} + widgets = {"status": forms.Select(attrs={"class": "select"})} + +class ControlStatusForm(forms.ModelForm): + class Meta: + model = Control + fields = ["status"] + labels = {"status": _("Status")} + widgets = {"status": forms.Select(attrs={"class": "select"})} + +class IncidentStatusForm(forms.ModelForm): + class Meta: + model = Incident + fields = ["status"] + labels = {"status": _("Status")} + widgets = {"status": forms.Select(attrs={"class": "select"})} + +class ResidualReviewForm(forms.ModelForm): + class Meta: + model = ResidualRisk + fields = ["review_required"] + labels = {"review_required": _("Review required")} + widgets = {"review_required": forms.CheckboxInput(attrs={"class": "checkbox"})} \ No newline at end of file diff --git a/risks/migrations/0022_alter_notification_options.py b/risks/migrations/0022_alter_notification_options.py new file mode 100644 index 0000000..1ca8cd6 --- /dev/null +++ b/risks/migrations/0022_alter_notification_options.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.6 on 2025-09-10 10:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0021_risk_status_notificationpreference"), + ] + + operations = [ + migrations.AlterModelOptions( + name="notification", + options={ + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + }, + ), + ] diff --git a/risks/models.py b/risks/models.py index 426dbd9..9995a56 100644 --- a/risks/models.py +++ b/risks/models.py @@ -275,6 +275,10 @@ class Incident(models.Model): updated_at = models.DateTimeField(auto_now=True) class Notification(models.Model): + class Meta: + verbose_name = _("Notification") + verbose_name_plural = _("Notifications") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications") message = models.TextField() diff --git a/risks/urls.py b/risks/urls.py index b450e3f..031882e 100644 --- a/risks/urls.py +++ b/risks/urls.py @@ -5,6 +5,7 @@ app_name = "risks" urlpatterns = [ path("", views.dashboard, name="dashboard"), + path("risks/index", views.dashboard, name="index"), path("risks/list_risks", views.list_risks, name="list_risks"), path("risks/risks/", views.show_risk, name="show_risk"), @@ -12,4 +13,15 @@ urlpatterns = [ path("risks/controls/", views.show_control, name="show_control"), path("risks/list_incidents", views.list_incidents, name="list_incidents"), path("risks/incidents/", views.show_incident, name="show_incident"), + + # Notifications + path("notifications/", views.notifications, name="notifications"), + path("notifications//read", views.notification_mark_read, name="notification_mark_read"), + path("notifications/mark_all_read", views.notification_mark_all_read, name="notification_mark_all_read"), + + # Risks status + path("risks//status", views.update_risk_status, name="update_risk_status"), + path("controls//status", views.update_control_status, name="update_control_status"), + path("incidents//status", views.update_incident_status, name="update_incident_status"), + path("residuals//review", views.update_residual_review, name="update_residual_review"), ] \ No newline at end of file diff --git a/risks/views.py b/risks/views.py index e7d3569..e0a4cc6 100644 --- a/risks/views.py +++ b/risks/views.py @@ -2,16 +2,29 @@ from django.contrib.admin.models import LogEntry from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType +from django.contrib import messages from django.db.models import Count, Q +from django.http import HttpResponseForbidden +from django.shortcuts import redirect, render, get_object_or_404 +from django.utils.translation import gettext_lazy as _ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from django.shortcuts import render, get_object_or_404 from collections import Counter +from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm from .models import Risk, Control, ResidualRisk, AuditLog, Incident, Notification from .serializers import ControlSerializer, RiskSerializer, ResidualRiskSerializer, UserSerializer, AuditSerializer, IncidentSerializer User = get_user_model() +def _can_edit_risk(user, risk: Risk) -> bool: + return bool(user.is_staff or (risk.owner_id and risk.owner_id == user.id)) + +def _can_edit_control(user, control: Control) -> bool: + return bool(user.is_staff or (control.responsible_id and control.responsible_id == user.id)) + +def _can_edit_incident(user, incident: Incident) -> bool: + return bool(user.is_staff or (incident.reported_by_id and incident.reported_by_id == user.id)) + # --------------------------------------------------------------------------- # API # --------------------------------------------------------------------------- @@ -274,3 +287,99 @@ def dashboard(request): 'notifications_unread': notifications_unread, } return render(request, 'risks/dashboard.html', context) + +# --------------------------------------------------------------------------- +# Notifications +# --------------------------------------------------------------------------- + +@login_required +def notifications(request): + """Eigene Benachrichtigungen ansehen + filtern""" + flt = request.GET.get("filter", "unread") + qs = Notification.objects.filter(user=request.user).order_by("-created_at") + if flt == "unread": + qs = qs.filter(read=False) + # Einfache Pagination (optional) + return render(request, "risks/notifications.html", { + "notifications": qs, + "filter": flt, + }) + +@login_required +def notification_mark_read(request, pk): + if request.method != "POST": + return HttpResponseForbidden() + notif = get_object_or_404(Notification, pk=pk, user=request.user) + notif.read = True + notif.save(update_fields=["read"]) + messages.success(request, _("Notification marked as read.")) + return redirect(request.META.get("HTTP_REFERER") or "risks:notifications") + +@login_required +def notification_mark_all_read(request): + if request.method != "POST": + return HttpResponseForbidden() + Notification.objects.filter(user=request.user, read=False).update(read=True) + messages.success(request, _("All notifications marked as read.")) + return redirect("risks:notifications") + +# --------------------------------------------------------------------------- +# Status Updates +# --------------------------------------------------------------------------- +@login_required +def update_risk_status(request, id): + risk = get_object_or_404(Risk, pk=id) + if not _can_edit_risk(request.user, risk): + return HttpResponseForbidden() + if request.method == "POST": + form = RiskStatusForm(request.POST, instance=risk) + if form.is_valid(): + obj = form.save(commit=False) + obj._changed_by = request.user + obj.save(update_fields=["status", "updated_at"]) + messages.success(request, _("Risk status updated.")) + return redirect("risks:show_risk", id=risk.pk) + +@login_required +def update_control_status(request, id): + control = get_object_or_404(Control, pk=id) + if not _can_edit_control(request.user, control): + return HttpResponseForbidden() + if request.method == "POST": + form = ControlStatusForm(request.POST, instance=control) + if form.is_valid(): + obj = form.save(commit=False) + obj._changed_by = request.user + obj.save(update_fields=["status", "updated_at"]) + messages.success(request, _("Control status updated.")) + return redirect("risks:show_control", id=control.pk) + +@login_required +def update_incident_status(request, id): + incident = get_object_or_404(Incident, pk=id) + if not _can_edit_incident(request.user, incident): + return HttpResponseForbidden() + if request.method == "POST": + form = IncidentStatusForm(request.POST, instance=incident) + if form.is_valid(): + obj = form.save(commit=False) + obj._changed_by = request.user + obj.save(update_fields=["status", "updated_at"]) + messages.success(request, _("Incident status updated.")) + return redirect("risks:show_incident", id=incident.pk) + +@login_required +def update_residual_review(request, risk_id): + """Review-Flag (Restrisiko) setzen/lösen""" + risk = get_object_or_404(Risk, pk=risk_id) + if not _can_edit_risk(request.user, risk): + return HttpResponseForbidden() + residual, created_resid = ResidualRisk.objects.get_or_create(risk=risk) + if request.method == "POST": + form = ResidualReviewForm(request.POST, instance=residual) + if form.is_valid(): + obj = form.save(commit=False) + obj._changed_by = request.user + obj.save(update_fields=["review_required", "updated_at"]) + messages.success(request, _("Residual review flag updated.")) + return redirect("risks:show_risk", id=risk.pk) \ No newline at end of file diff --git a/static/css/design.css b/static/css/design.css index 62fe93d..7392c06 100644 --- a/static/css/design.css +++ b/static/css/design.css @@ -243,4 +243,35 @@ body.dark-mode a { @media (max-width: 1215px) { .risk-chip { --chip-w: 100%; width: var(--chip-w); } -} \ No newline at end of file +} + +/* Container für Avatar + Badge */ +.avatar-wrap { + position: relative; + display: inline-block; +} + +.avatar-wrap .badge { + position: absolute; + top: -0.35rem; + right: -0.35rem; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 .25rem; + font-size: 0.75rem; + line-height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 2px #fff; +} + +.avatar-wrap .tag.is-medium + .badge { + min-width: 1.15rem; + height: 1.15rem; + font-size: 0.70rem; + line-height: 1.15rem; +} + +/* Dark-Mode/ +.navbar.is-dark .avatar-wrap .badge { box-shadow: 0 0 0 2px hsl(229, 53%, 18%); } \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 7be4056..d872ddb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,9 +1,10 @@ {% load static %} +{% load i18n %} - Risiko Management + {% trans "Risk Management" %} @@ -28,12 +29,12 @@