From f7ead4e5c3fd09dfb6eb2656275505a0a819a68f Mon Sep 17 00:00:00 2001 From: Kevin Heyer Date: Fri, 12 Sep 2025 13:04:04 +0200 Subject: [PATCH] Refactor risk management templates for improved usability and localization - Updated `item_incident.html` to implement ERP-style tabs for better navigation and added action icons for editing and deleting incidents. - Enhanced the overview tab with translated labels and improved layout for incident details. - Introduced linked risks and history tabs with appropriate translations and table structures. - Modified `item_risk.html` to include action icons for editing and deleting risks. - Refined `list_controls.html` to improve filter section layout and added translations for filter labels. - Updated `list_incidents.html` to enhance filter functionality and table layout, including translations for headers and buttons. - Improved `list_risks.html` by adding an action icon for adding new risks. - Adjusted `notifications.html` to enhance the display of new notifications with improved formatting and links. --- .gitignore | 1 - db.sqlite3 | Bin 266240 -> 278528 bytes risks/admin.py | 188 +++++---- risks/apps.py | 18 +- risks/audit_context.py | 14 + risks/context_processors.py | 13 +- risks/email_utils.py | 30 -- risks/forms.py | 48 ++- risks/middleware.py | 9 + ...content_type_notification_kind_and_more.py | 57 +++ risks/models.py | 249 ++++++----- risks/serializers.py | 43 +- risks/signals.py | 388 +++++++----------- risks/urls.py | 56 ++- risks/utils.py | 88 ++-- risks/views.py | 345 +++++++--------- static/css/design.css | 29 +- templates/risks/item_control.html | 256 ++++++------ templates/risks/item_incident.html | 249 +++++------ templates/risks/item_risk.html | 9 + templates/risks/list_controls.html | 294 ++++++------- templates/risks/list_incidents.html | 251 +++++------ templates/risks/list_risks.html | 1 + templates/risks/notifications.html | 10 +- 24 files changed, 1313 insertions(+), 1333 deletions(-) delete mode 100644 risks/email_utils.py create mode 100644 risks/migrations/0027_notification_content_type_notification_kind_and_more.py diff --git a/.gitignore b/.gitignore index 12a5b55..2c8cf69 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,6 @@ var/ db.sqlite3 media/ staticfiles/ -static/ # If you are using WhiteNoise for static file management static_root/ diff --git a/db.sqlite3 b/db.sqlite3 index f1189600f3eefc17d3a8b1fcd9e592d2d1310f63..e7d5ec4ac8dd25513e3f30c05051a3dc7d17c00d 100644 GIT binary patch delta 4935 zcmb`Ld2kcg8Nhen(^_7wv`@kY9~@q6j1O$FtJUgC1Ceb482P}M*e+odSz2t_vgAlI zwn?dgOeaTYQV4Bco2HOy8m51w85&Qc%%n-$q@AR6+FUbj)20nQG7~~tAdr-VV)~ve zW0a#wJN3-|EPe0#-nZZJp7uRx-M7znaH;JLhGE*s;~)V>sq7m+yyY8c(aq%R z;`Icj2bZVWi%R(1ViK*OC)uN?9iHJZ?oc%6xru=`kXGi>~y*u>dgAm=Y;zE8LOxl zp(oLs=m2^IEkX~Vdhs+G5p{7^{DU|pW>KejRNOS9$je1e6z^x2AM#fm*6mug_$tFO zOO`{0&8b+Rgs-v^dRZKdz$J%Yv6r*l;nVij%TOr?o7u=nC_a=3h9YCpcrcb2YN|59 z4LoLB*=2_}uf3CPo_S?$-FjP2Vb$exI_#QL)ns+Xl=$$t$f3V7=n{Glok6G2UuSIN zFC%>RC@=uPWlybTZ?JOxOvcoIIAm=!aHrT~#FpP>v$KA-=uy}tet?ZtM88CRbc);uwFxMfQ9IO3gJ|BzdCyV|0+b_PQmM&lz1cTcEU5A?XU zwhwJ>i6+zA$G@mYI|3VeHoG=&NOf)P7;NurYiVtrmg9+@Dfh0)wvExY(11^wm{Pp5 zFWwVxPfQ1I47X19cWlw8`&$OJ-jo)e_Vf>JbJYa{o=xNN{-KfBt?j9Ke|5O1zvf>* z@m7~7duSQhQ^Jj*d(rE}&^ytpRj`BoEURS?tp}`i$;W0htbl$NKd>BB;%B=+3qBE9d|+j-9v+TIQ{iD<>P{vGWBQmb4eC)Tq9>&dJ>8NXjqiHy$HPf%je&|*0E;^G zNOWQhsPo&N3sbUfhirGsnyPp{twmE4<9Zx-{Th~cY$mB-D4EL(WT})C*C+J2v~4<- z*2hxPSTs$PAfJczc!bC|_cm`!CnrYJ6G`%IwX~g#P7WrC>J#xHGUbDDP$stM>10$N zOoawxp^07D^CLiKNl40GpX&C>ZoA8?D5{)|#=y5>rHOru;TwwYDtf8ttA=+B-NFfB zJ^PmFS<|!G731Ipn|&(;he5VG4F-#>gN%(NyM@0jHv%6729h0*kXXGR+ezLUVP$R{47 zIpTeE7QK!BhW>1f1AFE(Z&TEX{gVxgg{#f$gpMT^T+rD?uy(bM>g9IJ+I8)zV-h5yNM;u*ncD2i4NO&eObNcGotTz9=B zH#F>*W5%+u!tNiyvAv*tO+h`1MuILmG^mGl?c=rK2K>_~EX7am1(s!>HmX|c>bx%l z%JJ=P)|Ty#z#nl2(_fetVvu!kfB7iZ_X0mFU!AE9u+I_id4|nC9D~7aBFDBf<_^;V zBjSqLcGBWul{>COJX5h4`T2Y5!bv@p)_X{4)jl!694^(Z;TxWZ%UBJc$-?TU&lB1= zPssAFFbVIY&%;049KCZH>}n|ELoY!0N5z_oUwZ+r$pv!e{~E~TO-7`f^yHqLS}yW# zbMY-j$E3pOuHH-CCLK!S>BpfTsMWaYMOcS#nSmT$^-H({M_+{1tP8*V6s*o?EZm)i zU0MF;@KWUpOYtm&8pW@RC6*HNgXT3tf<*of{sN!n_Y}{PboqL5W>1reLHo=2H7xJf zK94DBmL%#*_1nVa6yuXzO;SMLt;c-QT*_?Naa(mL644{oKIyi-dv_W?@inSW)#bfz z4WD6<%G$`<%I0`DN|In&n)7_AUTFY3b_#`~^GQY3RJZrKM*hDXtood?-R<^}Gn?K( zx6QE}UE7J#Iar0ia~f7P6zF?RQl%^E^>c$hGN$TdV@T?A+1)OOQ&smj*5QUfLQnSc z+YoX_6G=Hf@eO)BS-o&PK@wVumLMB4BO?OhW$~hTUi^o6O8lGn21$cqF=K!X>!a{e zcqrTyE((ppNug3WC=?2rVl9Q8qM3pmfEaoJV(0;gp$8y_t*5A?SWQt&v5KOGqS^p6 z47-wklqjkwR!}UbSVpmwqLPA~vKY3UqKsk*#bSz5iV})N6gCPg1)>lsEEHx6fx<+= zQ`8hw8Yzk>NIGX&0|lf25HdVT;%Cs|ajQ+_h>=-ziI8bSmvZtT)7nUn@{PK1#k>F*=N~Nww$EDjh2)kF#GHLJX_3I7#z3)`|+cfVbNmE zVfQMe4!Xfs;<-bYM^n&xuv;4&*B1eZ5G-~3;UKk$rVYndp)w^R6tKjLHFzN zT^~TR(7vc(j9=)n6?FRoKKcr=sKe{@ctN{$p|kHp=odOk*$mBZ`68^w$3BEL=7dOW zxiT+)^+Vz`yoE@%=OwL|;6Ah2Oe8zNV0HJWoBE^tir4_BlL0)oFKYZihz>6!FZ?xro45W}&;x?NdB=jtL%4(Wq&_Prq zz93GBb(Y^-GL~BN3G=wQM)<98x3JFid($K-gl{SfXxz=FDf^O-R+Pljb%0#6=vcLsdb$=c=FFlt}Ih#bT15 z+$lmal3?^C*~_-cXgWMBkzzYZFCOXqT=Tz0%okaw!{Y{9+&q(al=;c(J~|%npM_guwrHQB2P7NGDI(7)k{|5wY7WrvO2OngQ<=4J|@oEp__2ib4WR!LNyiMZ#A5LNs3x4Vy{^nm>J=2JMc$`w3v zaYLi2@#7N%siCpV_&|2zxT8)&yG6?F`ll1Hg=?msVN{O!n_O36%DjzJ*Sk60 zuaJXmqQV*%to{Q@llzFB6vzwY8S*lDoSY`#A=6~4P&Rp4rfZ83(=DHKH(5sKSpgN>>ta2a?G$6_T)qDN{n12Vk!eOLu^D$MmXk$?y(4`g@Xk zXB4J6YQ2q&JgX@ZRx?@n46^ER63D+b4T?_v9>SWjc}3Lsa>E2^A_OJqK2pqHM2di9Mb zU|kp0FC!UyJrV!UuR@#5Gdf4^vK4fbyDxEgjliz9pCNNqQAm;h3dnVGmwZG%AUDZ- z?#H%Z_@H|wX&7y$=M$GGhaj!Hd z5MIyh{{*Z0-=WW}6?$m}kXHFea=EG8c;47%n2=W3{__$#)#{+mw^3NV_dY`W{lz(N z17`sF4cQ@oEI%*bFI!BPOvgHuSzE*kNB>5R@|xor~WzpF}+UswQyL# zy61F#{6~DAuf`Yg9<+*{LVfTr@Vijve!}&FPrw;=igqd6rU$3$y7hY2N-;bnCRzn> zT!Z#{+#?=v3SdHmXSQP}U987;X}pD{*tOv6^>|nscl;Z7HQ-@Os+nQ2xW7M>d(igq zU446QfmYGDn+m6OH`?mD7YBJH?(x0UXgbmRhi%o*ah) zy=2EaF=-b-M%#G9j(aVshX46TZYfj$-{os)-3P1$kD>%%%2pDfA2;G7Qo4@evQkOM zq+Bh-E)Bcd@Fser2_F>uYXp$fV$o*2Pdu_!04FqPaN$~dp&9$dqgw=UQj4uMvyA;# z0gP!d;lO(;`m4Vb*qgetIG!3AOWPbY3pD0re)Uw8cu6N_yz;N`t~;?w?6H(6YZ|5OE0o1+NT4TMi&Ewi z@5xps)kg%7(vHM#qJgfrvc4FTnOpT*khwK12_}a7HN4n{^O9s__^5{4+VPw;X<*o) z;Uya$5ff6W^_a0+=$q}>B_0q<{m0zx6Z`cgGnlb9`o0Y}mit0(Z`kJ#h2egoNEY=X zU3TF;Vq7PHV+?Y%%#C-7aef1gT7vFC(B}z;VH|IWhbn_npWht`MM6G5jH8WPRl%q? z;EwnMp|Aocp;mlRofUt$T8Tux;bIq_mk$DRmTZ%6$&2zq z_89Gu#a=}KUwEQy$CdQc?d;I;j$&?cBw67e`t43^5fko`Ir&aDmxo*enARw4E<3dc V-RuvC1AdrkXN7$+Rvj^%`WKw{8JYk9 diff --git a/risks/admin.py b/risks/admin.py index d4c472b..2e5e86d 100644 --- a/risks/admin.py +++ b/risks/admin.py @@ -1,125 +1,150 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.http import HttpResponseRedirect +from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from .models import Control, Incident, Notification, NotificationPreference, NotificationRule, Risk, ResidualRisk, User +from .models import ( + Control, + Incident, + Notification, + NotificationPreference, + NotificationRule, + Risk, + ResidualRisk, + User, +) + +# --------------------------------------------------------------------------- +# Global Admin Settings +# --------------------------------------------------------------------------- admin.site.site_header = _("Administration") admin.site.site_title = _("Admin") admin.site.index_title = _("Administration") + +# ---- Inlines ---- class NotificationPreferenceInline(admin.StackedInline): + """Preferences inline for notifications on User model""" 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")}), + (_("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")}), ) + class ResidualRiskInline(admin.StackedInline): - """ - Inline editor for ResidualRisk, linked one-to-one with Risk - """ + """Inline editor for ResidualRisk (one-to-one with Risk)""" model = ResidualRisk extra = 0 can_delete = False readonly_fields = ("score", "level", "review_required") fields = ("likelihood", "impact", "score", "level", "review_required") + class ControlRisksInline(admin.TabularInline): + """M2M relation between Risk and Control""" model = Control.risks.through fk_name = "risk" extra = 1 autocomplete_fields = ("control",) + +class NotificationInline(admin.TabularInline): + """Inline display of notifications on User model""" + model = Notification + fields = ("created_at", "message", "read", "sent") + readonly_fields = ("created_at", "message") + extra = 0 + ordering = ("-created_at",) + + +# ---- Shared Mixins ---- +class ChangedByMixin: + """Automatically track user who created/changed/deleted""" + def save_model(self, request, obj, form, change): + obj._changed_by = request.user + super().save_model(request, obj, form, change) + + def delete_model(self, request, obj): + obj._changed_by = request.user + super().delete_model(request, obj) + + +class RedirectOnSaveMixin: + """Redirect to detail view instead of staying in admin""" + redirect_url_name = None + + def response_add(self, request, obj, post_url_continue=None): + return HttpResponseRedirect(reverse(self.redirect_url_name, args=[obj.pk])) + + def response_change(self, request, obj): + return HttpResponseRedirect(reverse(self.redirect_url_name, args=[obj.pk])) + + +# --------------------------------------------------------------------------- +# Risk +# --------------------------------------------------------------------------- @admin.register(Risk) -class RiskAdmin(admin.ModelAdmin): - list_display = ( - "title", - "owner_name", - "status", - "score", - "level", - "likelihood", - "impact", - "follow_up", - ) +class RiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin): + redirect_url_name = "risks:show_risk" + list_display = ("title", "owner_name", "status", "score", "level", "likelihood", "impact", "follow_up") + list_filter = ("status", "level", "likelihood", "impact", "owner") + search_fields = ("title", "asset", "process", "category") + inlines = [ResidualRiskInline, ControlRisksInline] def owner_name(self, obj): if not obj.owner: return "-" return obj.owner.get_full_name() or obj.owner.username - list_filter = ("status", "level", "likelihood", "impact", "owner") - search_fields = ("title", "asset", "process", "category") - inlines = [ResidualRiskInline, ControlRisksInline] - - def save_model(self, request, obj, form, change): - obj._changed_by = request.user - super().save_model(request, obj, form, change) - - def delete_model(self, request, obj): - obj._changed_by = request.user - super().delete_model(request, obj) +# --------------------------------------------------------------------------- +# Residual Risk +# --------------------------------------------------------------------------- @admin.register(ResidualRisk) -class ResidualRiskAdmin(admin.ModelAdmin): - list_display = ( - "risk", - "score", - "level", - "likelihood", - "impact", - "review_required" - ) +class ResidualRiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin): + redirect_url_name = "risks:show_risk" + list_display = ("risk", "score", "level", "likelihood", "impact", "review_required") list_filter = ("level", "likelihood", "impact", "review_required") - def save_model(self, request, obj, form, change): - obj._changed_by = request.user - super().save_model(request, obj, form, change) - - def delete_model(self, request, obj): - obj._changed_by = request.user - super().delete_model(request, obj) +# --------------------------------------------------------------------------- +# Control +# --------------------------------------------------------------------------- @admin.register(Control) -class ControlAdmin(admin.ModelAdmin): +class ControlAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin): + redirect_url_name = "risks:show_control" list_display = ("title", "status", "due_date", "responsible") list_filter = ("status", "due_date") - autocomplete_fields = ("risks", "responsible",) + autocomplete_fields = ("risks", "responsible") search_fields = ("title", "description") - def save_model(self, request, obj, form, change): - obj._changed_by = request.user - super().save_model(request, obj, form, change) - - def delete_model(self, request, obj): - obj._changed_by = request.user - super().delete_model(request, obj) +# --------------------------------------------------------------------------- +# Incident +# --------------------------------------------------------------------------- @admin.register(Incident) -class IncidentAdmin(admin.ModelAdmin): +class IncidentAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin): + redirect_url_name = "risks:show_incident" list_display = ("title", "date_reported", "reported_by", "status") list_filter = ("status", "date_reported", "reported_by") - filter_horizontal = ("related_risks",) search_fields = ("title", "description") autocomplete_fields = ("related_risks",) + filter_horizontal = ("related_risks",) - def save_model(self, request, obj, form, change): - obj._changed_by = request.user - super().save_model(request, obj, form, change) - - def delete_model(self, request, obj): - obj._changed_by = request.user - super().delete_model(request, obj) +# --------------------------------------------------------------------------- +# Notification +# --------------------------------------------------------------------------- @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") @@ -129,21 +154,18 @@ class NotificationAdmin(admin.ModelAdmin): list_editable = ("read", "sent") ordering = ("-created_at",) autocomplete_fields = ("user",) + actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"] @admin.display(description=_("User")) def user_display(self, obj): - if not obj.user: - return "—" - return obj.user.get_full_name() or obj.user.username + return obj.user.get_full_name() if obj.user else "—" @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"] - + # Bulk actions @admin.action(description=_("Mark selected as read")) def mark_as_read(self, request, queryset): n = queryset.update(read=True) @@ -164,13 +186,8 @@ class NotificationAdmin(admin.ModelAdmin): 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",) +# ---- Notification Rule ---- @admin.register(NotificationRule) class NotificationRuleAdmin(admin.ModelAdmin): list_display = ("kind", "enabled_in_app", "enabled_email", "to_owner", "to_staff", "short_extras") @@ -184,14 +201,19 @@ class NotificationRuleAdmin(admin.ModelAdmin): txt = (obj.extra_recipients or "").replace("\n", ", ") return (txt[:50] + "…") if len(txt) > 50 else txt + +# --------------------------------------------------------------------------- +# User (extension) +# --------------------------------------------------------------------------- @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") - + 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): @@ -200,4 +222,4 @@ class UserAdmin(BaseUserAdmin): def responsible_controls_count(self, obj): return obj.controls_responsible.count() - responsible_controls_count.short_description = _("Controls Responsible") \ No newline at end of file + responsible_controls_count.short_description = _("Controls Responsible") diff --git a/risks/apps.py b/risks/apps.py index 095f206..d126fc4 100644 --- a/risks/apps.py +++ b/risks/apps.py @@ -1,21 +1,35 @@ from django.apps import AppConfig from django.utils.translation import gettext_lazy as _ + +# --------------------------------------------------------------------------- +# Risks AppConfig +# --------------------------------------------------------------------------- class RisksConfig(AppConfig): + """App configuration for the risks module.""" default_auto_field = "django.db.models.BigAutoField" name = "risks" verbose_name = _("Risk Management") def ready(self): - import risks.signals + """ + Initialize signals and ensure NotificationRules exist for all + NotificationKind choices. Ignores database errors during migration. + """ + import risks.signals # noqa: F401 (ensure signal handlers are loaded) try: from django.db.utils import OperationalError, ProgrammingError from .models import NotificationRule, NotificationKind + + # Test DB availability NotificationRule.objects.count() except (OperationalError, ProgrammingError): + # Happens during migrate or before tables exist return + + # Ensure all NotificationKind values have a corresponding NotificationRule 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 + NotificationRule.objects.create(kind=kind) diff --git a/risks/audit_context.py b/risks/audit_context.py index 5e5b04e..7b762ca 100644 --- a/risks/audit_context.py +++ b/risks/audit_context.py @@ -1,8 +1,22 @@ import threading + +# --------------------------------------------------------------------------- +# Thread-local storage for current user +# --------------------------------------------------------------------------- _local = threading.local() + +# --------------------------------------------------------------------------- +# set_current_user() +# --------------------------------------------------------------------------- def set_current_user(user): + """Store the current user in thread-local storage.""" _local.user = user + +# --------------------------------------------------------------------------- +# get_current_user() +# --------------------------------------------------------------------------- def get_current_user(): + """Retrieve the current user from thread-local storage (or None).""" return getattr(_local, "user", None) \ No newline at end of file diff --git a/risks/context_processors.py b/risks/context_processors.py index aecaaf1..4a71db6 100644 --- a/risks/context_processors.py +++ b/risks/context_processors.py @@ -1,7 +1,14 @@ +# --------------------------------------------------------------------------- +# unread_notifications_count() +# --------------------------------------------------------------------------- def unread_notifications_count(request): + """ + Context processor: + Returns the number of unread notifications for the current user. + """ 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 + count = Notification.objects.filter(user=request.user, read=False).count() + return {"notifications_unread_count": count} \ No newline at end of file diff --git a/risks/email_utils.py b/risks/email_utils.py deleted file mode 100644 index 27963a8..0000000 --- a/risks/email_utils.py +++ /dev/null @@ -1,30 +0,0 @@ -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/forms.py b/risks/forms.py index 7ec45d0..2f6812c 100644 --- a/risks/forms.py +++ b/risks/forms.py @@ -2,30 +2,48 @@ from django import forms from django.utils.translation import gettext_lazy as _ from .models import Risk, Control, Incident, ResidualRisk -class RiskStatusForm(forms.ModelForm): + +# --------------------------------------------------------------------------- +# Base form for status field (DRY for Risk/Control/Incident) +# --------------------------------------------------------------------------- +class BaseStatusForm(forms.ModelForm): + """Abstract base form for models with a 'status' field.""" class Meta: + fields = ["status"] + labels = {"status": _("Status")} + widgets = {"status": forms.Select(attrs={"class": "select"})} + + +# --------------------------------------------------------------------------- +# RiskStatusForm +# --------------------------------------------------------------------------- +class RiskStatusForm(BaseStatusForm): + class Meta(BaseStatusForm.Meta): model = Risk - fields = ["status"] - labels = {"status": _("Status")} - widgets = {"status": forms.Select(attrs={"class": "select"})} -class ControlStatusForm(forms.ModelForm): - class Meta: + +# --------------------------------------------------------------------------- +# ControlStatusForm +# --------------------------------------------------------------------------- +class ControlStatusForm(BaseStatusForm): + class Meta(BaseStatusForm.Meta): model = Control - fields = ["status"] - labels = {"status": _("Status")} - widgets = {"status": forms.Select(attrs={"class": "select"})} -class IncidentStatusForm(forms.ModelForm): - class Meta: + +# --------------------------------------------------------------------------- +# IncidentStatusForm +# --------------------------------------------------------------------------- +class IncidentStatusForm(BaseStatusForm): + class Meta(BaseStatusForm.Meta): model = Incident - fields = ["status"] - labels = {"status": _("Status")} - widgets = {"status": forms.Select(attrs={"class": "select"})} + +# --------------------------------------------------------------------------- +# ResidualReviewForm +# --------------------------------------------------------------------------- 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 + widgets = {"review_required": forms.CheckboxInput(attrs={"class": "checkbox"})} diff --git a/risks/middleware.py b/risks/middleware.py index cad4512..339b663 100644 --- a/risks/middleware.py +++ b/risks/middleware.py @@ -1,9 +1,18 @@ from .audit_context import set_current_user + +# --------------------------------------------------------------------------- +# AuditUserMiddleware +# --------------------------------------------------------------------------- class AuditUserMiddleware: + """ + Middleware to store the current request.user in thread-local storage. + Used for auditing (_changed_by, etc.). + """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): + # Save current user for this request in thread-local storage set_current_user(getattr(request, "user", None)) return self.get_response(request) diff --git a/risks/migrations/0027_notification_content_type_notification_kind_and_more.py b/risks/migrations/0027_notification_content_type_notification_kind_and_more.py new file mode 100644 index 0000000..cab9a2a --- /dev/null +++ b/risks/migrations/0027_notification_content_type_notification_kind_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.6 on 2025-09-12 10:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("risks", "0026_alter_control_risks"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="content_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + migrations.AddField( + model_name="notification", + name="kind", + field=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"), + ], + default="", + max_length=40, + ), + ), + migrations.AddField( + model_name="notification", + name="object_id", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/risks/models.py b/risks/models.py index e5b17b1..169ae4f 100644 --- a/risks/models.py +++ b/risks/models.py @@ -1,34 +1,48 @@ -from django.conf import settings -from django.contrib.auth.models import AbstractUser -from django.core.serializers.json import DjangoJSONEncoder -from django.db import models -from django.utils.translation import gettext_lazy as _ -from multiselectfield import MultiSelectField import datetime import json +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from multiselectfield import MultiSelectField + +# --------------------------------------------------------------------------- +# SafeJSONEncoder +# --------------------------------------------------------------------------- class SafeJSONEncoder(DjangoJSONEncoder): + """JSON encoder that can handle datetime.date properly.""" def default(self, obj): if isinstance(obj, datetime.date): return obj.isoformat() return super().default(obj) + +# --------------------------------------------------------------------------- +# User +# --------------------------------------------------------------------------- class User(AbstractUser): - """ - Custom user model to support both local and SSO users. - """ + """Custom user model to support both local and SSO users.""" is_sso_user = models.BooleanField(default=False) @property def risks_owned(self): - """ All risks where the user is the risk owner. """ + """All risks where the user is the risk owner.""" return self.owned_risks.all() - + @property def controls_responsible(self): - """ All controls where the user is responsible. """ + """All controls where the user is responsible.""" return self.responsible_controls.all() + +# --------------------------------------------------------------------------- +# Risk +# --------------------------------------------------------------------------- class Risk(models.Model): class Meta: @@ -81,14 +95,8 @@ class Risk(models.Model): cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True) # Risk evaluation before controls - likelihood = models.IntegerField( - choices=LIKELIHOOD_CHOICES, - default=1 - ) - impact = models.IntegerField( - choices=IMPACT_CHOICES, - default=1 - ) + likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES, default=1) + impact = models.IntegerField(choices=IMPACT_CHOICES, default=1) # Calculated fields score = models.IntegerField(editable=False) @@ -106,10 +114,8 @@ class Risk(models.Model): follow_up = models.DateField(blank=True, null=True) def save(self, *args, **kwargs): - # Calculate risk score + # Calculate risk score and level self.score = self.likelihood * self.impact - - # Determine level based on score if self.score <= 4: self.level = "Low" elif self.score <= 8: @@ -118,55 +124,40 @@ class Risk(models.Model): self.level = "High" else: self.level = "Critical" - super().save(*args, **kwargs) def __str__(self): return f"{self.title} (Score: {self.score}, Level: {self.level})" + +# --------------------------------------------------------------------------- +# Residual Risk +# --------------------------------------------------------------------------- class ResidualRisk(models.Model): - """ - Residual Risk after implementing controls - """ + """Residual risk after implementing controls.""" class Meta: verbose_name = _("Residual Risk") verbose_name_plural = _("Residual Risks") - risk = models.OneToOneField( - Risk, - on_delete=models.CASCADE, - related_name="residual_risk") - - likelihood = models.IntegerField( - choices=Risk.LIKELIHOOD_CHOICES, - default=1 - ) - - impact = models.IntegerField( - choices=Risk.IMPACT_CHOICES, - default=1 - ) - + risk = models.OneToOneField(Risk, on_delete=models.CASCADE, related_name="residual_risk") + likelihood = models.IntegerField(choices=Risk.LIKELIHOOD_CHOICES, default=1) + impact = models.IntegerField(choices=Risk.IMPACT_CHOICES, default=1) score = models.IntegerField(editable=False) - level = models.CharField(max_length=50, editable=False) - review_required = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True,) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): - # Load previous state (if it exists) + # Mark for review if likelihood/impact changed if self.pk: old = ResidualRisk.objects.get(pk=self.pk) if old.likelihood != self.likelihood or old.impact != self.impact: self.review_required = True + # Calculate residual risk score and level self.score = self.likelihood * self.impact - - # Determine level based on score if self.score <= 4: self.level = "Low" elif self.score <= 8: @@ -175,16 +166,19 @@ class ResidualRisk(models.Model): self.level = "High" else: self.level = "Critical" - + super().save(*args, **kwargs) def __str__(self): return f"Residual Risk for {self.risk.title} (Score: {self.score}, Level: {self.level})" + +# --------------------------------------------------------------------------- +# Control +# --------------------------------------------------------------------------- class Control(models.Model): - """ - A security control/measure linked to a risk. - """ + """Security control/measure linked to a risk.""" + class Meta: verbose_name = _("Control") verbose_name_plural = _("Controls") @@ -208,19 +202,21 @@ class Control(models.Model): ) description = models.TextField(blank=True, null=True) wiki_link = models.URLField(blank=True, null=True) - created_at = models.DateTimeField(auto_now_add=True,) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # Relation to risk - risks = models.ManyToManyField("Risk", related_name="controls", blank=True) + risks = models.ManyToManyField(Risk, related_name="controls", blank=True) def __str__(self): return f"{self.title} ({self.get_status_display()})" + +# --------------------------------------------------------------------------- +# AuditLog +# --------------------------------------------------------------------------- class AuditLog(models.Model): - """ - Generic audit log entry for tracking changes. - """ + """Generic audit log entry for tracking changes.""" class Meta: verbose_name = _("Auditlog") @@ -238,7 +234,6 @@ class AuditLog(models.Model): on_delete=models.SET_NULL, related_name="audit_logs" ) - action = models.CharField(max_length=10, choices=ACTION_CHOICES) model = models.CharField(max_length=100) object_id = models.CharField(max_length=50) @@ -247,11 +242,13 @@ class AuditLog(models.Model): def __str__(self): return f"[{self.timestamp}] {self.user} {self.action} {self.model}({self.object_id})" - + + +# --------------------------------------------------------------------------- +# Incident +# --------------------------------------------------------------------------- class Incident(models.Model): - """ - Incidents and related risks - """ + """Incidents and related risks.""" class Meta: verbose_name = _("Incident") @@ -262,36 +259,28 @@ class Incident(models.Model): ("in_progress", _("In Progress")), ("closed", _("Closed")), ] + title = models.CharField(_("Title"), max_length=255) description = models.TextField(_("Description"), blank=True, null=True) date_reported = models.DateField(_("Date reported"), blank=True, null=True) reported_by = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_("Reported by"), - null=True, blank=True, on_delete=models.SET_NULL, related_name="incidents" + settings.AUTH_USER_MODEL, + verbose_name=_("Reported by"), + null=True, blank=True, + on_delete=models.SET_NULL, + related_name="incidents" ) status = models.CharField(max_length=12, choices=STATUS_CHOICES) - related_risks = models.ManyToManyField("Risk", blank=True, related_name="incidents") - created_at = models.DateTimeField(auto_now_add=True,) + related_risks = models.ManyToManyField(Risk, blank=True, related_name="incidents") + created_at = models.DateTimeField(auto_now_add=True) 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() - #related_objects = - created_at = models.DateTimeField(auto_now_add=True) - read = models.BooleanField(default=False) # Read in WebApp - sent = models.BooleanField(default=False) # Sent via Mail (optional) - - def __str__(self): - user_display = self.user.username if self.user else "System" - return f"{user_display}: {self.message[:50]}..." +# --------------------------------------------------------------------------- +# NotificationKind +# --------------------------------------------------------------------------- class NotificationKind(models.TextChoices): + """Event types for notifications.""" RISK_CREATED = "risk.created", _("Risk created") RISK_UPDATED = "risk.updated", _("Risk updated") RISK_DELETED = "risk.deleted", _("Risk deleted") @@ -315,10 +304,57 @@ class NotificationKind(models.TextChoices): USER_CREATED = "user.created", _("User created") USER_DELETED = "user.deleted", _("User deleted") + +# --------------------------------------------------------------------------- +# Notification +# --------------------------------------------------------------------------- +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() + kind = models.CharField(max_length=40, choices=NotificationKind.choices, default="") + created_at = models.DateTimeField(auto_now_add=True) + read = models.BooleanField(default=False) # Read in WebApp + sent = models.BooleanField(default=False) # Sent via Mail (optional) + + # Optional relation to any object + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + object_id = models.PositiveIntegerField(null=True, blank=True) + related_object = GenericForeignKey("content_type", "object_id") + + def __str__(self): + user_display = self.user.username if self.user else "System" + return f"{user_display}: {self.message[:50]}..." + + def get_link(self): + """Return URL to the related object if available.""" + if not self.related_object: + return None + model_name = self.content_type.model + if model_name == "risk": + return reverse("risks:show_risk", args=[self.object_id]) + if model_name == "control": + return reverse("risks:show_control", args=[self.object_id]) + if model_name == "incident": + return reverse("risks:show_incident", args=[self.object_id]) + return None + + +# --------------------------------------------------------------------------- +# NotificationPreference +# --------------------------------------------------------------------------- class NotificationPreference(models.Model): - """ - Wich events does the user want to receive as notifications? - """ + """User-specific notification preferences.""" + user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -327,9 +363,9 @@ class NotificationPreference(models.Model): ) # Risks - risk_created = models.BooleanField(default=True) - risk_updated = models.BooleanField(default=True) - risk_deleted = models.BooleanField(default=True) + 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) @@ -337,17 +373,17 @@ class NotificationPreference(models.Model): 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) + 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) + 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) + user_created = models.BooleanField(default=False) + user_deleted = models.BooleanField(default=False) # Incidents incident_created = models.BooleanField(default=True) @@ -361,12 +397,16 @@ class NotificationPreference(models.Model): return f"Prefs({self.user})" def should_notify(self, event_code: str) -> bool: + """Return True if user wants notifications for this event code.""" return bool(getattr(self, event_code, False)) + +# --------------------------------------------------------------------------- +# NotificationRule +# --------------------------------------------------------------------------- class NotificationRule(models.Model): - """ - Global Rules: Wich Event sends In-App- and/or Mail-Notifications? - """ + """Global rules: Which events trigger in-app and/or email notifications.""" + class Meta: verbose_name = _("Notification rule") verbose_name_plural = _("Notification rules") @@ -380,18 +420,15 @@ class NotificationRule(models.Model): enabled_in_app = models.BooleanField(_("Show in app"), default=True) enabled_email = models.BooleanField(_("Send via email"), default=False) - # Empfängerkreise + # Recipient groups to_owner = models.BooleanField( _("Send to owner/responsible/reporter (if available)"), - default=True - ) - to_staff = models.BooleanField( - _("Send to all staff"), - default=False + 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 + blank=True, ) def __str__(self): diff --git a/risks/serializers.py b/risks/serializers.py index 91a1642..a06c21b 100644 --- a/risks/serializers.py +++ b/risks/serializers.py @@ -2,6 +2,10 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import Risk, Control, ResidualRisk, AuditLog, Incident + +# --------------------------------------------------------------------------- +# ResidualRiskSerializer +# --------------------------------------------------------------------------- class ResidualRiskSerializer(serializers.ModelSerializer): class Meta: model = ResidualRisk @@ -17,6 +21,9 @@ class ResidualRiskSerializer(serializers.ModelSerializer): read_only_fields = ["score", "level"] +# --------------------------------------------------------------------------- +# ControlSerializer +# --------------------------------------------------------------------------- class ControlSerializer(serializers.ModelSerializer): risks = serializers.PrimaryKeyRelatedField(many=True, queryset=Risk.objects.all()) @@ -35,8 +42,12 @@ class ControlSerializer(serializers.ModelSerializer): "risks", ] + +# --------------------------------------------------------------------------- +# RiskSerializer +# --------------------------------------------------------------------------- class RiskSerializer(serializers.ModelSerializer): - # Nested representation of related controls + # Nested representation of related controls (read-only) controls = ControlSerializer(many=True, read_only=True) class Meta: @@ -60,6 +71,10 @@ class RiskSerializer(serializers.ModelSerializer): "controls", ] + +# --------------------------------------------------------------------------- +# AuditSerializer +# --------------------------------------------------------------------------- class AuditSerializer(serializers.ModelSerializer): class Meta: model = AuditLog @@ -73,6 +88,10 @@ class AuditSerializer(serializers.ModelSerializer): "timestamp", ] + +# --------------------------------------------------------------------------- +# UserSerializer +# --------------------------------------------------------------------------- User = get_user_model() class UserSerializer(serializers.ModelSerializer): @@ -90,11 +109,19 @@ class UserSerializer(serializers.ModelSerializer): "controls_responsible", ] + +# --------------------------------------------------------------------------- +# RiskSummarySerializer +# --------------------------------------------------------------------------- class RiskSummarySerializer(serializers.ModelSerializer): class Meta: model = Risk - fields = ["id", "title", "score", "level"] + fields = ["id", "title", "score", "level"] + +# --------------------------------------------------------------------------- +# IncidentSerializer +# --------------------------------------------------------------------------- class IncidentSerializer(serializers.ModelSerializer): related_risks = serializers.PrimaryKeyRelatedField( many=True, queryset=Risk.objects.all() @@ -106,11 +133,18 @@ class IncidentSerializer(serializers.ModelSerializer): class Meta: model = Incident fields = [ - "id", "title", "description", "date_reported", - "created_at", "updated_at", "status", "related_risks", + "id", + "title", + "description", + "date_reported", + "created_at", + "updated_at", + "status", + "related_risks", ] def create(self, validated_data): + """Ensure related_risks are set after creation.""" risks = validated_data.pop("related_risks", []) obj = super().create(validated_data) if risks: @@ -118,6 +152,7 @@ class IncidentSerializer(serializers.ModelSerializer): return obj def update(self, instance, validated_data): + """Ensure related_risks are updated properly.""" risks = validated_data.pop("related_risks", None) obj = super().update(instance, validated_data) if risks is not None: diff --git a/risks/signals.py b/risks/signals.py index a2065d8..1f0aba4 100644 --- a/risks/signals.py +++ b/risks/signals.py @@ -4,24 +4,33 @@ 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, Notification, NotificationKind, NotificationPreference +from .models import ( + Control, Risk, ResidualRisk, AuditLog, Incident, + Notification, NotificationKind, NotificationPreference +) from .utils import model_diff, notify_event + # --------------------------------------------------------------------------- -# General definitions +# General definitions & helpers # --------------------------------------------------------------------------- User = get_user_model() + def serialize_value(value): + """Serialize values for audit log (pk/isoformat).""" if isinstance(value, Model): - return value.pk # oder str(value), wenn du mehr Infos willst + return value.pk if isinstance(value, (datetime, date)): return value.isoformat() return value + def _pref(user: User) -> NotificationPreference | None: + """Ensure NotificationPreference exists for user.""" if not user: return None pref = getattr(user, "notification_preference", None) @@ -29,394 +38,304 @@ def _pref(user: User) -> NotificationPreference | None: 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.""" + """Create notifications for all users that want this event.""" 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.""" + """Return risk owner + all control responsibles.""" owners = [risk.owner] if risk.owner else [] responsibles = list( User.objects.filter(responsible_controls__risks=risk).distinct() ) return set(owners + responsibles) + # --------------------------------------------------------------------------- -# Incidents +# User # --------------------------------------------------------------------------- + @receiver(post_save, sender=User) def user_saved(sender, instance: User, created, **kwargs): - # Prefs automatisch anlegen + """Auto-create prefs + notify staff.""" _pref(instance) - # An Staff, die dieses Event wollen if created: - staff = User.objects.filter(is_staff=True, notification_preference__user_created=True) + 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) + 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): + """Audit + notify on create/update.""" + user = getattr(instance, "_changed_by", None) if created: + # Initial audit log AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="create", model="Risk", object_id=instance.pk, - changes={ - f.name: { - "old": None, - "new": serialize_value(getattr(instance, f.name)) - } for f in instance._meta.fields - }, + changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))} + for f in instance._meta.fields}, ) - else: - old = Risk.objects.get(pk=instance.pk) - changes = model_diff(old, instance) - if changes: - clean_changes = { - field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])} - for field, vals in changes.items() - } - AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), - action="update", - model="Risk", - object_id=instance.pk, - 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: + # Diff audit log + old = Risk.objects.get(pk=instance.pk) + changes = model_diff(old, instance) + if changes: + clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])} + for f, v in changes.items()} + AuditLog.objects.create( + user=user, + action="update", + model="Risk", + object_id=instance.pk, + changes=clean, + ) 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): - """ - Signal that runs after a Risk is deleted. - """ +def risk_deleted(sender, instance: Risk, **kwargs): user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=user, - action="delete", - model="Risk", - object_id=instance.pk, - changes=None, # no fields to track on deletion + user=user, action="delete", model="Risk", object_id=instance.pk, changes=None ) - notify_event( NotificationKind.RISK_DELETED, message=_("Risk deleted: {t}").format(t=instance.title), users=[instance.owner] if instance.owner_id else None, ) + # --------------------------------------------------------------------------- # Controls # --------------------------------------------------------------------------- + @receiver(post_save, sender=Control) def control_saved(sender, instance: Control, created, **kwargs): - # Review-Flag für alle betroffenen Residuals setzen + """Update residuals + audit + notify.""" + # Force review on related residuals for risk in instance.risks.all(): - resid, created = ResidualRisk.objects.get_or_create(risk=risk) - # Statuswechsel auf Review Required + resid, _ = 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") - # 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): + # Audit log + user = getattr(instance, "_changed_by", None) if created: AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="create", model="Control", object_id=instance.pk, - changes={ - f.name: { - "old": None, - "new": serialize_value(getattr(instance, f.name)) - } for f in instance._meta.fields - }, + changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))} + for f in instance._meta.fields}, ) + kind = NotificationKind.CONTROL_CREATED else: old = Control.objects.get(pk=instance.pk) changes = model_diff(old, instance) if changes: - clean_changes = { - field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])} - for field, vals in changes.items() - } + clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])} + for f, v in changes.items()} AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), - action="update", - model="Control", - object_id=instance.pk, - changes=clean_changes, + user=user, action="update", model="Control", object_id=instance.pk, changes=clean ) + kind = NotificationKind.CONTROL_UPDATED - kind = NotificationKind.CONTROL_CREATED if created else NotificationKind.CONTROL_UPDATED + # Notify notify_event( kind, - message=_("Control {event}: {t}").format( - event=_("created") if created else _("updated"), - t=instance.title, + message=_("Control {e}: {t}").format( + e=_("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): +def control_deleted(sender, instance: Control, **kwargs): user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=user, - action="delete", - model="Control", - object_id=instance.pk, - changes=None, + user=user, action="delete", model="Control", object_id=instance.pk, 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): +def control_risks_changed(sender, instance: Control, action, **kwargs): if action in {"post_add", "post_remove", "post_clear"}: - 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) + for risk in instance.risks.all(): + resid, _ = 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") + _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 + """Audit + notify on create/update.""" + user = getattr(instance, "_changed_by", None) - # 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): + # Audit log if created: AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="create", model="ResidualRisk", object_id=instance.pk, - changes={ - f.name: { - "old": None, - "new": serialize_value(getattr(instance, f.name)) - } for f in instance._meta.fields - }, + changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))} + for f in instance._meta.fields}, ) - else: - old = ResidualRisk.objects.get(pk=instance.pk) - changes = model_diff(old, instance) - if changes: - clean_changes = { - field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])} - for field, vals in changes.items() - } - AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), - action="update", - model="ResidualRisk", - object_id=instance.pk, - 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, + if changes: + clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])} + for f, v in changes.items()} + AuditLog.objects.create( + user=user, action="update", model="ResidualRisk", + object_id=instance.pk, changes=clean ) + # Special handling: review_required + if "review_required" in changes: + if instance.review_required: + kind = NotificationKind.RESIDUAL_REVIEW_REQUIRED + msg = _("Residual review required for risk: {t}") + else: + kind = NotificationKind.RESIDUAL_REVIEW_COMPLETED + msg = _("Residual review completed for risk: {t}") + else: + kind = NotificationKind.RESIDUAL_UPDATED + msg = _("Residual updated for risk: {t}") + + notify_event( + kind, + message=msg.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): +def residual_deleted(sender, instance: ResidualRisk, **kwargs): user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=user, - action="delete", - model="ResidualRisk", - object_id=instance.pk, - changes=None, + user=user, action="delete", model="ResidualRisk", object_id=instance.pk, 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 # --------------------------------------------------------------------------- + @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): + """Audit + notify on create/update.""" + user = getattr(instance, "_changed_by", None) if created: AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="create", model="Incident", object_id=instance.pk, - changes={ - f.name: { - "old": None, - "new": serialize_value(getattr(instance, f.name)) - } for f in instance._meta.fields - }, + changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))} + for f in instance._meta.fields}, ) + kind = NotificationKind.INCIDENT_CREATED else: old = Incident.objects.get(pk=instance.pk) changes = model_diff(old, instance) if changes: - clean_changes = { - field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])} - for field, vals in changes.items() - } + clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])} + for f, v in changes.items()} AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), - action="update", - model="Incident", - object_id=instance.pk, - changes=clean_changes, + user=user, action="update", model="Incident", object_id=instance.pk, changes=clean ) + kind = NotificationKind.INCIDENT_UPDATED - 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, + message=_("Incident {e}: {t}").format( + e=_("created") if created else _("updated"), t=instance.title ), users=[instance.reported_by] if instance.reported_by_id else None, ) + +@receiver(post_delete, sender=Incident) +def incident_deleted(sender, instance: Incident, **kwargs): + user = getattr(instance, "_changed_by", None) or get_current_user() + AuditLog.objects.create( + user=user, action="delete", 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, + ) + + @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"]: +def incident_risks_changed(sender, instance, action, pk_set, **kwargs): + if action in {"post_add", "post_remove", "post_clear"}: user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( user=user, @@ -425,20 +344,3 @@ def log_incident_risks_change(sender, instance, action, reverse, model, pk_set, object_id=instance.pk, changes={"related_risks": {"action": action, "ids": list(pk_set)}}, ) - -@receiver(post_delete, sender=Incident) -def log_incident_delete(sender, instance, **kwargs): - user = getattr(instance, "_changed_by", None) or get_current_user() - AuditLog.objects.create( - user=user, - action="delete", - 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/urls.py b/risks/urls.py index a07da14..6f74c3e 100644 --- a/risks/urls.py +++ b/risks/urls.py @@ -4,25 +4,43 @@ from . import views app_name = "risks" urlpatterns = [ - path("", views.dashboard, name="dashboard"), + # ----------------------------------------------------------------------- + # Dashboard + # ----------------------------------------------------------------------- + path("", views.dashboard, name="dashboard"), + path("risks/index", views.dashboard, name="index"), - 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"), - path("risks/list_controls", views.list_controls, name="list_controls"), - 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"), - path("risks/risk_matrix", views.risk_matrix, name="risk_matrix"), + # ----------------------------------------------------------------------- + # Risks + # ----------------------------------------------------------------------- + path("risks/list_risks", views.list_risks, name="list_risks"), + path("risks/risks/", views.show_risk, name="show_risk"), + path("risks/risk_matrix", views.risk_matrix, name="risk_matrix"), + path("risks//status", views.update_risk_status, name="update_risk_status"), - # 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"), + # ----------------------------------------------------------------------- + # Controls + # ----------------------------------------------------------------------- + path("risks/list_controls", views.list_controls, name="list_controls"), + path("risks/controls/", views.show_control, name="show_control"), + path("controls//status", views.update_control_status, name="update_control_status"), - # 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 + # ----------------------------------------------------------------------- + # Incidents + # ----------------------------------------------------------------------- + path("risks/list_incidents", views.list_incidents, name="list_incidents"), + path("risks/incidents/", views.show_incident, name="show_incident"), + path("incidents//status", views.update_incident_status, name="update_incident_status"), + + # ----------------------------------------------------------------------- + # Residual Risks + # ----------------------------------------------------------------------- + path("residuals//review", views.update_residual_review, name="update_residual_review"), + + # ----------------------------------------------------------------------- + # 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"), +] diff --git a/risks/utils.py b/risks/utils.py index d76c4e9..71e8940 100644 --- a/risks/utils.py +++ b/risks/utils.py @@ -1,13 +1,23 @@ +from datetime import date, datetime +from typing import Iterable, Optional + 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,NotificationRule, NotificationKind, Risk, ResidualRisk -from typing import Iterable, Optional + +from .models import ( + AuditLog, Notification, NotificationRule, + NotificationKind, Risk, ResidualRisk, +) User = get_user_model() + +# --------------------------------------------------------------------------- +# model_diff() +# --------------------------------------------------------------------------- def model_diff(old, new, fields=None): """ Compare two model instances and return a dict of changed fields. @@ -24,32 +34,35 @@ def model_diff(old, new, fields=None): for field_name in fields: old_value = getattr(old, field_name, None) new_value = getattr(new, field_name, None) - if old_value != new_value: changes[field_name] = {"old": old_value, "new": new_value} return changes + +# --------------------------------------------------------------------------- +# check_risk_followups() +# --------------------------------------------------------------------------- def check_risk_followups(): """ - Check if follow ups need attention and create notifications. - Ensures no duplicate notifications per risk per day + Check if follow-ups need attention and create notifications. + Ensures no duplicate notifications per risk per day. """ today = now().date() risks = Risk.objects.filter(follow_up__lte=today).select_related("owner") for risk in risks: - # Risk-Status auf review_required setzen (nicht überschreiben, wenn bereits closed) - if risk.status != "closed" and risk.status != "review_required": + # Status aktualisieren (außer wenn bereits closed/review_required) + if risk.status not in ("closed", "review_required"): Risk.objects.filter(pk=risk.pk).update(status="review_required") - # ResidualRisk-Objekt sicherstellen und Review-Flag setzen - resid, created = ResidualRisk.objects.get_or_create(risk=risk) + # ResidualRisk sicherstellen + Review-Flag setzen + resid, _ = ResidualRisk.objects.get_or_create(risk=risk) if not resid.review_required: resid.review_required = True resid.save() - # Notification an Stakeholder + # Notification (einmalig pro Risk/Tag) message = _("Follow-up reached: review required for risk '{t}'").format(t=risk.title) notification, created = Notification.objects.get_or_create( user=risk.owner, @@ -58,70 +71,77 @@ def check_risk_followups(): ) 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}, + user=None, + action="create", + model="Notification", + object_id=notification.pk, + changes={ + "message": notification.message, + "user": risk.owner.username if risk.owner else None, + }, ) notify_event( NotificationKind.RISK_REVIEW_REQUIRED, - message=_("Follow-up reached: review required for risk '{t}'").format(t=risk.title), + message=message, users=[risk.owner] if risk.owner_id else None, ) + +# --------------------------------------------------------------------------- +# _split_emails() +# --------------------------------------------------------------------------- def _split_emails(value: str) -> list[str]: + """Normalize a comma/newline-separated list of emails into a clean list.""" if not value: return [] raw = value.replace("\n", ",").split(",") return [e.strip() for e in raw if "@" in e and e.strip()] + +# --------------------------------------------------------------------------- +# notify_event() +# --------------------------------------------------------------------------- def notify_event(kind: str, *, message: str, users: Optional[Iterable[User]] = None): """ - Generates in-app notifications and/or emails depending on the rule. + Generates in-app notifications and/or emails depending on the NotificationRule. - 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 + # Defaults (no rule → in-app only) enabled_in_app = True enabled_email = False - to_staff = False + recipients_users = set() extra_emails = [] - recipients_users = set() - + # Base recipients if users: - for u in users: - if u and getattr(u, "is_active", False): - recipients_users.add(u) + recipients_users.update(u for u in users if u and getattr(u, "is_active", False)) + # Rule overrides if rule: enabled_in_app = rule.enabled_in_app enabled_email = rule.enabled_email if rule.to_staff: - to_staff = True + recipients_users.update(User.objects.filter(is_staff=True, is_active=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 + # In-App Notifications if enabled_in_app: for u in recipients_users: Notification.objects.create(user=u, message=message) - # E-Mail + # Email Notifications 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 + emails = list(dict.fromkeys(emails)) # de-dupe, preserve order if emails: - subject = _("Notification") - body = message send_mail( - subject, - body, + _("Notification"), + message, getattr(settings, "DEFAULT_FROM_EMAIL", "webmaster@localhost"), emails, - fail_silently=True, # im Zweifel nicht crashen + fail_silently=True, # don’t crash on mail error ) diff --git a/risks/views.py b/risks/views.py index c5fb73f..8996477 100644 --- a/risks/views.py +++ b/risks/views.py @@ -1,103 +1,92 @@ +from collections import Counter +from django.contrib import messages 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.db.models import Count from django.http import HttpResponseForbidden -from django.shortcuts import redirect, render, get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from collections import Counter, defaultdict + 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 +from .serializers import ( + ControlSerializer, RiskSerializer, ResidualRiskSerializer, + UserSerializer, AuditSerializer, IncidentSerializer, +) User = get_user_model() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- 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 +# API ViewSets # --------------------------------------------------------------------------- -class RiskViewSet(viewsets.ModelViewSet): - """ - API endpoint for managing Risks. - Provides CRUD operations. - """ +class _ChangedByMixin: + """Mixin to track user who changed an object.""" + def perform_create(self, serializer): + instance = serializer.save() + instance._changed_by = self.request.user + + def perform_update(self, serializer): + instance = serializer.save() + instance._changed_by = self.request.user + + +class RiskViewSet(_ChangedByMixin, viewsets.ModelViewSet): + """API endpoint for managing Risks.""" queryset = Risk.objects.all() serializer_class = RiskSerializer permission_classes = [IsAuthenticated] - def perform_create(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user - def perform_update(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user - -class ControlViewSet(viewsets.ModelViewSet): - """ - API endpoint for managing Controls. - Provides CRUD operations. - """ +class ControlViewSet(_ChangedByMixin, viewsets.ModelViewSet): + """API endpoint for managing Controls.""" queryset = Control.objects.all() serializer_class = ControlSerializer permission_classes = [IsAuthenticated] - def perform_create(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user - - def perform_update(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user class ResidualRiskViewSet(viewsets.ModelViewSet): - """ - API endpoint for Residual risks. - """ + """API endpoint for Residual Risks.""" queryset = ResidualRisk.objects.all() serializer_class = ResidualRiskSerializer permission_classes = [IsAuthenticated] -class UserViewSet(viewsets.ReadOnlyModelViewSet): - """ - API endpoint for listing users and their responsibilities. - """ + +class UserViewSet(_ChangedByMixin, viewsets.ReadOnlyModelViewSet): + """API endpoint for listing users and their responsibilities.""" queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [IsAuthenticated] - def perform_create(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user - - def perform_update(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user class AuditViewSet(viewsets.ReadOnlyModelViewSet): - """ - API endpoint for view audit logging. - """ + """API endpoint for viewing audit logs.""" queryset = AuditLog.objects.all() serializer_class = AuditSerializer permission_classes = [IsAuthenticated] -class IncidentViewSet(viewsets.ModelViewSet): - """ - API endpoint for listing incidents and its related risks. - """ + +class IncidentViewSet(_ChangedByMixin, viewsets.ModelViewSet): + """API endpoint for listing incidents and their related risks.""" queryset = Incident.objects.all() serializer_class = IncidentSerializer permission_classes = [IsAuthenticated] @@ -106,242 +95,180 @@ class IncidentViewSet(viewsets.ModelViewSet): instance = serializer.save(reported_by=self.request.user) instance._changed_by = self.request.user - def perform_update(self, serializer): - instance = serializer.save() - instance._changed_by = self.request.user # --------------------------------------------------------------------------- -# Web => Risks, Controls, Incidents +# Web Views: Risks # --------------------------------------------------------------------------- - @login_required def list_risks(request): + """List all risks with filters and sorting.""" qs = Risk.objects.all().select_related("owner", "residual_risk") - # Filter - risk_id = request.GET.get("risk") - control_id = request.GET.get("control") - owner_id = request.GET.get("owner") - category = request.GET.get("category") - asset = request.GET.get("asset") - process = request.GET.get("process") - - if risk_id: - qs = qs.filter(id=risk_id) - if control_id: - qs = qs.filter(controls__id=control_id) - if owner_id: - qs = qs.filter(owner_id=owner_id) - if category: - qs = qs.filter(category=category) - if asset: - qs = qs.filter(asset=asset) - if process: - qs = qs.filter(process=process) + # Filters + filters = { + "id": request.GET.get("risk"), + "controls__id": request.GET.get("control"), + "owner_id": request.GET.get("owner"), + "category": request.GET.get("category"), + "asset": request.GET.get("asset"), + "process": request.GET.get("process"), + } + qs = qs.filter(**{k: v for k, v in filters.items() if v}) + # Sorting sort = request.GET.get("sort") or "title" direction = request.GET.get("dir") or "asc" - if direction == "desc": - qs = qs.order_by(f"-{sort}") - else: - qs = qs.order_by(sort) + qs = qs.order_by(f"-{sort}" if direction == "desc" else sort) risks = qs.distinct() - risk_choices = Risk.objects.all().order_by("title") - control_choices = Control.objects.all().order_by("title") - owner_choices = User.objects.filter(owned_risks__isnull=False).distinct().order_by("username") - category_choices = (Risk.objects.exclude(category__isnull=True) - .exclude(category__exact="") - .values_list("category", flat=True) - .distinct() - .order_by("category")) - asset_choices = (Risk.objects.exclude(asset__isnull=True) - .exclude(asset__exact="") - .values_list("asset", flat=True) - .distinct() - .order_by("asset")) - process_choices = (Risk.objects.exclude(process__isnull=True) - .exclude(process__exact="") - .values_list("process", flat=True) - .distinct() - .order_by("process")) - return render(request, "risks/list_risks.html", { "risks": risks, - "risk_choices": risk_choices, - "control_choices": control_choices, - "owner_choices": owner_choices, - "category_choices": category_choices, - "asset_choices": asset_choices, - "process_choices": process_choices, + "risk_choices": Risk.objects.all().order_by("title"), + "control_choices": Control.objects.all().order_by("title"), + "owner_choices": User.objects.filter(owned_risks__isnull=False).distinct().order_by("username"), + "category_choices": (Risk.objects.exclude(category__isnull=True).exclude(category__exact="") + .values_list("category", flat=True).distinct().order_by("category")), + "asset_choices": (Risk.objects.exclude(asset__isnull=True).exclude(asset__exact="") + .values_list("asset", flat=True).distinct().order_by("asset")), + "process_choices": (Risk.objects.exclude(process__isnull=True).exclude(process__exact="") + .values_list("process", flat=True).distinct().order_by("process")), "current_sort": sort, "current_dir": direction, }) + @login_required def show_risk(request, id): - """ - View for single risk - """ + """Show single risk details + logs.""" 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") - return render(request, "risks/item_risk.html", {"risk": risk, "logs": logs}) + +# --------------------------------------------------------------------------- +# Web Views: Controls +# --------------------------------------------------------------------------- @login_required def list_controls(request): - """ - View for listing all Controls - """ + """List all controls with filters.""" qs = Control.objects.all().select_related("responsible") - control_id = request.GET.get("control") - risk_id = request.GET.get("risk") - status = request.GET.get("status") - responsible_id = request.GET.get("responsible") - - if control_id: - qs = qs.filter(id=control_id) - if risk_id: - qs = qs.filter(risks__id=risk_id) # FIX - if status: - qs = qs.filter(status=status) - if responsible_id: - qs = qs.filter(responsible_id=responsible_id) + filters = { + "id": request.GET.get("control"), + "risks__id": request.GET.get("risk"), + "status": request.GET.get("status"), + "responsible_id": request.GET.get("responsible"), + } + qs = qs.filter(**{k: v for k, v in filters.items() if v}) controls = qs.order_by("title").distinct() - risks = Risk.objects.all().order_by("title") - users = User.objects.filter(responsible_controls__isnull=False).distinct().order_by("username") - return render(request, "risks/list_controls.html", { "controls": controls, - "risks": risks, - "users": users, + "control_choices": Control.objects.all().order_by("title"), + "risk_choices": Risk.objects.all().order_by("title"), + "responsible_choices": User.objects.filter(responsible_controls__isnull=False).distinct().order_by("username"), "status_choices": Control.STATUS_CHOICES, }) + @login_required def show_control(request, id): + """Show single control details + logs.""" control = get_object_or_404(Control, pk=id) ct = ContentType.objects.get_for_model(Control) - logs = LogEntry.objects.filter( - content_type=ct, - object_id=control.pk - ).order_by("-action_time") - + logs = LogEntry.objects.filter(content_type=ct, object_id=control.pk).order_by("-action_time") return render(request, "risks/item_control.html", {"control": control, "logs": logs}) + +# --------------------------------------------------------------------------- +# Web Views: Incidents +# --------------------------------------------------------------------------- @login_required def list_incidents(request): - """ - View for listing all Incidents - """ + """List all incidents with filters.""" qs = Incident.objects.all().select_related("reported_by").prefetch_related("related_risks") - risk_id = request.GET.get("risk") - status = request.GET.get("status") - reported_by = request.GET.get("reported_by") - - if risk_id: - qs = qs.filter(related_risks__id=risk_id) # FIX - if status: - qs = qs.filter(status=status) - if reported_by: - qs = qs.filter(reported_by=reported_by) + filters = { + "related_risks__id": request.GET.get("risk"), + "status": request.GET.get("status"), + "reported_by": request.GET.get("reported_by"), + } + qs = qs.filter(**{k: v for k, v in filters.items() if v}) incidents = qs.order_by("title").distinct() - risks = Risk.objects.all().order_by("title") - users = User.objects.filter(incidents__isnull=False).distinct().order_by("username") # sinnvoller - return render(request, "risks/list_incidents.html", { "incidents": incidents, - "risks": risks, - "users": users, + "incident_choices": incidents, + "risk_choices": Risk.objects.all().order_by("title"), + "user_choices": User.objects.filter(incidents__isnull=False).distinct().order_by("username"), "status_choices": Incident.STATUS_CHOICES, }) + @login_required def show_incident(request, id): + """Show single incident details + logs.""" incident = get_object_or_404(Incident, pk=id) ct = ContentType.objects.get_for_model(Incident) - logs = LogEntry.objects.filter( - content_type=ct, - object_id=incident.pk - ).order_by("-action_time") - + logs = LogEntry.objects.filter(content_type=ct, object_id=incident.pk).order_by("-action_time") return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs}) + + +# --------------------------------------------------------------------------- +# Dashboard +# --------------------------------------------------------------------------- @login_required def dashboard(request): - """ - Dashboardview with KPIs - """ + """Dashboard view with KPIs.""" # Risikoübersicht risks_total = Risk.objects.count() - risks_by_level = Risk.objects.values('level').annotate(count=Count('id')) + risks_by_level = Risk.objects.values("level").annotate(count=Count("id")) # CIA-Zähler für MultiSelectField - risks_cia = Risk.objects.values_list('cia', flat=True) + risks_cia = Risk.objects.values_list("cia", flat=True) cia_counter = Counter() for cia_list in risks_cia: - if isinstance(cia_list, list): # MultiSelectField gibt Liste zurück + if isinstance(cia_list, list): for c in cia_list: cia_counter[c] += 1 - elif cia_list: # Falls irgendwie noch ein String drin ist + elif cia_list: cia_counter[cia_list] += 1 - # Residualrisiken - residual_review_required = ResidualRisk.objects.filter(review_required=True).count() - - # Kontrollen - controls_by_status = Control.objects.values('status').annotate(count=Count('id')) - - # Incidents - incidents_status = Incident.objects.values('status').annotate(count=Count('id')) - - # Benachrichtigungen - notifications_unread = Notification.objects.filter(user=request.user, read=False).count() - - print(type(cia_counter), cia_counter) - - # Context für Template context = { - 'risks_total': risks_total, - 'risks_by_level': risks_by_level, - 'risks_by_cia': dict(cia_counter), # <-- hier Counter in dict umwandeln - 'residual_review_required': residual_review_required, - 'controls_by_status': controls_by_status, - 'incidents_status': incidents_status, - 'notifications_unread': notifications_unread, + "risks_total": risks_total, + "risks_by_level": risks_by_level, + "risks_by_cia": dict(cia_counter), + "residual_review_required": ResidualRisk.objects.filter(review_required=True).count(), + "controls_by_status": Control.objects.values("status").annotate(count=Count("id")), + "incidents_status": Incident.objects.values("status").annotate(count=Count("id")), + "notifications_unread": Notification.objects.filter(user=request.user, read=False).count(), } - return render(request, 'risks/dashboard.html', context) + return render(request, "risks/dashboard.html", context) + # --------------------------------------------------------------------------- # Notifications # --------------------------------------------------------------------------- - @login_required def notifications(request): - """Eigene Benachrichtigungen ansehen + filtern""" + """View own notifications with optional filter.""" 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, - }) + return render(request, "risks/notifications.html", {"notifications": qs, "filter": flt}) + @login_required def notification_mark_read(request, pk): + """Mark single notification as read.""" if request.method != "POST": return HttpResponseForbidden() notif = get_object_or_404(Notification, pk=pk, user=request.user) @@ -350,19 +277,23 @@ def notification_mark_read(request, pk): 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): + """Mark all notifications as read.""" 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): + """Update risk status.""" risk = get_object_or_404(Risk, pk=id) if not _can_edit_risk(request.user, risk): return HttpResponseForbidden() @@ -375,8 +306,10 @@ def update_risk_status(request, id): messages.success(request, _("Risk status updated.")) return redirect("risks:show_risk", id=risk.pk) + @login_required def update_control_status(request, id): + """Update control status.""" control = get_object_or_404(Control, pk=id) if not _can_edit_control(request.user, control): return HttpResponseForbidden() @@ -389,8 +322,10 @@ def update_control_status(request, id): messages.success(request, _("Control status updated.")) return redirect("risks:show_control", id=control.pk) + @login_required def update_incident_status(request, id): + """Update incident status.""" incident = get_object_or_404(Incident, pk=id) if not _can_edit_incident(request.user, incident): return HttpResponseForbidden() @@ -403,13 +338,14 @@ def update_incident_status(request, id): 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""" + """Toggle residual risk review flag.""" 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) + residual, _ = ResidualRisk.objects.get_or_create(risk=risk) if request.method == "POST": form = ResidualReviewForm(request.POST, instance=residual) if form.is_valid(): @@ -420,21 +356,20 @@ def update_residual_review(request, risk_id): return redirect("risks:show_risk", id=risk.pk) +# --------------------------------------------------------------------------- +# Risk Matrix +# --------------------------------------------------------------------------- def risk_matrix(request): - risks = (Risk.objects - .select_related("owner", "residual_risk") # wichtig fürs Netto - .all()) - + """Show gross/net risk matrix.""" + risks = Risk.objects.select_related("owner", "residual_risk").all() impacts = sorted(Risk.IMPACT_CHOICES, key=lambda x: x[0]) likelihoods = sorted(Risk.LIKELIHOOD_CHOICES, key=lambda x: x[0]) gross_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts} - net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts} + net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts} for r in risks: - # Brutto platzieren gross_matrix[r.impact][r.likelihood].append(r) - # Netto (falls vorhanden) platzieren rr = getattr(r, "residual_risk", None) if rr: net_matrix[rr.impact][rr.likelihood].append(r) diff --git a/static/css/design.css b/static/css/design.css index 4a7de21..b54a65c 100644 --- a/static/css/design.css +++ b/static/css/design.css @@ -173,6 +173,7 @@ abbr { text-decoration: none; } .breadcrumb:not(:last-child) { margin-bottom: 0; border-bottom: 1px solid var(--prosoft-normal); } .breadcrumb { background-color: #f0ebeb; } .breadcrumb a { color: var(--prosoft-normal) !important; } +.breadcrumb-add-icon {color: limegreen !important} /* ========================= Lists inside .content @@ -310,13 +311,13 @@ body.dark-mode a { color: #bb86fc; } Dark Mode Palette ========================= */ body.dark-mode { - --bg-main: #121212; - --bg-surface: #1e1e1e; + --bg-main: #3c3c3c; + --bg-surface: #3c3c3c; --bg-hover: #2a2a2a; --border-color: #333; --text-main: #f5f5f5; --text-muted: #bbb; - --link-color: #bb86fc; + --link-color: #fff; --link-hover: #d0aaff; background-color: var(--bg-main); @@ -342,7 +343,7 @@ body.dark-mode .content { Navbar / Topbar ========================= */ body.dark-mode .navbar.topbar-nav { - background-color: var(--bg-surface) !important; + box-shadow: none; } body.dark-mode .navbar.topbar-nav .navbar-item, @@ -351,7 +352,7 @@ body.dark-mode .navbar.topbar-nav .navbar-link { } body.dark-mode .navbar.topbar-nav .navbar-item:hover, body.dark-mode .navbar.topbar-nav .navbar-link:hover { - background-color: var(--bg-hover); + color: #fff; } body.dark-mode .navbar.topbar-nav .navbar-link::after { @@ -408,13 +409,12 @@ body.dark-mode td { /* ========================= Inputs / Forms ========================= */ -body.dark-mode input, body.dark-mode select, body.dark-mode textarea { background: var(--bg-surface); color: var(--text-main); - border: 1px solid var(--border-color); } + body.dark-mode input::placeholder { color: var(--text-muted); } @@ -424,7 +424,7 @@ body.dark-mode input::placeholder { ========================= */ body.dark-mode .navbar-dropdown { background: var(--bg-surface); - border: 1px solid var(--border-color); + } body.dark-mode .navbar-dropdown .navbar-item { @@ -435,4 +435,17 @@ body.dark-mode .navbar-dropdown .navbar-item { body.dark-mode .navbar-dropdown .navbar-item:hover { background: var(--bg-hover); color: #fff !important; +} + +body.dark-mode .section.has-background-light { + background-color: var(--border-color) !important; +} + +body.dark-mode .label.is-small { + color: var(--text-main) !important; +} + +body.dark-mode .button.is-light { + background-color: var(--bg-main) !important; + color: var(--text-main) !important; } \ No newline at end of file diff --git a/templates/risks/item_control.html b/templates/risks/item_control.html index 1c0a00e..f62b673 100644 --- a/templates/risks/item_control.html +++ b/templates/risks/item_control.html @@ -1,159 +1,133 @@ {% extends "base.html" %} {% load i18n risk_extras %} + {% block crumbs %} -
  • Maßnahmen
  • +
  • {% trans "Controls" %}
  • {{ control.title }}
  • {% endblock %} -{% block content %} -
    -
    -
    -

    Maßnahme: {{ control.title }}

    -

    {{ control.description }}

    -
    -
    - -
    -
    -

    Überblick

    - {% if request.user.is_staff or control.responsible.id == request.user.id %} -
    - {% csrf_token %} -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - - - - - - - {% endif %} -
    - +{% block content %} + + + + + +
    +
    -

    Verantwortliche/r: {{ control.responsible|default:"-" }}

    -

    Zum Wiki Eintrag

    +

    {% trans "Control" %}: {{ control.title }}

    +

    {% trans "Responsible" %}: {{ control.responsible|default:"–" }}

    +

    {% trans "Status" %}: {{ control.get_status_display }}

    +

    + {% trans "Link" %}: + {% if control.wiki_link %} + 🔗 + {% else %} + – + {% endif %} +

    - -

    Erstellt am: {{ control.created_at|date:'d.m.Y H:i' }}

    -

    Aktualisiert am: {{ control.updated_at|date:'d.m.Y H:i' }}

    +

    {% trans "Created at" %}: {{ control.created_at|date:"d.m.Y H:i" }}

    +

    {% trans "Updated at" %}: {{ control.updated_at|date:"d.m.Y H:i" }}

    +

    {% trans "Deadline" %}: {{ control.due_date|date:"d.m.Y"|default:"–" }}

    -
    -
    - - -
    -
    -

    Verknüpfte Risiken

    -
    -
    - {% if control.risks %} - - - - - - - - - - - - - {% for risk in control.risks.all %} - - - - - - - - {% endfor %} - -
    TitelRisikoeignerKategorieAssetProzess
    {{ risk.title }} - {% if risk.owner %} - {{ risk.owner }} - {% else %} - – - {% endif %} - - {% if risk.category %} - {{ risk.category }} - {% else %} - – - {% endif %} - - {% if risk.asset %} - {{ risk.asset }} - {% else %} - – - {% endif %} - - {% if risk.process %} - {{ risk.process }} - {% else %} - – - {% endif %} -
    - {% else %} -

    Keine Verknüpften Risiken.

    - {% endif %} +
    +

    {% trans "Description" %}

    +

    {{ control.description|default:"–" }}

    +
    - +
    - -
    -
    -

    Historie

    -
    -
    - {% if logs %} - - - - - - - - - - {% for log in logs %} - - - - - - {% endfor %} - -
    ZeitpunktBenutzerAktion
    {{ log.action_time|date:"d.m.Y H:i" }}{{ log.user.get_full_name|default:log.user.username }}{{ log.get_change_message }}
    - {% else %} -

    Keine Historie vorhanden.

    - {% endif %} -
    -
    + + -

    + + -
    -{% endblock %} \ No newline at end of file + + +{% endblock %} diff --git a/templates/risks/item_incident.html b/templates/risks/item_incident.html index acd55fb..1dc08f6 100644 --- a/templates/risks/item_incident.html +++ b/templates/risks/item_incident.html @@ -1,157 +1,126 @@ {% extends "base.html" %} +{% load i18n risk_extras %} + {% block crumbs %} -
  • Vorfälle
  • +
  • {% trans "Incidents" %}
  • {{ incident.title }}
  • {% endblock %} -{% block content %} -
    -
    -
    -

    Vorfall: {{ incident.title }}

    -

    {{ incident.description }}

    -
    -
    - -
    -
    -

    Überblick

    - {% if request.user.is_staff or incident.reported_by_id == request.user.id %} -
    - {% csrf_token %} -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - - - - - - - {% endif %} -
    - +{% block content %} + + + + + +
    +
    -

    Gemeldet von: {{ incident.reported_by|default:"-" }}

    -

    Gemeldet am: {{ incident.date_reported|date:'d.m.Y' }}

    -

    Status: {{ incident.status }}

    +

    {% trans "Incident" %}: {{ incident.title }}

    +

    {% trans "Reported by" %}: {{ incident.reported_by|default:"–" }}

    +

    {% trans "Reported on" %}: {{ incident.date_reported|date:"d.m.Y" }}

    +

    {% trans "Status" %}: {{ incident.get_status_display }}

    -

    Erstellt am: {{ incident.created_at|date:'d.m.Y H:i' }}

    -

    Aktualisiert am: {{ incident.updated_at|date:'d.m.Y H:i' }}

    +

    {% trans "Created at" %}: {{ incident.created_at|date:"d.m.Y H:i" }}

    +

    {% trans "Updated at" %}: {{ incident.updated_at|date:"d.m.Y H:i" }}

    -
    -
    - - -
    -
    -

    Zugehörige Risiken

    -
    -
    - {% if incident.related_risks %} - - - - - - - - - - - - {% for risk in incident.related_risks.all %} - - - - - - - - {% endfor %} - -
    TitelRisikoeignerKategorieAssetProzess
    {{ risk.title }} - {% if risk.owner %} - {{ risk.owner }} - {% else %} - – - {% endif %} - - {% if risk.category %} - {{ risk.category }} - {% else %} - – - {% endif %} - - {% if risk.asset %} - {{ risk.asset }} - {% else %} - – - {% endif %} - - {% if risk.process %} - {{ risk.process }} - {% else %} - – - {% endif %} -
    - {% else %} -

    Keine Verknüpften Risiken.

    - {% endif %} +
    +

    {% trans "Description" %}

    +

    {{ incident.description|default:"–" }}

    +
    - +
    - -
    -
    -

    Historie

    -
    -
    - {% if logs %} - - - - - - - - - - {% for log in logs %} - - - - - - {% endfor %} - -
    ZeitpunktBenutzerAktion
    {{ log.action_time|date:"d.m.Y H:i" }}{{ log.user.get_full_name|default:log.user.username }}{{ log.get_change_message }}
    - {% else %} -

    Keine Historie vorhanden.

    - {% endif %} -
    -
    + + -

    + + -
    -{% endblock %} \ No newline at end of file + + + +{% endblock %} diff --git a/templates/risks/item_risk.html b/templates/risks/item_risk.html index 3e1be97..2ba061f 100644 --- a/templates/risks/item_risk.html +++ b/templates/risks/item_risk.html @@ -13,6 +13,15 @@ {% trans "Measures" %} {% trans "Incidents" %} {% trans "History" %} + +
    diff --git a/templates/risks/list_controls.html b/templates/risks/list_controls.html index 61779a4..8a73368 100644 --- a/templates/risks/list_controls.html +++ b/templates/risks/list_controls.html @@ -1,177 +1,151 @@ {% extends "base.html" %} +{% load i18n risk_extras %} + {% block crumbs %} -
  • Maßnahmen
  • +
  • {% trans "Controls" %}
  • +
  • {% endblock %} + {% block content %} - -
    -
    -

    Auswahl

    -
    -
    + +
    + +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    -
    - + + -

    Maßnahmen

    - -
    - - - - {% if request.user.is_staff %}{% endif %} - - - - - - - - - - {% if request.user.is_staff %} - - - - - - - - - - {% endif %} - {% for c in controls %} - - {% if request.user.is_staff %} - - {% endif %} - - - - - - - - {% empty %} - - - - {% endfor %} - -
    MaßnahmeRisikenVerantwortliche/rStatusFristLink
    - - - -
    - - - - {{ c.title }} - {% if c.risk %} - - {{ c.risk.title }} - - {% else %} - – - {% endif %} - - {% if c.responsible %} - {{ c.responsible.get_full_name|default:c.responsible.username }} - {% else %} - – - {% endif %} - {{ c.get_status_display }} - {% if c.due_date %} - {{ c.due_date|date:"d.m.Y" }} - {% else %} - – - {% endif %} - - {% if c.wiki_link %} - 🔗 - {% else %} - – - {% endif %} -
    Keine Maßnahmen gefunden
    -
    -
    + +
    -{% endblock %} \ No newline at end of file + +
    + + + + + + + + + + + + + + {% for c in controls %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "No." %}{% trans "Control" %}{% trans "Related Risk" %}{% trans "Responsible" %}{% trans "Status" %}{% trans "Deadline" %}{% trans "Link" %}
    {{ c.id }}{{ c.title }} + {% if c.risk %} + + {{ c.risk.title }} + + {% else %} + – + {% endif %} + + {% if c.responsible %} + {{ c.responsible.get_full_name|default:c.responsible.username }} + {% else %} + – + {% endif %} + {{ c.get_status_display }} + {% if c.due_date %} + {{ c.due_date|date:"d.m.Y" }} + {% else %} + – + {% endif %} + + {% if c.wiki_link %} + 🔗 + {% else %} + – + {% endif %} +
    {% trans "No controls found." %}
    +
    + +{% endblock %} diff --git a/templates/risks/list_incidents.html b/templates/risks/list_incidents.html index 9ba25aa..c762ced 100644 --- a/templates/risks/list_incidents.html +++ b/templates/risks/list_incidents.html @@ -1,154 +1,131 @@ {% extends "base.html" %} +{% load i18n risk_extras %} + {% block crumbs %} -
  • Vorfälle
  • +
  • {% trans "Incidents" %}
  • +
  • {% endblock %} + {% block content %} - -
    -
    -

    Auswahl

    -
    -
    + +
    + +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    +
    - -
    -
    - -
    -
    - -
    -
    -
    + +
    + +
    +
    -
    - +
    -

    Vorfälle

    + + -
    - - - - {% if request.user.is_staff %}{% endif %} - - - - - - - - - {% if request.user.is_staff %} - - - - - - - - - - {% endif %} - {% for i in incidents %} - - {% if request.user.is_staff %} - - {% endif %} - - - - - - - {% endfor %} - -
    VorfallZugehörige RisikenStatusGemeldet amGemeldet von
    - - - -
    - - - - {{ i.title }} - {% if i.related_risks.exists %} -
      - {% for r in i.related_risks.all %} -
    • {{ r.title }}
    • - {% endfor %} - {% else %} - Noch kein Risiko zugeordnet - {% endif %} -
    -
    {{ i.get_status_display }}{{ i.date_reported|date:"d.m.Y" }}{{ i.reported_by }}
    -
    -
    + +
    -{% endblock %} \ No newline at end of file + +
    + + + + + + + + + + + + + {% for i in incidents %} + + + + + + + + + {% empty %} + + {% endfor %} + +
    {% trans "No." %}{% trans "Incident" %}{% trans "Linked Risks" %}{% trans "Status" %}{% trans "Reported on" %}{% trans "Reported by" %}
    {{ i.id }}{{ i.title }} + {% if i.related_risks.exists %} +
      + {% for r in i.related_risks.all %} +
    • {{ r.title }}
    • + {% endfor %} +
    + {% else %} + + {% endif %} +
    {{ i.get_status_display }}{{ i.date_reported|date:"d.m.Y" }}{{ i.reported_by|default:"–" }}
    {% trans "No incidents found." %}
    +
    + +{% endblock %} diff --git a/templates/risks/list_risks.html b/templates/risks/list_risks.html index 02c309d..aa28143 100644 --- a/templates/risks/list_risks.html +++ b/templates/risks/list_risks.html @@ -2,6 +2,7 @@ {% load i18n risk_extras %} {% block crumbs %}
  • {% trans "Risk analysis" %}
  • +
  • {% endblock %} {% block content %} diff --git a/templates/risks/notifications.html b/templates/risks/notifications.html index 28d6b8d..24e3b49 100644 --- a/templates/risks/notifications.html +++ b/templates/risks/notifications.html @@ -30,9 +30,15 @@

    {% if not n.read %} - {% trans "New" %} + + {% trans "New" %} + + {% endif %} + {% if n.get_link %} + {{ n.message }} + {% else %} + {{ n.message }} {% endif %} - {{ n.message }}

    {{ n.created_at|date:"d.m.Y H:i" }}