diff --git a/TODO b/TODO index 3a5c5be..95b27bb 100644 --- a/TODO +++ b/TODO @@ -1,66 +1,12 @@ ✅Risiken -✅-Titel -✅-Asset -✅-Prozess -✅-Kategorie -✅-Eintrittswahrscheinlichkeit vor Maßnahmen -✅-Schadenshöhe vor Maßnahmen -✅-Score vor Maßnahmen (Berechnet sich aus Eintrittswahrscheinlichkeit und Schadenshöhe) -✅-Stufe vor Maßnahmen (Errechnet sich aus dem Score) -✅-Risikoeigner -✅-Maßnahmen (Ein Risiko kann mehrere Maßnahmen haben) -✅-Restrisiko -✅-Wiedervorlage -✅-Schutzziele - ✅Eintrittswahrscheinlichkeiten -✅-1, sehr gering, Voraussichtliches Auftreten seltener als einmal in 5 Jahren. -✅-2, gering, Voraussichtliches Auftreten einmal in 1 bis 5 Jahren. -✅-3, wahrscheinlich, Voraussichtliches Auftreten einmal pro Jahr oder häufiger. -✅-4, sehr wahrscheinlich, Voraussichtliches Auftreten mehrmals pro Jahr oder monatlich. - ✅Schadenshöhen -✅-1, niedrig, (z.B. Schaden < 1.000 €, geringer operativer Einfluss) -✅-2, mittel, (z.B. Schaden 1.000 € -5.000 €, lokaler Einfluss) -✅-3, hoch, (z.B. Schaden 5.000 € -15.000 €, Einfluss auf ein Team) -✅-4, erheblich, (z.B. Schaden 50.000 € -100.000 €, regionaler Einfluss) -✅-5, kritisch, (z.B. Schaden > 100.000 €, existenzbedrohend) - ✅Maßnahmen -✅-Titel -✅-Status -✅-Frist -✅-Verantwortlicher -✅-Beschreibung -✅-Wiki-Link - ✅Maßnahmenstatus -✅-Geplant, Die Maßnahme wurde identifiziert und im Risikoregister erfasst, die Umsetzung hat jedoch noch nicht begonnen. Dies ist der Ausgangsstatus für jede neue Maßnahme. -✅-In Bearbeitung, Die Umsetzung der Maßnahme hat begonnen. -✅-Abgeschlossen, Die Maßnahme wurde vollständig umgesetzt (Triggert Neubewertung durch Risikoeigner) -✅-Überprüft, Die Wirksamkeit der abgeschlossenen Maßnahme wurde verifiziert und bestätigt. -✅-Abgelehnt/Verworfen, Eine geplante Maßnahme wird nicht umgesetzt, weil sie entweder nicht mehr relevant ist, die Kosten zu hoch sind oder eine alternative, effektivere Maßnahme gefunden wurde. Dies muss gut dokumentiert und begründet werden. - ✅Restrisiko -✅-Risiko identifikation -✅-Eintrittswahrscheinlichkeit nach Maßnahmen -✅-Schadenshöhe nach Maßnahmen -✅-Score nach Maßnahmen (Berechnet sich aus Eintrittswahrscheinlichkeit und Schadenshöhe) -✅-Stufe nach Maßnahmen (Errechnet sich aus dem Score) - ✅Schutzziele (CIA) -✅-Verfügbarkeit -✅-Integrität -✅-Vertraulichkeit - ✅Benutzer -✅-Benutzer ist Risikoverantwortlicher -✅-Benutzer ist Maßnahmenverantwortlicher - ✅Audit -✅-Logging -✅-Audit-Trail - ✅Vorfälle Benachrichtigungen diff --git a/api/__pycache__/__init__.cpython-311.pyc b/api/__pycache__/__init__.cpython-311.pyc index 2fb33de..6ba2218 100644 Binary files a/api/__pycache__/__init__.cpython-311.pyc and b/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/api/__pycache__/views.cpython-311.pyc b/api/__pycache__/views.cpython-311.pyc index d70b87c..b7efb64 100644 Binary files a/api/__pycache__/views.cpython-311.pyc and b/api/__pycache__/views.cpython-311.pyc differ diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 5106d16..48eb40b 100644 Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 961dd0b..0cf6574 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 74a05be..85d305b 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index 979f373..b976614 100644 Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ diff --git a/db.sqlite3 b/db.sqlite3 index 6996ee4..c5adb26 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/risks/migrations/0009_risk_created_at_risk_updatet_at.py b/risks/migrations/0009_risk_created_at_risk_updatet_at.py new file mode 100644 index 0000000..c132c26 --- /dev/null +++ b/risks/migrations/0009_risk_created_at_risk_updatet_at.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.6 on 2025-09-08 09:15 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risks', '0008_rename_send_notification_sent'), + ] + + operations = [ + migrations.AddField( + model_name='risk', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='risk', + name='updatet_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/risks/migrations/0010_alter_residualrisk_impact_and_more.py b/risks/migrations/0010_alter_residualrisk_impact_and_more.py new file mode 100644 index 0000000..90ffa7a --- /dev/null +++ b/risks/migrations/0010_alter_residualrisk_impact_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.6 on 2025-09-08 09:44 + +import multiselectfield.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risks', '0009_risk_created_at_risk_updatet_at'), + ] + + operations = [ + migrations.AlterField( + model_name='residualrisk', + name='impact', + field=models.IntegerField(choices=[('1', 'Low (< 1,000 € – minor operational impact)'), ('2', 'Medium (1,000–5,000 € – local impact)'), ('3', 'High (5,000–15,000 € – team-level impact)'), ('4', 'Severe (50,000–100,000 € – regional impact)'), ('5', 'Critical (> 100,000 € – existential threat)')], default=1), + ), + migrations.AlterField( + model_name='residualrisk', + name='likelihood', + field=models.IntegerField(choices=[('1', 'Very low – occurs less than once every 5 years'), ('2', 'Low – once every 1–5 years'), ('3', 'Likely – once per year or more'), ('4', 'Very likely – multiple times per year/monthly')], default=1), + ), + migrations.AlterField( + model_name='risk', + name='cia', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('1', 'Confidentiality'), ('2', 'Integrity'), ('3', 'Availability')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='risk', + name='impact', + field=models.IntegerField(choices=[('1', 'Low (< 1,000 € – minor operational impact)'), ('2', 'Medium (1,000–5,000 € – local impact)'), ('3', 'High (5,000–15,000 € – team-level impact)'), ('4', 'Severe (50,000–100,000 € – regional impact)'), ('5', 'Critical (> 100,000 € – existential threat)')], default=1), + ), + migrations.AlterField( + model_name='risk', + name='likelihood', + field=models.IntegerField(choices=[('1', 'Very low – occurs less than once every 5 years'), ('2', 'Low – once every 1–5 years'), ('3', 'Likely – once per year or more'), ('4', 'Very likely – multiple times per year/monthly')], default=1), + ), + ] diff --git a/risks/migrations/0011_risk_description.py b/risks/migrations/0011_risk_description.py new file mode 100644 index 0000000..ef8025d --- /dev/null +++ b/risks/migrations/0011_risk_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-09-08 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risks', '0010_alter_residualrisk_impact_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='risk', + name='description', + field=models.TextField(blank=True, max_length=225, null=True), + ), + ] diff --git a/risks/migrations/0012_alter_residualrisk_impact_and_more.py b/risks/migrations/0012_alter_residualrisk_impact_and_more.py new file mode 100644 index 0000000..76b918c --- /dev/null +++ b/risks/migrations/0012_alter_residualrisk_impact_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.6 on 2025-09-08 09:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risks', '0011_risk_description'), + ] + + operations = [ + migrations.AlterField( + model_name='residualrisk', + name='impact', + field=models.IntegerField(choices=[(1, 'Low (< 1,000 € – minor operational impact)'), (2, 'Medium (1,000–5,000 € – local impact)'), (3, 'High (5,000–15,000 € – team-level impact)'), (4, 'Severe (50,000–100,000 € – regional impact)'), (5, 'Critical (> 100,000 € – existential threat)')], default=1), + ), + migrations.AlterField( + model_name='residualrisk', + name='likelihood', + field=models.IntegerField(choices=[(1, 'Very low – occurs less than once every 5 years'), (2, 'Low – once every 1–5 years'), (3, 'Likely – once per year or more'), (4, 'Very likely – multiple times per year/monthly')], default=1), + ), + migrations.AlterField( + model_name='risk', + name='impact', + field=models.IntegerField(choices=[(1, 'Low (< 1,000 € – minor operational impact)'), (2, 'Medium (1,000–5,000 € – local impact)'), (3, 'High (5,000–15,000 € – team-level impact)'), (4, 'Severe (50,000–100,000 € – regional impact)'), (5, 'Critical (> 100,000 € – existential threat)')], default=1), + ), + migrations.AlterField( + model_name='risk', + name='likelihood', + field=models.IntegerField(choices=[(1, 'Very low – occurs less than once every 5 years'), (2, 'Low – once every 1–5 years'), (3, 'Likely – once per year or more'), (4, 'Very likely – multiple times per year/monthly')], default=1), + ), + ] diff --git a/risks/models.py b/risks/models.py index 09509ee..ced4069 100644 --- a/risks/models.py +++ b/risks/models.py @@ -40,16 +40,19 @@ class Risk(models.Model): ] CIA_CHOICES = [ - (1, "Confidentiality"), - (2, "Integrity"), - (3, "Availability") + ("1", "Confidentiality"), + ("2", "Integrity"), + ("3", "Availability") ] # Basic information title = models.CharField(max_length=255) + description = models.TextField(max_length=225, blank=True, null=True) asset = models.CharField(max_length=255, blank=True, null=True) 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) # CIA Protection Goals cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True) diff --git a/risks/serializers.py b/risks/serializers.py index 2f065ce..0570c3e 100644 --- a/risks/serializers.py +++ b/risks/serializers.py @@ -43,6 +43,8 @@ class RiskSerializer(serializers.ModelSerializer): "asset", "process", "category", + "created_at", + "updatet_at", "likelihood", "impact", "score", diff --git a/risks/signals.py b/risks/signals.py index 89b57c5..c8ccef2 100644 --- a/risks/signals.py +++ b/risks/signals.py @@ -1,41 +1,50 @@ +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 .models import Control, Risk, ResidualRisk, AuditLog, Incident from .utils import model_diff -@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. - """ - - 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() +# --------------------------------------------------------------------------- +# General definitions +# --------------------------------------------------------------------------- +def serialize_value(value): + if isinstance(value, Model): + return value.pk # oder str(value), wenn du mehr Infos willst + if isinstance(value, (datetime, date)): + return value.isoformat() + return value +# --------------------------------------------------------------------------- +# Risks +# --------------------------------------------------------------------------- @receiver(post_save, sender=Risk) def log_risk_save(sender, instance, created, **kwargs): + + if created: AuditLog.objects.create( user=getattr(instance, "_changed_by", None), action="create", model="Risk", 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 = 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", @@ -57,6 +66,9 @@ def log_risk_delete(sender, instance, **kwargs): changes=None, # no fields to track on deletion ) +# --------------------------------------------------------------------------- +# Controls +# --------------------------------------------------------------------------- @receiver(post_save, sender=Control) def log_control_save(sender, instance, created, **kwargs): @@ -66,13 +78,22 @@ def log_control_save(sender, instance, created, **kwargs): action="create", model="Control", 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 = 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() + } AuditLog.objects.create( user=getattr(instance, "_changed_by", None), action="update", @@ -91,6 +112,26 @@ def log_control_delete(sender, instance, **kwargs): 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. + """ + + 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() + +# --------------------------------------------------------------------------- +# Residual risks +# --------------------------------------------------------------------------- @receiver(post_save, sender=ResidualRisk) def log_residual_save(sender, instance, created, **kwargs): @@ -100,19 +141,28 @@ def log_residual_save(sender, instance, created, **kwargs): action="create", model="ResidualRisk", 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 = 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=changes, + changes=clean_changes, ) @receiver(post_delete, sender=ResidualRisk) @@ -125,6 +175,9 @@ def log_residual_delete(sender, instance, **kwargs): changes=None, ) +# --------------------------------------------------------------------------- +# Incidents +# --------------------------------------------------------------------------- @receiver(post_save, sender=Incident) def log_incident_save(sender, instance, created, **kwargs): @@ -141,6 +194,10 @@ def log_incident_save(sender, instance, created, **kwargs): 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", diff --git a/risks/templatetags/___init__.py b/risks/templatetags/___init__.py new file mode 100644 index 0000000..e69de29 diff --git a/risks/templatetags/risk_extras.py b/risks/templatetags/risk_extras.py new file mode 100644 index 0000000..ed5945d --- /dev/null +++ b/risks/templatetags/risk_extras.py @@ -0,0 +1,9 @@ +from django import template +from ..models import Risk + +register = template.Library() + +@register.filter +def cia_label(value): + mapping = dict(Risk.CIA_CHOICES) + return mapping.get(value, value) diff --git a/risks/urls.py b/risks/urls.py index fdee6b0..6fbc91a 100644 --- a/risks/urls.py +++ b/risks/urls.py @@ -7,7 +7,10 @@ urlpatterns = [ path("", views.dashboard, name="dashboard"), path("risks/index", views.dashboard, name="index"), path("risks/stats", views.stats, name="statistics"), - path("risks/risks", views.risks, name="risks"), - path("risks/controls", views.controls, name="controls"), - path("risks/incidents", views.incidents, name="incidents"), + 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"), ] \ No newline at end of file diff --git a/risks/views.py b/risks/views.py index 9c810b0..0b1a62d 100644 --- a/risks/views.py +++ b/risks/views.py @@ -1,10 +1,14 @@ +from django.contrib.admin.models import LogEntry from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from .models import Risk, Control, ResidualRisk, AuditLog, Incident from .serializers import ControlSerializer, RiskSerializer, ResidualRiskSerializer, UserSerializer, AuditSerializer, IncidentSerializer +User = get_user_model() + # --------------------------------------------------------------------------- # API # --------------------------------------------------------------------------- @@ -51,8 +55,6 @@ class ResidualRiskViewSet(viewsets.ModelViewSet): serializer_class = ResidualRiskSerializer permission_classes = [IsAuthenticated] -User = get_user_model() - class UserViewSet(viewsets.ReadOnlyModelViewSet): """ API endpoint for listing users and their responsibilities. @@ -107,11 +109,85 @@ def dashboard(request): def stats(request): return render(request, "risks/statistics.html") -def risks(request): - return render(request, "risks/list_risks.html") +def list_risks(request): + qs = Risk.objects.all().select_related("owner") -def controls(request): - return render(request, "risks/list_controls.html") + # GET-Parameter lesen + risk_id = request.GET.get("risk") + control_id = request.GET.get("control") + owner_id = request.GET.get("owner") -def incidents(request): - return render(request, "risks/list_incidents.html") \ No newline at end of file + 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) + + risks = qs.order_by("title").distinct() + + controls = Control.objects.all().order_by("title") + owners = User.objects.filter(owned_risks__isnull=False).distinct().order_by("username") + + return render(request, "risks/list_risks.html", { + "risks": risks, + "controls": controls, + "owners": owners, + }) + +def show_risk(request, id): + risk = get_object_or_404(Risk, 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}) + +def list_controls(request): + qs = Control.objects.all().select_related("risk", "responsible") + + # Filter + 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(risk_id=risk_id) + if status: + qs = qs.filter(status=status) + if responsible_id: + qs = qs.filter(responsible_id=responsible_id) + + controls = qs.order_by("title") + + 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, + "status_choices": Control.STATUS_CHOICES, + }) + +def show_control(request, id): + 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") + + return render(request, "risks/item_control.html", {"control": control, "logs": logs}) + +def list_incidents(request): + return render(request, "risks/list_incidents.html") + +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 diff --git a/static/css/design.css b/static/css/design.css index 359ea4a..a24aa1f 100644 --- a/static/css/design.css +++ b/static/css/design.css @@ -42,4 +42,20 @@ .home-icon { font-size: 20px; display: inline-block; margin: 10px; } /* Dropdown optisch näher am Screenshot */ -.navbar-dropdown { border-top: none; box-shadow: 0 8px 16px rgba(0,0,0,.1); } \ No newline at end of file +.navbar-dropdown { border-top: none; box-shadow: 0 8px 16px rgba(0,0,0,.1); } + +/* Breadcrumbs */ +.top-breadcrumb { padding: 10px 0;} + +.breadcrumb { + margin-bottom: 20px; + background-color: #f0ebeb; +} + +.content li{ + margin-top: 5px !important; +} + +.content li+li { + margin: 0 !important; +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index b31b9c5..6112d79 100644 --- a/templates/base.html +++ b/templates/base.html @@ -14,7 +14,7 @@