From 686030e4cbfddae8f7398013bd78a94748ca5f76 Mon Sep 17 00:00:00 2001 From: Kevin Heyer Date: Tue, 9 Sep 2025 12:00:29 +0200 Subject: [PATCH] feat: Enhance risk management application with user auditing and improved incident handling - Added AuditUserMiddleware to track the current user for auditing purposes. - Introduced audit_context for managing the current user in thread-local storage. - Updated Control and Incident models to include created_at and updated_at timestamps. - Refactored Control and Incident serializers to handle related risks and timestamps. - Modified views to set the _changed_by attribute for user actions. - Enhanced incident listing and detail views to display related risks and user actions. - Updated templates for better presentation of risks and incidents. - Added migrations for new fields and relationships in the database. - Improved filtering options in the incident list view. --- api/__pycache__/__init__.cpython-311.pyc | Bin 173 -> 169 bytes api/__pycache__/views.cpython-311.pyc | Bin 1255 -> 1251 bytes config/__pycache__/__init__.cpython-311.pyc | Bin 176 -> 172 bytes config/__pycache__/settings.cpython-311.pyc | Bin 5063 -> 5097 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1858 -> 1803 bytes config/__pycache__/wsgi.cpython-311.pyc | Bin 696 -> 692 bytes config/settings.py | 1 + config/urls.py | 3 +- db.sqlite3 | Bin 208896 -> 221184 bytes risks/admin.py | 18 +- risks/audit_context.py | 8 + risks/middleware.py | 9 + ..._created_at_control_updatet_at_and_more.py | 52 ++++++ .../0014_remove_control_risk_control_risks.py | 21 +++ .../migrations/0015_alter_auditlog_changes.py | 20 +++ ..._updatet_at_control_updated_at_and_more.py | 32 ++++ .../migrations/0017_alter_incident_status.py | 24 +++ risks/models.py | 26 ++- risks/serializers.py | 45 +++-- risks/signals.py | 68 ++++---- risks/views.py | 51 ++++-- templates/risks/item_control.html | 50 +++--- templates/risks/item_incident.html | 121 +++++++++++++ templates/risks/item_risk.html | 2 +- templates/risks/list_incidents.html | 160 ++++++++++-------- 25 files changed, 540 insertions(+), 171 deletions(-) create mode 100644 risks/audit_context.py create mode 100644 risks/middleware.py create mode 100644 risks/migrations/0013_control_created_at_control_updatet_at_and_more.py create mode 100644 risks/migrations/0014_remove_control_risk_control_risks.py create mode 100644 risks/migrations/0015_alter_auditlog_changes.py create mode 100644 risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py create mode 100644 risks/migrations/0017_alter_incident_status.py diff --git a/api/__pycache__/__init__.cpython-311.pyc b/api/__pycache__/__init__.cpython-311.pyc index 6ba22184670e8181044f0ecf1f7206e707d78756..9a9a706125e7693dc0b6fea7ee5e4b80c1360ba3 100644 GIT binary patch delta 43 xcmZ3>xRQ~3IWI340}!yU-8GThl+k9Qqd0$gW>I2%QD$*=d~RZ1V&24TCjj_A4Hf_Z delta 47 zcmZ3I2% TQD$*=d~RZ1V&3LEjNHrs$aEIq delta 69 zcmaFN`J9t`IWI340}#AU*|(8<8KbtXenx(7s(yBAS!P~FYGrDXeo<;cezCr1u)nU6 Xxq*S9Zct`%wytktUgG8(jNHrs5RMja diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 48eb40bcb36cffc54c391702cbfa381a168fff30..2579686a52b774f2e7ba0628ce4d574e844442eb 100644 GIT binary patch delta 43 xcmdnMxQ3B?IWI340}u$W-Zhcil+k{oqd0$gW>I2%QD$*=d~RZ1V&24TR{;944J-fv delta 47 zcmZ3(xPg&-IWI340}#AU**B5fl+k&jqqwkVu)nU6xq*S9Zct`%wytktUgE@bR{$g~ B4H5tV diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 0cf65745b2a9576f50fc943c9dffe209ee93ab95..d2e5ac0dba70470959de3287c19a7635b88926e1 100644 GIT binary patch delta 137 zcmX@E{!*QHIWI340}#A_zdz&5M&5a>j2x5Kuzr=6TO)V}+OitZ=lUbHg-C92*KQ~oBJGCq`FC(=wwMf4xwIIJ(zdW-jF}^6X zI6FQ!F)uN1vLdSjmj=)xMj$S}z1f?!iBW1r_yu0mi@c^+fFz6A6&AA(+-&?z4cs4u NCjVx0W>Etw1pr|VWT=T)8s5BnaKwjS=oh|7*aV?gn)cmMz+m9 zOo@yfRcu9>#o5LBn|CqGGHToFXXNLm>Sw2xW#(n1R;Cu|7o`^D7wdZl`|BE+8yFbs z1_4#;`X=TjPX5QDz@-kfixG&6-*47nZDQ2B$g6aPSLq^)@)Z{48!Q|RE*%mR%qN)7 ku(>F#aYb0;B8%n~7R?X5?EFj(+#f_HA7OK5Q3YxT0Da0f9{>OV diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index b976614273605c9e1cf9c9445eb35fa8b942452e..01cbb7e0ddcf6af7531e25cb878cd524dd2823fe 100644 GIT binary patch delta 64 zcmdnNx`mZ{IWI340}zO;-nEhY2cx>Renx(7s(yBAS!P~FYGrDXeo<;cezAUeW>I2% SQD$*=d~RZ1V%}surY-=KmKG-f delta 68 zcmdnOx`UN_IWI340}#AU*|(AV2cx#Venx(7s(yBAS!P~FYGrDXeo<;cezCr1u)nU6 Wxq*S9Zct`%wytktUgBgerY->OzZMJt diff --git a/config/settings.py b/config/settings.py index f85427c..899d0f9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -50,6 +50,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "risks.middleware.AuditUserMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/config/urls.py b/config/urls.py index b247a85..4802609 100644 --- a/config/urls.py +++ b/config/urls.py @@ -18,8 +18,7 @@ urlpatterns = [ path("api/ping/", ping), # Public healthcheck endpoint path("api/secure-ping/", secure_ping), # Protected API endpoint path("api/", include(router.urls)), - path("", include("risks.urls")), - path("risks/", include("risks.urls")), + path("", include("risks.urls", namespace="risks")), ] # Add OIDC routes only if Single Sign-On is enabled diff --git a/db.sqlite3 b/db.sqlite3 index c5adb267aef30ae39bb64418f35dbdce610b0ad6..16f0f61ece3741316c09524505bb027297b1c373 100644 GIT binary patch delta 3657 zcma)93vd(18NS^+N!D|1z$hjL%V)q=!RAgU>2xAIWQPVruz5cMwveSW7M3NW2L_C5 zjz=?{zHp%I&`GB>ePo!@A&NU(+BAhE?MxensT(Pg+x+#kClBbb8fIK5(^99hI-TrN4T|!=`)mq8e zpjVF>Di@gyZyAmnI`yCE)vOZLk1lS|QT9L}8BO)4IF1*Cp;%f@2BYzCG$O~-!Bjew z&ZJzN%hSwB3f4oIUX92gPFldC@rUhp>!~uh^Lc@ zSYaxnO@-o-;6NfNyGHK<=6rsa)FSc%FS?i16X3QIu%|#Ql!-*svBZvGxGxmnA>YN! z=NCjj?`x4Hug@i|a1ekkB>*>JHjvmYm*{9N2Sr!8)eAPLf@Is%p| zfe9s)a8f3I=A0@*mAPK>v&>D=&vAZ_x5edhyG7s`_dImTz+%d3yUd^$(R=71I)kR5 zr%@A1+5UmHp?2G4+ily=v&sn@W4le&j0LKnm|jf_#vJ7mk=$EV740m{7k6;VblM5> zqQ%(hrs?Qr5ZOrrmw!LoC zTNj$Inr@jojZanVG#uAo*Kg6!W;QV_yav8O{f3B9lzQ!P&;?XG4~|2eSO`4oj|FfE zKw%o*wGhE3Q<0zrwON_C@VX*IN14;&ecLk_dKWqoL;JFFFRE{&JZZ3J|Imd|$K+n8Hp_j(1 z2FKB=05DNTqj9Ry7zjL`7`i*x3Ms)0KDRjOii`J%9_<_h^eKftRjVrL8%WOphF}t_ zHxbJaa1#+Qqw^Gc6Mcq0MjxUN(7Wib==bQ?=ps5#czh38bTj~Fvo;z`_~Pe4nqfw- zQRpW61bu|AqxXr%+vv~e4fH$o8qqj|Obl%VW^FLxHxT43j5$Wx{$f3688gSS$~JS; zL6*_2IQ_i-{3m?~o@GO{d&@@!K=uPuo+g+PAUA-a`-n^=F*_ys^xEKzXP?k@Uz)O-kRtr<~ z472GS3LQgpZ77#2p&(F1%r!$E56_1+>zlQs z)!ZuYi^_eO_zrfygUw?TJhrV+`Etjv%qB|=8OjZbeqzYYweVhvlst573dUl0gl#=m031zCWal zwv3;i31{F}I^p6eT0-g2`(^1dUUlG)XttIOI&EMWngX;oK$@nEM7;Q$tL5mwP?Obq zi=4Tgl(om)V#*qw6<^U$=)cNbV^-6Z@DXsF`q>C82EQ-b>@}7JxD;C@Qn>Gfck)T`T+`Qn5Od{jSBi>jJETgI5y$YuuK>&yA)1jW2JPC)wl(qa6&B8}p!8A*ekS2nc`hnL_n8D|H(1=gB z12aC-35IE>t^D0Ws%e(ZYA!x3iR2-k(2b+wC zbgK+@rxP5(SN1^`G-JnJSc~h1;ph021Q`6246G{=15VsG0!P$^Nk~KdJn;x`PQ&L( z^P~F5lpXY;>Mi)-Qqp0)`KX*<3ciDX6aa&HtgZm~B5`0RKidt#Rc$+ja_V&ng?ys4|l@%Vxz|Ukw6*bwkl~xX(duqeC(& z0O@w}(iiHH9da}-#~p>`9ddsxlp!Vk(NM|}O{E?2Q2NzhBx6w`9nT~+VaIwom9BRd zs~;yN2IV-s^Qzr#a|OSi6k-=Ia+56ORwVbiE2Pn@%u#r=F4yMo@5#>reI>J!LMyXM z0NE55FjIV8z0yb#kMP%nAmuJcFJ20HFHi1Dldkv#k_9=t&l0Sa{tjAfcYHZu!AH{6IJe*$W|ML(cZ1F4mevK?P≥#I*)C zm`IV!dL*K?wP++ornd53BO{0Pj4^NMNl*R)oU=5OY|-SVydFWsmM+NRNH?5Sb?=U{ zcvcWLZe$;PXSZcIt~Wf{?y@6<*-O6!Z+QAmz?Tp%w z)>=6gjbuWxwWOT!7J?Gwg&b{Z{7X-PA2p3Y}&VX~c-!KGZrlz4{WiIh{<)U3fE^#ji z6~O`Qzse)YJDY3dSS@SQD~(gSIah7XksFz%PIH>NW%HwS=9W%-0crAAXL~=+^ZuUa z*`BZNV{-RJY3H2OwFn{i;|1(|L4Vo}Ym={hNaS&G`Yf64*@Cy!j8Olc;y_w(r%V>#Y>j$=Hq5b z_*zIZF$HDQXFQN<-0(ps(6VwE z<(ZF6y(+^nFM|6}$o^)aE@WRW_e$5LTHEv1-Qss*f$*_;&~(?diT{jmum)QlaOp=c!J-W>4zBjMn**yaq>KssIhI+n_wWb&M=YMgn~$UJAkgn(gr zE^T(6#j%druZlKB8rtb#0?)Tk!?ULhi}XMOmm6nRLIG!}3e!wh()GD@S%AVvt?rJTuit_<7uOK=))}CmpH4HSBo^ zk>AL-WQaWv5I&o$0=joD&ZOV0!ixr+m|2i+B67g)l5Vn&lX7|joQP)6Cw-|^sJ^8k zoM@;l^6lhAOSw$mP;yb=)N?*KwJdy&Puv9I`{5!mB46N;Mc+3&kZ$iMLP* z7AgU!s+KBBsiGDsireQ7tYWd8J(H}NQk=AZA;jes{HNr1s!XG#)axzsD!!niSXt&c za{F;8SyHYLAI~A+#y8AyNR*}h$cD?1lw&<@^$KS!=Pk1NS(BBI;WGJE`4@rB2Dl}p z=~@|Yxrc^yZIGQ1)~n{yqm}Tz#5_#2ue2Yx>sqBz^d{yop9qB$w5AJZ&=sBdkip1> zrX^aA-{N@L1=`Yy5}hp0Xj6SO($JKMhguq%V=ak>NN7{XMB&%_-R|IK-*l{SyohuE zSBXLgE)aW`cr6Z>3;JkD2hO8Ev}3{8(1Fu|cWHu$ZfnLt$e-wnBh9#!m#?Cux^|c* z-@u)0MBq6|&4I^D+Aw$HLxjz{$2n1og27-w@fpY8$NrR0{=_N!4l4T&rhEy!bC~ab QK=jo%NMqr?#crm50bWI~ZU6uP diff --git a/risks/admin.py b/risks/admin.py index 955afb1..32ee87d 100644 --- a/risks/admin.py +++ b/risks/admin.py @@ -19,13 +19,6 @@ class UserAdmin(BaseUserAdmin): return obj.controls_responsible.count() responsible_controls_count.short_description = "Controls Responsible" - -class ControlInline(admin.TabularInline): - model = Control - extra = 1 - fields = ("title", "status", "due_date", "responsible", "wiki_link") - autocomplete_fields = ("responsible",) - class ResidualRiskInline(admin.StackedInline): """ Inline editor for ResidualRisk, linked one-to-one with Risk @@ -36,6 +29,11 @@ class ResidualRiskInline(admin.StackedInline): readonly_fields = ("score", "level", "review_required") fields = ("likelihood", "impact", "score", "level", "review_required") +class ControlRisksInline(admin.TabularInline): + model = Control.risks.through + fk_name = "risk" + extra = 1 + autocomplete_fields = ("control",) @admin.register(Risk) class RiskAdmin(admin.ModelAdmin): @@ -50,7 +48,7 @@ class RiskAdmin(admin.ModelAdmin): ) list_filter = ("level", "likelihood", "impact", "owner") search_fields = ("title", "asset", "process", "category") - inlines = [ControlInline, ResidualRiskInline] + inlines = [ResidualRiskInline, ControlRisksInline] # Controls hier verknüpfen def save_model(self, request, obj, form, change): obj._changed_by = request.user @@ -82,10 +80,10 @@ class ResidualRiskAdmin(admin.ModelAdmin): @admin.register(Control) class ControlAdmin(admin.ModelAdmin): - list_display = ("title", "status", "due_date", "responsible", "risk") + list_display = ("title", "status", "due_date", "responsible") list_filter = ("status", "due_date") + autocomplete_fields = ("risks", "responsible",) search_fields = ("title", "description") - autocomplete_fields = ("responsible", "risk") def save_model(self, request, obj, form, change): obj._changed_by = request.user diff --git a/risks/audit_context.py b/risks/audit_context.py new file mode 100644 index 0000000..5e5b04e --- /dev/null +++ b/risks/audit_context.py @@ -0,0 +1,8 @@ +import threading +_local = threading.local() + +def set_current_user(user): + _local.user = user + +def get_current_user(): + return getattr(_local, "user", None) \ No newline at end of file diff --git a/risks/middleware.py b/risks/middleware.py new file mode 100644 index 0000000..cad4512 --- /dev/null +++ b/risks/middleware.py @@ -0,0 +1,9 @@ +from .audit_context import set_current_user + +class AuditUserMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + set_current_user(getattr(request, "user", None)) + return self.get_response(request) diff --git a/risks/migrations/0013_control_created_at_control_updatet_at_and_more.py b/risks/migrations/0013_control_created_at_control_updatet_at_and_more.py new file mode 100644 index 0000000..d1043ea --- /dev/null +++ b/risks/migrations/0013_control_created_at_control_updatet_at_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.6 on 2025-09-09 07:00 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0012_alter_residualrisk_impact_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="control", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="control", + name="updatet_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="incident", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="incident", + name="updatet_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="residualrisk", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="residualrisk", + name="updatet_at", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/risks/migrations/0014_remove_control_risk_control_risks.py b/risks/migrations/0014_remove_control_risk_control_risks.py new file mode 100644 index 0000000..bf2a0b9 --- /dev/null +++ b/risks/migrations/0014_remove_control_risk_control_risks.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.6 on 2025-09-09 07:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0013_control_created_at_control_updatet_at_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="control", + name="risk", + ), + migrations.AddField( + model_name="control", + name="risks", + field=models.ManyToManyField(related_name="controls", to="risks.risk"), + ), + ] diff --git a/risks/migrations/0015_alter_auditlog_changes.py b/risks/migrations/0015_alter_auditlog_changes.py new file mode 100644 index 0000000..96b4bc9 --- /dev/null +++ b/risks/migrations/0015_alter_auditlog_changes.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-09-09 08:37 + +import risks.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0014_remove_control_risk_control_risks"), + ] + + operations = [ + migrations.AlterField( + model_name="auditlog", + name="changes", + field=models.JSONField( + blank=True, encoder=risks.models.SafeJSONEncoder, null=True + ), + ), + ] diff --git a/risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py b/risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py new file mode 100644 index 0000000..9c7a9eb --- /dev/null +++ b/risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-09-09 09:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0015_alter_auditlog_changes"), + ] + + operations = [ + migrations.RenameField( + model_name="control", + old_name="updatet_at", + new_name="updated_at", + ), + migrations.RenameField( + model_name="incident", + old_name="updatet_at", + new_name="updated_at", + ), + migrations.RenameField( + model_name="residualrisk", + old_name="updatet_at", + new_name="updated_at", + ), + migrations.RenameField( + model_name="risk", + old_name="updatet_at", + new_name="updated_at", + ), + ] diff --git a/risks/migrations/0017_alter_incident_status.py b/risks/migrations/0017_alter_incident_status.py new file mode 100644 index 0000000..a1a3c8c --- /dev/null +++ b/risks/migrations/0017_alter_incident_status.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.6 on 2025-09-09 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("risks", "0016_rename_updatet_at_control_updated_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="incident", + name="status", + field=models.CharField( + choices=[ + ("open", "Opened"), + ("in_progress", "In Progress"), + ("closed", "Closed"), + ], + max_length=12, + ), + ), + ] diff --git a/risks/models.py b/risks/models.py index ced4069..6d5ace5 100644 --- a/risks/models.py +++ b/risks/models.py @@ -1,7 +1,16 @@ 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 multiselectfield import MultiSelectField +import datetime +import json + +class SafeJSONEncoder(DjangoJSONEncoder): + def default(self, obj): + if isinstance(obj, datetime.date): + return obj.isoformat() + return super().default(obj) class User(AbstractUser): """ @@ -52,7 +61,7 @@ class Risk(models.Model): process = models.CharField(max_length=255, blank=True, null=True) category = models.CharField(max_length=255, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True,) - updatet_at = models.DateTimeField(auto_now=True) + updated_at = models.DateTimeField(auto_now=True) # CIA Protection Goals cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True) @@ -127,12 +136,15 @@ class ResidualRisk(models.Model): review_required = models.BooleanField(default=False) + 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) if self.pk: old = ResidualRisk.objects.get(pk=self.pk) if old.likelihood != self.likelihood or old.impact != self.impact: - self.review_required = False + self.review_required = True self.score = self.likelihood * self.impact @@ -174,9 +186,11 @@ 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,) + updated_at = models.DateTimeField(auto_now=True) # Relation to risk - risk = models.ForeignKey(Risk, on_delete=models.CASCADE, related_name="controls") + risks = models.ManyToManyField("Risk", related_name="controls") def __str__(self): return f"{self.title} ({self.get_status_display()})" @@ -201,7 +215,7 @@ class AuditLog(models.Model): action = models.CharField(max_length=10, choices=ACTION_CHOICES) model = models.CharField(max_length=100) object_id = models.CharField(max_length=50) - changes = models.JSONField(null=True, blank=True) + changes = models.JSONField(null=True, blank=True, encoder=SafeJSONEncoder) timestamp = models.DateTimeField(auto_now_add=True) def __str__(self): @@ -214,7 +228,7 @@ class Incident(models.Model): STATUS_CHOICES = [ ("open", "Opened"), ("in_progress", "In Progress"), - ("close", "Closed"), + ("closed", "Closed"), ] title = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) @@ -222,6 +236,8 @@ class Incident(models.Model): reported_by = models.ForeignKey(settings.AUTH_USER_MODEL, 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,) + updated_at = models.DateTimeField(auto_now=True) class Notification(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications") diff --git a/risks/serializers.py b/risks/serializers.py index 0570c3e..fb12561 100644 --- a/risks/serializers.py +++ b/risks/serializers.py @@ -18,17 +18,21 @@ class ResidualRiskSerializer(serializers.ModelSerializer): class ControlSerializer(serializers.ModelSerializer): + risks = serializers.PrimaryKeyRelatedField(many=True, queryset=Risk.objects.all()) + class Meta: model = Control fields = [ "id", "title", "status", + "created_at", + "updated_at", "due_date", "responsible", "description", "wiki_link", - "risk", + "risks", ] class RiskSerializer(serializers.ModelSerializer): @@ -44,16 +48,14 @@ class RiskSerializer(serializers.ModelSerializer): "process", "category", "created_at", - "updatet_at", + "updated_at", "likelihood", "impact", "score", "level", "owner", "follow_up", - "confidentiality", - "integrity", - "availability", + "cia", "controls", ] @@ -93,15 +95,30 @@ class RiskSummarySerializer(serializers.ModelSerializer): fields = ["id", "title", "score", "level"] class IncidentSerializer(serializers.ModelSerializer): - related_risks = RiskSummarySerializer(many=True, read_only=True) - + related_risks = serializers.PrimaryKeyRelatedField( + many=True, queryset=Risk.objects.all() + ) + date_reported = serializers.DateField(format="%Y-%m-%d", required=False) + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + class Meta: model = Incident fields = [ - "id", - "title", - "description", - "date_reported", - "status", - "related_risks", - ] \ No newline at end of file + "id", "title", "description", "date_reported", + "created_at", "updated_at", "status", "related_risks", + ] + + def create(self, validated_data): + risks = validated_data.pop("related_risks", []) + obj = super().create(validated_data) + if risks: + obj.related_risks.set(risks) + return obj + + def update(self, instance, validated_data): + risks = validated_data.pop("related_risks", None) + obj = super().update(instance, validated_data) + if risks is not None: + obj.related_risks.set(risks) + return obj diff --git a/risks/signals.py b/risks/signals.py index c8ccef2..599a838 100644 --- a/risks/signals.py +++ b/risks/signals.py @@ -2,6 +2,7 @@ from datetime import date, datetime from django.db.models import Model from django.db.models.signals import post_save, post_delete, m2m_changed from django.dispatch import receiver +from .audit_context import get_current_user from .models import Control, Risk, ResidualRisk, AuditLog, Incident from .utils import model_diff @@ -21,8 +22,6 @@ def serialize_value(value): # --------------------------------------------------------------------------- @receiver(post_save, sender=Risk) def log_risk_save(sender, instance, created, **kwargs): - - if created: AuditLog.objects.create( user=getattr(instance, "_changed_by", None), @@ -39,7 +38,6 @@ def log_risk_save(sender, instance, created, **kwargs): 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"])} @@ -50,7 +48,7 @@ def log_risk_save(sender, instance, created, **kwargs): action="update", model="Risk", object_id=instance.pk, - changes=changes, + changes=clean_changes, ) @receiver(post_delete, sender=Risk) @@ -58,8 +56,9 @@ def log_risk_delete(sender, instance, **kwargs): """ Signal that runs after a Risk is deleted. """ + user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="delete", model="Risk", object_id=instance.pk, @@ -88,7 +87,6 @@ def log_control_save(sender, instance, created, **kwargs): 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"])} @@ -99,35 +97,39 @@ def log_control_save(sender, instance, created, **kwargs): action="update", model="Control", object_id=instance.pk, - changes=changes, + changes=clean_changes, ) @receiver(post_delete, sender=Control) def log_control_delete(sender, instance, **kwargs): + user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="delete", model="Control", object_id=instance.pk, changes=None, ) -@receiver(post_save, sender=Control) -def update_residual_risk_on_control_change(sender, instance, **kwargs): - """ - Whenever a control is saved, check if the related risk has a residual risk. - If a control is completed or verified, flag the residual risk for review. - """ +@receiver(m2m_changed, sender=Control.risks.through) +def control_risks_changed(sender, instance, action, reverse, pk_set, **kwargs): + if action in {"post_add", "post_remove", "post_clear"}: + if action == "post_clear": + affected_risks = instance.risks.all() + elif pk_set: + if reverse: + from .models import Risk + affected_risks = Risk.objects.filter(pk__in=pk_set) + else: + affected_risks = Risk.objects.filter(pk__in=pk_set) + else: + affected_risks = instance.risks.all() - risk = instance.risk - - # Ensure residual risk exists - residual, created = ResidualRisk.objects.get_or_create(risk=risk) - - # If a control is marked as completed or verified, we mark residual risk for review - if instance.status in ["completed", "verified"]: - residual.review_required = True - residual.save() + from .models import ResidualRisk + for risk in affected_risks: + residual, _ = ResidualRisk.objects.get_or_create(risk=risk) + residual.review_required = True + residual.save() # --------------------------------------------------------------------------- # Residual risks @@ -151,7 +153,6 @@ def log_residual_save(sender, instance, created, **kwargs): 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"])} @@ -167,8 +168,9 @@ def log_residual_save(sender, instance, created, **kwargs): @receiver(post_delete, sender=ResidualRisk) def log_residual_delete(sender, instance, **kwargs): + user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="delete", model="ResidualRisk", object_id=instance.pk, @@ -187,12 +189,16 @@ def log_incident_save(sender, instance, created, **kwargs): action="create", model="Incident", object_id=instance.pk, - changes={f.name: {"old": None, "new": 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 = 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"])} @@ -203,14 +209,15 @@ def log_incident_save(sender, instance, created, **kwargs): action="update", model="Incident", object_id=instance.pk, - changes=changes, + changes=clean_changes, ) @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"]: + user = getattr(instance, "_changed_by", None) or get_current_user() AuditLog.objects.create( - user=getattr(instance, "_changed_by", None), + user=user, action="update", model="Incident", object_id=instance.pk, @@ -219,8 +226,9 @@ def log_incident_risks_change(sender, instance, action, reverse, model, 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=getattr(instance, "_changed_by", None), + user=user, action="delete", model="Incident", object_id=instance.pk, diff --git a/risks/views.py b/risks/views.py index 0b1a62d..f3dc6f0 100644 --- a/risks/views.py +++ b/risks/views.py @@ -24,12 +24,10 @@ class RiskViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() def perform_update(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() class ControlViewSet(viewsets.ModelViewSet): """ @@ -43,12 +41,10 @@ class ControlViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() def perform_update(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() class ResidualRiskViewSet(viewsets.ModelViewSet): queryset = ResidualRisk.objects.all() @@ -66,12 +62,10 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def perform_create(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() def perform_update(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() class AuditViewSet(viewsets.ReadOnlyModelViewSet): """ @@ -92,12 +86,10 @@ class IncidentViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): instance = serializer.save(reported_by=self.request.user) instance._changed_by = self.request.user - instance.save() def perform_update(self, serializer): instance = serializer.save() instance._changed_by = self.request.user - instance.save() # --------------------------------------------------------------------------- # Web @@ -146,9 +138,8 @@ def show_risk(request, id): return render(request, "risks/item_risk.html", {"risk": risk, "logs": logs}) def list_controls(request): - qs = Control.objects.all().select_related("risk", "responsible") + qs = Control.objects.all().select_related("responsible") - # Filter control_id = request.GET.get("control") risk_id = request.GET.get("risk") status = request.GET.get("status") @@ -157,13 +148,13 @@ def list_controls(request): if control_id: qs = qs.filter(id=control_id) if risk_id: - qs = qs.filter(risk_id=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) - controls = qs.order_by("title") + 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") @@ -185,9 +176,39 @@ def show_control(request, id): return render(request, "risks/item_control.html", {"control": control, "logs": logs}) + def list_incidents(request): - return render(request, "risks/list_incidents.html") + 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) + + 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, + "status_choices": Incident.STATUS_CHOICES, + }) def show_incident(request, id): - incident = Incident.objects.get(pk=id) - return render(request, "risks/item_incident.html", {"incident": incident }) \ No newline at end of file + 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") + + return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs}) \ No newline at end of file diff --git a/templates/risks/item_control.html b/templates/risks/item_control.html index b171e41..21540e5 100644 --- a/templates/risks/item_control.html +++ b/templates/risks/item_control.html @@ -20,59 +20,63 @@
-

- Verknüpfte Risiken: -

+

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

Zum Wiki Eintrag

-

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

+

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

-

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

+

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

- +
-

Maßnahmen

+

Verknüpfte Risiken

- {% if control.controls.all %} + {% if control.risks %} - - - - + + + + - {% for control in control.controls.all %} - - - + {% for risk in control.risks.all %} + + +
TitelStatusFristVerantwortlicherLinkRisikoeignerKategorieAssetProzess
{{ control.title }}{{ control.get_status_display }}
{{ risk.title }} - {% if control.due_date %} - {{ control.due_date|date:"d.m.Y" }} + {% if risk.owner %} + {{ risk.owner }} {% else %} – {% endif %} - {% if control.responsible %} - {{ control.responsible.get_full_name|default:control.responsible.username }} + {% if risk.category %} + {{ risk.category }} {% else %} – {% endif %} - {% if control.wiki_link %} - 🔗 + {% if risk.asset %} + {{ risk.asset }} + {% else %} + – + {% endif %} + + {% if risk.process %} + {{ risk.process }} {% else %} – {% endif %} @@ -82,7 +86,7 @@
{% else %} -

Keine Maßnahmen erfasst.

+

Keine Verknüpften Risiken.

{% endif %}
diff --git a/templates/risks/item_incident.html b/templates/risks/item_incident.html index 48c5400..e9267ca 100644 --- a/templates/risks/item_incident.html +++ b/templates/risks/item_incident.html @@ -4,5 +4,126 @@
  • {{ incident.title }}
  • {% endblock %} {% block content %} +
    +
    +
    +

    Vorfall: {{ incident.title }}

    +

    {{ incident.description }}

    +
    +
    + +
    +
    +

    Überblick

    +
    + +
    +
    +
    +

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

    +

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

    +

    Status: {{ incident.status }}

    +
    +
    +

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

    +

    Aktualisiert am: {{ 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 %} +
    +
    + + + +
    +
    +

    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 diff --git a/templates/risks/item_risk.html b/templates/risks/item_risk.html index 7d28e00..080821c 100644 --- a/templates/risks/item_risk.html +++ b/templates/risks/item_risk.html @@ -40,7 +40,7 @@

    Kategorie: {{ risk.category|default:"-" }}

    Risikoeigner: {{ risk.owner|default:"-" }}

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

    -

    Aktualisiert am: {{ risk.updatet_at|date:'d.m.Y H:i' }}

    +

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

    diff --git a/templates/risks/list_incidents.html b/templates/risks/list_incidents.html index 23424ef..34f27b9 100644 --- a/templates/risks/list_incidents.html +++ b/templates/risks/list_incidents.html @@ -8,74 +8,86 @@

    Auswahl

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

    Vorfälle

    @@ -91,19 +103,25 @@ - - Switch entwendet + {% for i in incidents %} + + {{ i.title }} + {% if i.related_risks.exists %}
      -
    • - Hardware Diebstahl -
    • + {% for r in i.related_risks.all %} +
    • {{ r.title }}
    • + {% endfor %} + {% else %} + Noch kein Risiko zugeordnet + {% endif %}
    - Closed - 08.09.2025 - Kevin Heyer + {{ i.get_status_display }} + {{ i.date_reported|date:"d.m.Y" }} + {{ i.reported_by }} + {% endfor %}