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.
This commit is contained in:
Kevin Heyer 2025-09-12 13:04:04 +02:00
parent 66e53e171e
commit f7ead4e5c3
24 changed files with 1313 additions and 1333 deletions

1
.gitignore vendored
View file

@ -27,7 +27,6 @@ var/
db.sqlite3 db.sqlite3
media/ media/
staticfiles/ staticfiles/
static/
# If you are using WhiteNoise for static file management # If you are using WhiteNoise for static file management
static_root/ static_root/

Binary file not shown.

View file

@ -1,125 +1,150 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 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 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_header = _("Administration")
admin.site.site_title = _("Admin") admin.site.site_title = _("Admin")
admin.site.index_title = _("Administration") admin.site.index_title = _("Administration")
# ---- Inlines ----
class NotificationPreferenceInline(admin.StackedInline): class NotificationPreferenceInline(admin.StackedInline):
"""Preferences inline for notifications on User model"""
model = NotificationPreference model = NotificationPreference
can_delete = False can_delete = False
extra = 0 extra = 0
fieldsets = ( fieldsets = (
(_("Risks"), {"fields": ("risk_created","risk_updated","risk_deleted")}), (_("Risks"), {"fields": ("risk_created", "risk_updated", "risk_deleted")}),
(_("Controls"), {"fields": ("control_created","control_updated","control_deleted")}), (_("Controls"), {"fields": ("control_created", "control_updated", "control_deleted")}),
(_("Residual risks"), {"fields": ("residual_created","residual_updated","residual_deleted")}), (_("Residual risks"), {"fields": ("residual_created", "residual_updated", "residual_deleted")}),
(_("Reviews"), {"fields": ("review_required","review_completed")}), (_("Reviews"), {"fields": ("review_required", "review_completed")}),
(_("Incidents"), {"fields": ("incident_created","incident_updated","incident_deleted")}), (_("Incidents"), {"fields": ("incident_created", "incident_updated", "incident_deleted")}),
(_("Users"), {"fields": ("user_created","user_deleted")}), (_("Users"), {"fields": ("user_created", "user_deleted")}),
) )
class ResidualRiskInline(admin.StackedInline): class ResidualRiskInline(admin.StackedInline):
""" """Inline editor for ResidualRisk (one-to-one with Risk)"""
Inline editor for ResidualRisk, linked one-to-one with Risk
"""
model = ResidualRisk model = ResidualRisk
extra = 0 extra = 0
can_delete = False can_delete = False
readonly_fields = ("score", "level", "review_required") readonly_fields = ("score", "level", "review_required")
fields = ("likelihood", "impact", "score", "level", "review_required") fields = ("likelihood", "impact", "score", "level", "review_required")
class ControlRisksInline(admin.TabularInline): class ControlRisksInline(admin.TabularInline):
"""M2M relation between Risk and Control"""
model = Control.risks.through model = Control.risks.through
fk_name = "risk" fk_name = "risk"
extra = 1 extra = 1
autocomplete_fields = ("control",) 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) @admin.register(Risk)
class RiskAdmin(admin.ModelAdmin): class RiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
list_display = ( redirect_url_name = "risks:show_risk"
"title", list_display = ("title", "owner_name", "status", "score", "level", "likelihood", "impact", "follow_up")
"owner_name", list_filter = ("status", "level", "likelihood", "impact", "owner")
"status", search_fields = ("title", "asset", "process", "category")
"score", inlines = [ResidualRiskInline, ControlRisksInline]
"level",
"likelihood",
"impact",
"follow_up",
)
def owner_name(self, obj): def owner_name(self, obj):
if not obj.owner: if not obj.owner:
return "-" return "-"
return obj.owner.get_full_name() or obj.owner.username 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) @admin.register(ResidualRisk)
class ResidualRiskAdmin(admin.ModelAdmin): class ResidualRiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
list_display = ( redirect_url_name = "risks:show_risk"
"risk", list_display = ("risk", "score", "level", "likelihood", "impact", "review_required")
"score",
"level",
"likelihood",
"impact",
"review_required"
)
list_filter = ("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) @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_display = ("title", "status", "due_date", "responsible")
list_filter = ("status", "due_date") list_filter = ("status", "due_date")
autocomplete_fields = ("risks", "responsible",) autocomplete_fields = ("risks", "responsible")
search_fields = ("title", "description") 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) @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_display = ("title", "date_reported", "reported_by", "status")
list_filter = ("status", "date_reported", "reported_by") list_filter = ("status", "date_reported", "reported_by")
filter_horizontal = ("related_risks",)
search_fields = ("title", "description") search_fields = ("title", "description")
autocomplete_fields = ("related_risks",) 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) @admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin): class NotificationAdmin(admin.ModelAdmin):
date_hierarchy = "created_at" date_hierarchy = "created_at"
list_display = ("id", "created_at", "user_display", "short_message", "read", "sent") list_display = ("id", "created_at", "user_display", "short_message", "read", "sent")
list_display_links = ("id", "short_message") list_display_links = ("id", "short_message")
@ -129,21 +154,18 @@ class NotificationAdmin(admin.ModelAdmin):
list_editable = ("read", "sent") list_editable = ("read", "sent")
ordering = ("-created_at",) ordering = ("-created_at",)
autocomplete_fields = ("user",) autocomplete_fields = ("user",)
actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"]
@admin.display(description=_("User")) @admin.display(description=_("User"))
def user_display(self, obj): def user_display(self, obj):
if not obj.user: return obj.user.get_full_name() if obj.user else ""
return ""
return obj.user.get_full_name() or obj.user.username
@admin.display(description=_("Message")) @admin.display(description=_("Message"))
def short_message(self, obj): def short_message(self, obj):
msg = obj.message or "" msg = obj.message or ""
return (msg[:80] + "") if len(msg) > 80 else msg return (msg[:80] + "") if len(msg) > 80 else msg
# Bulk-Aktionen # Bulk actions
actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"]
@admin.action(description=_("Mark selected as read")) @admin.action(description=_("Mark selected as read"))
def mark_as_read(self, request, queryset): def mark_as_read(self, request, queryset):
n = queryset.update(read=True) n = queryset.update(read=True)
@ -164,13 +186,8 @@ class NotificationAdmin(admin.ModelAdmin):
n = queryset.update(sent=False) n = queryset.update(sent=False)
self.message_user(request, _("%(n)d notifications marked as unsent.") % {"n": n}) 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) @admin.register(NotificationRule)
class NotificationRuleAdmin(admin.ModelAdmin): class NotificationRuleAdmin(admin.ModelAdmin):
list_display = ("kind", "enabled_in_app", "enabled_email", "to_owner", "to_staff", "short_extras") 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", ", ") txt = (obj.extra_recipients or "").replace("\n", ", ")
return (txt[:50] + "") if len(txt) > 50 else txt return (txt[:50] + "") if len(txt) > 50 else txt
# ---------------------------------------------------------------------------
# User (extension)
# ---------------------------------------------------------------------------
@admin.register(User) @admin.register(User)
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + ( fieldsets = BaseUserAdmin.fieldsets + (
(_("SSO Information"), {"fields": ("is_sso_user",)}), (_("SSO Information"), {"fields": ("is_sso_user",)}),
) )
list_display = ("username", "email", "is_staff", "is_superuser", "is_sso_user", list_display = (
"owned_risks_count", "responsible_controls_count") "username", "email", "is_staff", "is_superuser", "is_sso_user",
"owned_risks_count", "responsible_controls_count"
)
inlines = [NotificationInline, NotificationPreferenceInline] inlines = [NotificationInline, NotificationPreferenceInline]
def owned_risks_count(self, obj): def owned_risks_count(self, obj):

View file

@ -1,20 +1,34 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# ---------------------------------------------------------------------------
# Risks AppConfig
# ---------------------------------------------------------------------------
class RisksConfig(AppConfig): class RisksConfig(AppConfig):
"""App configuration for the risks module."""
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "risks" name = "risks"
verbose_name = _("Risk Management") verbose_name = _("Risk Management")
def ready(self): 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: try:
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from .models import NotificationRule, NotificationKind from .models import NotificationRule, NotificationKind
# Test DB availability
NotificationRule.objects.count() NotificationRule.objects.count()
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
# Happens during migrate or before tables exist
return return
# Ensure all NotificationKind values have a corresponding NotificationRule
existing = set(NotificationRule.objects.values_list("kind", flat=True)) existing = set(NotificationRule.objects.values_list("kind", flat=True))
for kind, _label in NotificationKind.choices: for kind, _label in NotificationKind.choices:
if kind not in existing: if kind not in existing:

View file

@ -1,8 +1,22 @@
import threading import threading
# ---------------------------------------------------------------------------
# Thread-local storage for current user
# ---------------------------------------------------------------------------
_local = threading.local() _local = threading.local()
# ---------------------------------------------------------------------------
# set_current_user()
# ---------------------------------------------------------------------------
def set_current_user(user): def set_current_user(user):
"""Store the current user in thread-local storage."""
_local.user = user _local.user = user
# ---------------------------------------------------------------------------
# get_current_user()
# ---------------------------------------------------------------------------
def get_current_user(): def get_current_user():
"""Retrieve the current user from thread-local storage (or None)."""
return getattr(_local, "user", None) return getattr(_local, "user", None)

View file

@ -1,7 +1,14 @@
# ---------------------------------------------------------------------------
# unread_notifications_count()
# ---------------------------------------------------------------------------
def unread_notifications_count(request): def unread_notifications_count(request):
"""
Context processor:
Returns the number of unread notifications for the current user.
"""
if not request.user.is_authenticated: if not request.user.is_authenticated:
return {"notifications_unread_count": 0} return {"notifications_unread_count": 0}
from .models import Notification from .models import Notification
return { count = Notification.objects.filter(user=request.user, read=False).count()
"notifications_unread_count": Notification.objects.filter(user=request.user, read=False).count() return {"notifications_unread_count": count}
}

View file

@ -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

View file

@ -2,27 +2,45 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Risk, Control, Incident, ResidualRisk 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: class Meta:
fields = ["status"]
labels = {"status": _("Status")}
widgets = {"status": forms.Select(attrs={"class": "select"})}
# ---------------------------------------------------------------------------
# RiskStatusForm
# ---------------------------------------------------------------------------
class RiskStatusForm(BaseStatusForm):
class Meta(BaseStatusForm.Meta):
model = Risk 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 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 model = Incident
fields = ["status"]
labels = {"status": _("Status")}
widgets = {"status": forms.Select(attrs={"class": "select"})}
# ---------------------------------------------------------------------------
# ResidualReviewForm
# ---------------------------------------------------------------------------
class ResidualReviewForm(forms.ModelForm): class ResidualReviewForm(forms.ModelForm):
class Meta: class Meta:
model = ResidualRisk model = ResidualRisk

View file

@ -1,9 +1,18 @@
from .audit_context import set_current_user from .audit_context import set_current_user
# ---------------------------------------------------------------------------
# AuditUserMiddleware
# ---------------------------------------------------------------------------
class 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): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
# Save current user for this request in thread-local storage
set_current_user(getattr(request, "user", None)) set_current_user(getattr(request, "user", None))
return self.get_response(request) return self.get_response(request)

View file

@ -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),
),
]

View file

@ -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 datetime
import json 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): class SafeJSONEncoder(DjangoJSONEncoder):
"""JSON encoder that can handle datetime.date properly."""
def default(self, obj): def default(self, obj):
if isinstance(obj, datetime.date): if isinstance(obj, datetime.date):
return obj.isoformat() return obj.isoformat()
return super().default(obj) return super().default(obj)
# ---------------------------------------------------------------------------
# User
# ---------------------------------------------------------------------------
class User(AbstractUser): 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) is_sso_user = models.BooleanField(default=False)
@property @property
def risks_owned(self): 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() return self.owned_risks.all()
@property @property
def controls_responsible(self): def controls_responsible(self):
""" All controls where the user is responsible. """ """All controls where the user is responsible."""
return self.responsible_controls.all() return self.responsible_controls.all()
# ---------------------------------------------------------------------------
# Risk
# ---------------------------------------------------------------------------
class Risk(models.Model): class Risk(models.Model):
class Meta: class Meta:
@ -81,14 +95,8 @@ class Risk(models.Model):
cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True) cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True)
# Risk evaluation before controls # Risk evaluation before controls
likelihood = models.IntegerField( likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES, default=1)
choices=LIKELIHOOD_CHOICES, impact = models.IntegerField(choices=IMPACT_CHOICES, default=1)
default=1
)
impact = models.IntegerField(
choices=IMPACT_CHOICES,
default=1
)
# Calculated fields # Calculated fields
score = models.IntegerField(editable=False) score = models.IntegerField(editable=False)
@ -106,10 +114,8 @@ class Risk(models.Model):
follow_up = models.DateField(blank=True, null=True) follow_up = models.DateField(blank=True, null=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Calculate risk score # Calculate risk score and level
self.score = self.likelihood * self.impact self.score = self.likelihood * self.impact
# Determine level based on score
if self.score <= 4: if self.score <= 4:
self.level = "Low" self.level = "Low"
elif self.score <= 8: elif self.score <= 8:
@ -118,55 +124,40 @@ class Risk(models.Model):
self.level = "High" self.level = "High"
else: else:
self.level = "Critical" self.level = "Critical"
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return f"{self.title} (Score: {self.score}, Level: {self.level})" return f"{self.title} (Score: {self.score}, Level: {self.level})"
# ---------------------------------------------------------------------------
# Residual Risk
# ---------------------------------------------------------------------------
class ResidualRisk(models.Model): class ResidualRisk(models.Model):
""" """Residual risk after implementing controls."""
Residual Risk after implementing controls
"""
class Meta: class Meta:
verbose_name = _("Residual Risk") verbose_name = _("Residual Risk")
verbose_name_plural = _("Residual Risks") verbose_name_plural = _("Residual Risks")
risk = models.OneToOneField( risk = models.OneToOneField(Risk, on_delete=models.CASCADE, related_name="residual_risk")
Risk, likelihood = models.IntegerField(choices=Risk.LIKELIHOOD_CHOICES, default=1)
on_delete=models.CASCADE, impact = models.IntegerField(choices=Risk.IMPACT_CHOICES, default=1)
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) score = models.IntegerField(editable=False)
level = models.CharField(max_length=50, editable=False) level = models.CharField(max_length=50, editable=False)
review_required = models.BooleanField(default=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) updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Load previous state (if it exists) # Mark for review if likelihood/impact changed
if self.pk: if self.pk:
old = ResidualRisk.objects.get(pk=self.pk) old = ResidualRisk.objects.get(pk=self.pk)
if old.likelihood != self.likelihood or old.impact != self.impact: if old.likelihood != self.likelihood or old.impact != self.impact:
self.review_required = True self.review_required = True
# Calculate residual risk score and level
self.score = self.likelihood * self.impact self.score = self.likelihood * self.impact
# Determine level based on score
if self.score <= 4: if self.score <= 4:
self.level = "Low" self.level = "Low"
elif self.score <= 8: elif self.score <= 8:
@ -181,10 +172,13 @@ class ResidualRisk(models.Model):
def __str__(self): def __str__(self):
return f"Residual Risk for {self.risk.title} (Score: {self.score}, Level: {self.level})" return f"Residual Risk for {self.risk.title} (Score: {self.score}, Level: {self.level})"
# ---------------------------------------------------------------------------
# Control
# ---------------------------------------------------------------------------
class Control(models.Model): class Control(models.Model):
""" """Security control/measure linked to a risk."""
A security control/measure linked to a risk.
"""
class Meta: class Meta:
verbose_name = _("Control") verbose_name = _("Control")
verbose_name_plural = _("Controls") verbose_name_plural = _("Controls")
@ -208,19 +202,21 @@ class Control(models.Model):
) )
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
wiki_link = models.URLField(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) updated_at = models.DateTimeField(auto_now=True)
# Relation to risk # Relation to risk
risks = models.ManyToManyField("Risk", related_name="controls", blank=True) risks = models.ManyToManyField(Risk, related_name="controls", blank=True)
def __str__(self): def __str__(self):
return f"{self.title} ({self.get_status_display()})" return f"{self.title} ({self.get_status_display()})"
# ---------------------------------------------------------------------------
# AuditLog
# ---------------------------------------------------------------------------
class AuditLog(models.Model): class AuditLog(models.Model):
""" """Generic audit log entry for tracking changes."""
Generic audit log entry for tracking changes.
"""
class Meta: class Meta:
verbose_name = _("Auditlog") verbose_name = _("Auditlog")
@ -238,7 +234,6 @@ class AuditLog(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="audit_logs" related_name="audit_logs"
) )
action = models.CharField(max_length=10, choices=ACTION_CHOICES) action = models.CharField(max_length=10, choices=ACTION_CHOICES)
model = models.CharField(max_length=100) model = models.CharField(max_length=100)
object_id = models.CharField(max_length=50) object_id = models.CharField(max_length=50)
@ -248,10 +243,12 @@ class AuditLog(models.Model):
def __str__(self): def __str__(self):
return f"[{self.timestamp}] {self.user} {self.action} {self.model}({self.object_id})" return f"[{self.timestamp}] {self.user} {self.action} {self.model}({self.object_id})"
# ---------------------------------------------------------------------------
# Incident
# ---------------------------------------------------------------------------
class Incident(models.Model): class Incident(models.Model):
""" """Incidents and related risks."""
Incidents and related risks
"""
class Meta: class Meta:
verbose_name = _("Incident") verbose_name = _("Incident")
@ -262,36 +259,28 @@ class Incident(models.Model):
("in_progress", _("In Progress")), ("in_progress", _("In Progress")),
("closed", _("Closed")), ("closed", _("Closed")),
] ]
title = models.CharField(_("Title"), max_length=255) title = models.CharField(_("Title"), max_length=255)
description = models.TextField(_("Description"), blank=True, null=True) description = models.TextField(_("Description"), blank=True, null=True)
date_reported = models.DateField(_("Date reported"), blank=True, null=True) date_reported = models.DateField(_("Date reported"), blank=True, null=True)
reported_by = models.ForeignKey( reported_by = models.ForeignKey(
settings.AUTH_USER_MODEL, verbose_name=_("Reported by"), settings.AUTH_USER_MODEL,
null=True, blank=True, on_delete=models.SET_NULL, related_name="incidents" 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) status = models.CharField(max_length=12, choices=STATUS_CHOICES)
related_risks = models.ManyToManyField("Risk", blank=True, related_name="incidents") related_risks = models.ManyToManyField(Risk, blank=True, related_name="incidents")
created_at = models.DateTimeField(auto_now_add=True,) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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): class NotificationKind(models.TextChoices):
"""Event types for notifications."""
RISK_CREATED = "risk.created", _("Risk created") RISK_CREATED = "risk.created", _("Risk created")
RISK_UPDATED = "risk.updated", _("Risk updated") RISK_UPDATED = "risk.updated", _("Risk updated")
RISK_DELETED = "risk.deleted", _("Risk deleted") RISK_DELETED = "risk.deleted", _("Risk deleted")
@ -315,10 +304,57 @@ class NotificationKind(models.TextChoices):
USER_CREATED = "user.created", _("User created") USER_CREATED = "user.created", _("User created")
USER_DELETED = "user.deleted", _("User deleted") 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): class NotificationPreference(models.Model):
""" """User-specific notification preferences."""
Wich events does the user want to receive as notifications?
"""
user = models.OneToOneField( user = models.OneToOneField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -361,12 +397,16 @@ class NotificationPreference(models.Model):
return f"Prefs({self.user})" return f"Prefs({self.user})"
def should_notify(self, event_code: str) -> bool: 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)) return bool(getattr(self, event_code, False))
# ---------------------------------------------------------------------------
# NotificationRule
# ---------------------------------------------------------------------------
class NotificationRule(models.Model): class NotificationRule(models.Model):
""" """Global rules: Which events trigger in-app and/or email notifications."""
Global Rules: Wich Event sends In-App- and/or Mail-Notifications?
"""
class Meta: class Meta:
verbose_name = _("Notification rule") verbose_name = _("Notification rule")
verbose_name_plural = _("Notification rules") verbose_name_plural = _("Notification rules")
@ -380,18 +420,15 @@ class NotificationRule(models.Model):
enabled_in_app = models.BooleanField(_("Show in app"), default=True) enabled_in_app = models.BooleanField(_("Show in app"), default=True)
enabled_email = models.BooleanField(_("Send via email"), default=False) enabled_email = models.BooleanField(_("Send via email"), default=False)
# Empfängerkreise # Recipient groups
to_owner = models.BooleanField( to_owner = models.BooleanField(
_("Send to owner/responsible/reporter (if available)"), _("Send to owner/responsible/reporter (if available)"),
default=True default=True,
)
to_staff = models.BooleanField(
_("Send to all staff"),
default=False
) )
to_staff = models.BooleanField(_("Send to all staff"), default=False)
extra_recipients = models.TextField( extra_recipients = models.TextField(
_("Extra recipients (emails, comma or newline separated)"), _("Extra recipients (emails, comma or newline separated)"),
blank=True blank=True,
) )
def __str__(self): def __str__(self):

View file

@ -2,6 +2,10 @@ from django.contrib.auth import get_user_model
from rest_framework import serializers from rest_framework import serializers
from .models import Risk, Control, ResidualRisk, AuditLog, Incident from .models import Risk, Control, ResidualRisk, AuditLog, Incident
# ---------------------------------------------------------------------------
# ResidualRiskSerializer
# ---------------------------------------------------------------------------
class ResidualRiskSerializer(serializers.ModelSerializer): class ResidualRiskSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ResidualRisk model = ResidualRisk
@ -17,6 +21,9 @@ class ResidualRiskSerializer(serializers.ModelSerializer):
read_only_fields = ["score", "level"] read_only_fields = ["score", "level"]
# ---------------------------------------------------------------------------
# ControlSerializer
# ---------------------------------------------------------------------------
class ControlSerializer(serializers.ModelSerializer): class ControlSerializer(serializers.ModelSerializer):
risks = serializers.PrimaryKeyRelatedField(many=True, queryset=Risk.objects.all()) risks = serializers.PrimaryKeyRelatedField(many=True, queryset=Risk.objects.all())
@ -35,8 +42,12 @@ class ControlSerializer(serializers.ModelSerializer):
"risks", "risks",
] ]
# ---------------------------------------------------------------------------
# RiskSerializer
# ---------------------------------------------------------------------------
class RiskSerializer(serializers.ModelSerializer): class RiskSerializer(serializers.ModelSerializer):
# Nested representation of related controls # Nested representation of related controls (read-only)
controls = ControlSerializer(many=True, read_only=True) controls = ControlSerializer(many=True, read_only=True)
class Meta: class Meta:
@ -60,6 +71,10 @@ class RiskSerializer(serializers.ModelSerializer):
"controls", "controls",
] ]
# ---------------------------------------------------------------------------
# AuditSerializer
# ---------------------------------------------------------------------------
class AuditSerializer(serializers.ModelSerializer): class AuditSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = AuditLog model = AuditLog
@ -73,6 +88,10 @@ class AuditSerializer(serializers.ModelSerializer):
"timestamp", "timestamp",
] ]
# ---------------------------------------------------------------------------
# UserSerializer
# ---------------------------------------------------------------------------
User = get_user_model() User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@ -90,11 +109,19 @@ class UserSerializer(serializers.ModelSerializer):
"controls_responsible", "controls_responsible",
] ]
# ---------------------------------------------------------------------------
# RiskSummarySerializer
# ---------------------------------------------------------------------------
class RiskSummarySerializer(serializers.ModelSerializer): class RiskSummarySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Risk model = Risk
fields = ["id", "title", "score", "level"] fields = ["id", "title", "score", "level"]
# ---------------------------------------------------------------------------
# IncidentSerializer
# ---------------------------------------------------------------------------
class IncidentSerializer(serializers.ModelSerializer): class IncidentSerializer(serializers.ModelSerializer):
related_risks = serializers.PrimaryKeyRelatedField( related_risks = serializers.PrimaryKeyRelatedField(
many=True, queryset=Risk.objects.all() many=True, queryset=Risk.objects.all()
@ -106,11 +133,18 @@ class IncidentSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Incident model = Incident
fields = [ fields = [
"id", "title", "description", "date_reported", "id",
"created_at", "updated_at", "status", "related_risks", "title",
"description",
"date_reported",
"created_at",
"updated_at",
"status",
"related_risks",
] ]
def create(self, validated_data): def create(self, validated_data):
"""Ensure related_risks are set after creation."""
risks = validated_data.pop("related_risks", []) risks = validated_data.pop("related_risks", [])
obj = super().create(validated_data) obj = super().create(validated_data)
if risks: if risks:
@ -118,6 +152,7 @@ class IncidentSerializer(serializers.ModelSerializer):
return obj return obj
def update(self, instance, validated_data): def update(self, instance, validated_data):
"""Ensure related_risks are updated properly."""
risks = validated_data.pop("related_risks", None) risks = validated_data.pop("related_risks", None)
obj = super().update(instance, validated_data) obj = super().update(instance, validated_data)
if risks is not None: if risks is not None:

View file

@ -4,24 +4,33 @@ from django.db.models import Model
from django.db.models.signals import post_save, post_delete, m2m_changed from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .audit_context import get_current_user 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 from .utils import model_diff, notify_event
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# General definitions # General definitions & helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
User = get_user_model() User = get_user_model()
def serialize_value(value): def serialize_value(value):
"""Serialize values for audit log (pk/isoformat)."""
if isinstance(value, Model): if isinstance(value, Model):
return value.pk # oder str(value), wenn du mehr Infos willst return value.pk
if isinstance(value, (datetime, date)): if isinstance(value, (datetime, date)):
return value.isoformat() return value.isoformat()
return value return value
def _pref(user: User) -> NotificationPreference | None: def _pref(user: User) -> NotificationPreference | None:
"""Ensure NotificationPreference exists for user."""
if not user: if not user:
return None return None
pref = getattr(user, "notification_preference", None) pref = getattr(user, "notification_preference", None)
@ -29,394 +38,304 @@ def _pref(user: User) -> NotificationPreference | None:
pref = NotificationPreference.objects.create(user=user) pref = NotificationPreference.objects.create(user=user)
return pref return pref
def _notify(users, message: str, event_code: str): 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)): for u in set(filter(None, users)):
pref = _pref(u) pref = _pref(u)
if pref and pref.should_notify(event_code): if pref and pref.should_notify(event_code):
Notification.objects.create(user=u, message=message) Notification.objects.create(user=u, message=message)
def _risk_stakeholders(risk: Risk): def _risk_stakeholders(risk: Risk):
"""Risikoeigner + alle Verantwortlichen zugehöriger Controls.""" """Return risk owner + all control responsibles."""
owners = [risk.owner] if risk.owner else [] owners = [risk.owner] if risk.owner else []
responsibles = list( responsibles = list(
User.objects.filter(responsible_controls__risks=risk).distinct() User.objects.filter(responsible_controls__risks=risk).distinct()
) )
return set(owners + responsibles) return set(owners + responsibles)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Incidents # User
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def user_saved(sender, instance: User, created, **kwargs): def user_saved(sender, instance: User, created, **kwargs):
# Prefs automatisch anlegen """Auto-create prefs + notify staff."""
_pref(instance) _pref(instance)
# An Staff, die dieses Event wollen
if created: 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") _notify(staff, _("User '{u}' created").format(u=instance.username), "user_created")
@receiver(post_delete, sender=User) @receiver(post_delete, sender=User)
def user_deleted(sender, instance: User, **kwargs): 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") _notify(staff, _("User '{u}' deleted").format(u=instance.username), "user_deleted")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Risks # Risks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@receiver(post_save, sender=Risk) @receiver(post_save, sender=Risk)
def risk_saved(sender, instance: Risk, created, **kwargs): def risk_saved(sender, instance: Risk, created, **kwargs):
event = "risk_created" if created else "risk_updated" """Audit + notify on create/update."""
msg = _("Risk '{title}' {state}").format( user = getattr(instance, "_changed_by", None)
title=instance.title,
state=_("created") if created else _("updated"),
)
_notify([instance.owner], msg, event)
@receiver(post_delete, sender=Risk)
def risk_deleted(sender, instance: Risk, **kwargs):
msg = _("Risk '{title}' deleted").format(title=instance.title)
# Owner existiert evtl. nicht mehr -> kein Notify nötig
if instance.owner:
_notify([instance.owner], msg, "risk_deleted")
@receiver(post_save, sender=Risk)
def log_risk_save(sender, instance, created, **kwargs):
if created: if created:
# Initial audit log
AuditLog.objects.create( AuditLog.objects.create(
user=getattr(instance, "_changed_by", None), user=user,
action="create", action="create",
model="Risk", model="Risk",
object_id=instance.pk, object_id=instance.pk,
changes={ changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
f.name: { for f in instance._meta.fields},
"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( notify_event(
NotificationKind.RISK_CREATED, NotificationKind.RISK_CREATED,
message=_("Risk created: {t}").format(t=instance.title), message=_("Risk created: {t}").format(t=instance.title),
users=[instance.owner] if instance.owner_id else None, users=[instance.owner] if instance.owner_id else None,
) )
else: 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( notify_event(
NotificationKind.RISK_UPDATED, NotificationKind.RISK_UPDATED,
message=_("Risk updated: {t}").format(t=instance.title), message=_("Risk updated: {t}").format(t=instance.title),
users=[instance.owner] if instance.owner_id else None, users=[instance.owner] if instance.owner_id else None,
) )
@receiver(post_delete, sender=Risk) @receiver(post_delete, sender=Risk)
def log_risk_delete(sender, instance, **kwargs): def risk_deleted(sender, instance: Risk, **kwargs):
"""
Signal that runs after a Risk is deleted.
"""
user = getattr(instance, "_changed_by", None) or get_current_user() user = getattr(instance, "_changed_by", None) or get_current_user()
AuditLog.objects.create( AuditLog.objects.create(
user=user, user=user, action="delete", model="Risk", object_id=instance.pk, changes=None
action="delete",
model="Risk",
object_id=instance.pk,
changes=None, # no fields to track on deletion
) )
notify_event( notify_event(
NotificationKind.RISK_DELETED, NotificationKind.RISK_DELETED,
message=_("Risk deleted: {t}").format(t=instance.title), message=_("Risk deleted: {t}").format(t=instance.title),
users=[instance.owner] if instance.owner_id else None, users=[instance.owner] if instance.owner_id else None,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Controls # Controls
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@receiver(post_save, sender=Control) @receiver(post_save, sender=Control)
def control_saved(sender, instance: Control, created, **kwargs): 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(): for risk in instance.risks.all():
resid, created = ResidualRisk.objects.get_or_create(risk=risk) resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
# Statuswechsel auf Review Required
if not resid.review_required: if not resid.review_required:
resid.review_required = True resid.review_required = True
resid.save() resid.save()
if risk.status != "review_required": if risk.status != "review_required":
Risk.objects.filter(pk=risk.pk).update(status="review_required") Risk.objects.filter(pk=risk.pk).update(status="review_required")
# Notifications # Audit log
event = "control_created" if created else "control_updated" user = getattr(instance, "_changed_by", None)
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):
if created: if created:
AuditLog.objects.create( AuditLog.objects.create(
user=getattr(instance, "_changed_by", None), user=user,
action="create", action="create",
model="Control", model="Control",
object_id=instance.pk, object_id=instance.pk,
changes={ changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
f.name: { for f in instance._meta.fields},
"old": None,
"new": serialize_value(getattr(instance, f.name))
} for f in instance._meta.fields
},
) )
kind = NotificationKind.CONTROL_CREATED
else: else:
old = Control.objects.get(pk=instance.pk) old = Control.objects.get(pk=instance.pk)
changes = model_diff(old, instance) changes = model_diff(old, instance)
if changes: if changes:
clean_changes = { clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])}
field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])} for f, v in changes.items()}
for field, vals in changes.items()
}
AuditLog.objects.create( AuditLog.objects.create(
user=getattr(instance, "_changed_by", None), user=user, action="update", model="Control", object_id=instance.pk, changes=clean
action="update",
model="Control",
object_id=instance.pk,
changes=clean_changes,
) )
kind = NotificationKind.CONTROL_UPDATED
kind = NotificationKind.CONTROL_CREATED if created else NotificationKind.CONTROL_UPDATED # Notify
notify_event( notify_event(
kind, kind,
message=_("Control {event}: {t}").format( message=_("Control {e}: {t}").format(
event=_("created") if created else _("updated"), e=_("created") if created else _("updated"), t=instance.title
t=instance.title,
), ),
users=[instance.responsible] if instance.responsible_id else None, users=[instance.responsible] if instance.responsible_id else None,
) )
@receiver(post_delete, sender=Control) @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() user = getattr(instance, "_changed_by", None) or get_current_user()
AuditLog.objects.create( AuditLog.objects.create(
user=user, user=user, action="delete", model="Control", object_id=instance.pk, changes=None
action="delete",
model="Control",
object_id=instance.pk,
changes=None,
) )
notify_event( notify_event(
NotificationKind.CONTROL_DELETED, NotificationKind.CONTROL_DELETED,
message=_("Control deleted: {t}").format(t=instance.title), message=_("Control deleted: {t}").format(t=instance.title),
users=[instance.responsible] if instance.responsible_id else None, users=[instance.responsible] if instance.responsible_id else None,
) )
@receiver(m2m_changed, sender=Control.risks.through) @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"}: 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 instance.risks.all():
for risk in affected: resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
resid, created = ResidualRisk.objects.get_or_create(risk=risk)
if not resid.review_required: if not resid.review_required:
resid.review_required = True resid.review_required = True
resid.save() resid.save()
if risk.status != "review_required": if risk.status != "review_required":
Risk.objects.filter(pk=risk.pk).update(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 # Residual risks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@receiver(post_save, sender=ResidualRisk) @receiver(post_save, sender=ResidualRisk)
def residual_saved(sender, instance: ResidualRisk, created, **kwargs): def residual_saved(sender, instance: ResidualRisk, created, **kwargs):
# AuditLog erstellst du bereits anderswo hier Fokus auf Status/Notify """Audit + notify on create/update."""
risk = instance.risk user = getattr(instance, "_changed_by", None)
old = None
if not created:
try:
old = ResidualRisk.objects.get(pk=instance.pk)
except ResidualRisk.DoesNotExist:
pass
# Review-Logik: wenn review_required=True -> Risk.status = review_required # Audit log
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):
if created: if created:
AuditLog.objects.create( AuditLog.objects.create(
user=getattr(instance, "_changed_by", None), user=user,
action="create", action="create",
model="ResidualRisk", model="ResidualRisk",
object_id=instance.pk, object_id=instance.pk,
changes={ changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
f.name: { for f in instance._meta.fields},
"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( notify_event(
NotificationKind.RESIDUAL_CREATED, NotificationKind.RESIDUAL_CREATED,
message=_("Residual created for risk: {t}").format(t=instance.risk.title), message=_("Residual created for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None, users=[instance.risk.owner] if instance.risk.owner_id else None,
) )
else: else:
# Änderungen prüfen
old = ResidualRisk.objects.get(pk=instance.pk) old = ResidualRisk.objects.get(pk=instance.pk)
changes = model_diff(old, instance) changes = model_diff(old, instance)
# Review-Flag Wechsel gezielt melden: 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 "review_required" in changes:
if getattr(instance, "review_required", False): if instance.review_required:
notify_event( kind = NotificationKind.RESIDUAL_REVIEW_REQUIRED
NotificationKind.RESIDUAL_REVIEW_REQUIRED, msg = _("Residual review required for risk: {t}")
message=_("Residual review required for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
else: else:
notify_event( kind = NotificationKind.RESIDUAL_REVIEW_COMPLETED
NotificationKind.RESIDUAL_REVIEW_COMPLETED, msg = _("Residual review completed for risk: {t}")
message=_("Residual review completed for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None,
)
else: else:
kind = NotificationKind.RESIDUAL_UPDATED
msg = _("Residual updated for risk: {t}")
notify_event( notify_event(
NotificationKind.RESIDUAL_UPDATED, kind,
message=_("Residual updated for risk: {t}").format(t=instance.risk.title), message=msg.format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None, users=[instance.risk.owner] if instance.risk.owner_id else None,
) )
@receiver(post_delete, sender=ResidualRisk) @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() user = getattr(instance, "_changed_by", None) or get_current_user()
AuditLog.objects.create( AuditLog.objects.create(
user=user, user=user, action="delete", model="ResidualRisk", object_id=instance.pk, changes=None
action="delete",
model="ResidualRisk",
object_id=instance.pk,
changes=None,
) )
notify_event( notify_event(
NotificationKind.RESIDUAL_DELETED, NotificationKind.RESIDUAL_DELETED,
message=_("Residual deleted for risk: {t}").format(t=instance.risk.title), message=_("Residual deleted for risk: {t}").format(t=instance.risk.title),
users=[instance.risk.owner] if instance.risk.owner_id else None, users=[instance.risk.owner] if instance.risk.owner_id else None,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Incidents # Incidents
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@receiver(post_save, sender=Incident) @receiver(post_save, sender=Incident)
def incident_saved(sender, instance: Incident, created, **kwargs): def incident_saved(sender, instance: Incident, created, **kwargs):
event = "incident_created" if created else "incident_updated" """Audit + notify on create/update."""
stakeholders = set([instance.reported_by]) | set(r.owner for r in instance.related_risks.all() if r.owner) user = getattr(instance, "_changed_by", None)
_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):
if created: if created:
AuditLog.objects.create( AuditLog.objects.create(
user=getattr(instance, "_changed_by", None), user=user,
action="create", action="create",
model="Incident", model="Incident",
object_id=instance.pk, object_id=instance.pk,
changes={ changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
f.name: { for f in instance._meta.fields},
"old": None,
"new": serialize_value(getattr(instance, f.name))
} for f in instance._meta.fields
},
) )
kind = NotificationKind.INCIDENT_CREATED
else: else:
old = Incident.objects.get(pk=instance.pk) old = Incident.objects.get(pk=instance.pk)
changes = model_diff(old, instance) changes = model_diff(old, instance)
if changes: if changes:
clean_changes = { clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])}
field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])} for f, v in changes.items()}
for field, vals in changes.items()
}
AuditLog.objects.create( AuditLog.objects.create(
user=getattr(instance, "_changed_by", None), user=user, action="update", model="Incident", object_id=instance.pk, changes=clean
action="update",
model="Incident",
object_id=instance.pk,
changes=clean_changes,
) )
kind = NotificationKind.INCIDENT_UPDATED
kind = NotificationKind.INCIDENT_CREATED if created else NotificationKind.INCIDENT_UPDATED
notify_event( notify_event(
kind, kind,
message=_("Incident {event}: {t}").format( message=_("Incident {e}: {t}").format(
event=_("created") if created else _("updated"), e=_("created") if created else _("updated"), t=instance.title
t=instance.title,
), ),
users=[instance.reported_by] if instance.reported_by_id else None, 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) @receiver(m2m_changed, sender=Incident.related_risks.through)
def log_incident_risks_change(sender, instance, action, reverse, model, pk_set, **kwargs): def incident_risks_changed(sender, instance, action, pk_set, **kwargs):
if action in ["post_add", "post_remove", "post_clear"]: if action in {"post_add", "post_remove", "post_clear"}:
user = getattr(instance, "_changed_by", None) or get_current_user() user = getattr(instance, "_changed_by", None) or get_current_user()
AuditLog.objects.create( AuditLog.objects.create(
user=user, user=user,
@ -425,20 +344,3 @@ def log_incident_risks_change(sender, instance, action, reverse, model, pk_set,
object_id=instance.pk, object_id=instance.pk,
changes={"related_risks": {"action": action, "ids": list(pk_set)}}, 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,
)

View file

@ -4,25 +4,43 @@ from . import views
app_name = "risks" app_name = "risks"
urlpatterns = [ urlpatterns = [
# -----------------------------------------------------------------------
# Dashboard
# -----------------------------------------------------------------------
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("risks/index", views.dashboard, name="index"), path("risks/index", views.dashboard, name="index"),
# -----------------------------------------------------------------------
# Risks
# -----------------------------------------------------------------------
path("risks/list_risks", views.list_risks, name="list_risks"), path("risks/list_risks", views.list_risks, name="list_risks"),
path("risks/risks/<int:id>", views.show_risk, name="show_risk"), path("risks/risks/<int:id>", views.show_risk, name="show_risk"),
path("risks/risk_matrix", views.risk_matrix, name="risk_matrix"),
path("risks/<int:id>/status", views.update_risk_status, name="update_risk_status"),
# -----------------------------------------------------------------------
# Controls
# -----------------------------------------------------------------------
path("risks/list_controls", views.list_controls, name="list_controls"), path("risks/list_controls", views.list_controls, name="list_controls"),
path("risks/controls/<int:id>", views.show_control, name="show_control"), path("risks/controls/<int:id>", views.show_control, name="show_control"),
path("controls/<int:id>/status", views.update_control_status, name="update_control_status"),
# -----------------------------------------------------------------------
# Incidents
# -----------------------------------------------------------------------
path("risks/list_incidents", views.list_incidents, name="list_incidents"), path("risks/list_incidents", views.list_incidents, name="list_incidents"),
path("risks/incidents/<int:id>", views.show_incident, name="show_incident"), path("risks/incidents/<int:id>", views.show_incident, name="show_incident"),
path("risks/risk_matrix", views.risk_matrix, name="risk_matrix"), path("incidents/<int:id>/status", views.update_incident_status, name="update_incident_status"),
# -----------------------------------------------------------------------
# Residual Risks
# -----------------------------------------------------------------------
path("residuals/<int:risk_id>/review", views.update_residual_review, name="update_residual_review"),
# -----------------------------------------------------------------------
# Notifications # Notifications
# -----------------------------------------------------------------------
path("notifications/", views.notifications, name="notifications"), path("notifications/", views.notifications, name="notifications"),
path("notifications/<int:pk>/read", views.notification_mark_read, name="notification_mark_read"), path("notifications/<int:pk>/read", views.notification_mark_read, name="notification_mark_read"),
path("notifications/mark_all_read", views.notification_mark_all_read, name="notification_mark_all_read"), path("notifications/mark_all_read", views.notification_mark_all_read, name="notification_mark_all_read"),
# Risks status
path("risks/<int:id>/status", views.update_risk_status, name="update_risk_status"),
path("controls/<int:id>/status", views.update_control_status, name="update_control_status"),
path("incidents/<int:id>/status", views.update_incident_status, name="update_incident_status"),
path("residuals/<int:risk_id>/review", views.update_residual_review, name="update_residual_review"),
] ]

View file

@ -1,13 +1,23 @@
from datetime import date, datetime
from typing import Iterable, Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.mail import send_mail from django.core.mail import send_mail
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ 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() User = get_user_model()
# ---------------------------------------------------------------------------
# model_diff()
# ---------------------------------------------------------------------------
def model_diff(old, new, fields=None): def model_diff(old, new, fields=None):
""" """
Compare two model instances and return a dict of changed fields. 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: for field_name in fields:
old_value = getattr(old, field_name, None) old_value = getattr(old, field_name, None)
new_value = getattr(new, field_name, None) new_value = getattr(new, field_name, None)
if old_value != new_value: if old_value != new_value:
changes[field_name] = {"old": old_value, "new": new_value} changes[field_name] = {"old": old_value, "new": new_value}
return changes return changes
# ---------------------------------------------------------------------------
# check_risk_followups()
# ---------------------------------------------------------------------------
def check_risk_followups(): def check_risk_followups():
""" """
Check if follow ups need attention and create notifications. Check if follow-ups need attention and create notifications.
Ensures no duplicate notifications per risk per day Ensures no duplicate notifications per risk per day.
""" """
today = now().date() today = now().date()
risks = Risk.objects.filter(follow_up__lte=today).select_related("owner") risks = Risk.objects.filter(follow_up__lte=today).select_related("owner")
for risk in risks: for risk in risks:
# Risk-Status auf review_required setzen (nicht überschreiben, wenn bereits closed) # Status aktualisieren (außer wenn bereits closed/review_required)
if risk.status != "closed" and risk.status != "review_required": if risk.status not in ("closed", "review_required"):
Risk.objects.filter(pk=risk.pk).update(status="review_required") Risk.objects.filter(pk=risk.pk).update(status="review_required")
# ResidualRisk-Objekt sicherstellen und Review-Flag setzen # ResidualRisk sicherstellen + Review-Flag setzen
resid, created = ResidualRisk.objects.get_or_create(risk=risk) resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
if not resid.review_required: if not resid.review_required:
resid.review_required = True resid.review_required = True
resid.save() resid.save()
# Notification an Stakeholder # Notification (einmalig pro Risk/Tag)
message = _("Follow-up reached: review required for risk '{t}'").format(t=risk.title) message = _("Follow-up reached: review required for risk '{t}'").format(t=risk.title)
notification, created = Notification.objects.get_or_create( notification, created = Notification.objects.get_or_create(
user=risk.owner, user=risk.owner,
@ -58,70 +71,77 @@ def check_risk_followups():
) )
if created: if created:
AuditLog.objects.create( AuditLog.objects.create(
user=None, action="create", model="Notification", object_id=notification.pk, user=None,
changes={"message": notification.message, "user": risk.owner.username if risk.owner else None}, action="create",
model="Notification",
object_id=notification.pk,
changes={
"message": notification.message,
"user": risk.owner.username if risk.owner else None,
},
) )
notify_event( notify_event(
NotificationKind.RISK_REVIEW_REQUIRED, 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, users=[risk.owner] if risk.owner_id else None,
) )
# ---------------------------------------------------------------------------
# _split_emails()
# ---------------------------------------------------------------------------
def _split_emails(value: str) -> list[str]: def _split_emails(value: str) -> list[str]:
"""Normalize a comma/newline-separated list of emails into a clean list."""
if not value: if not value:
return [] return []
raw = value.replace("\n", ",").split(",") raw = value.replace("\n", ",").split(",")
return [e.strip() for e in raw if "@" in e and e.strip()] 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): 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. - users: Basic recipients (owner/responsible/reporter) can be None.
- staff/extra recipients are added from the rule. - staff/extra recipients are added from the rule.
""" """
rule = NotificationRule.objects.filter(kind=kind).first() rule = NotificationRule.objects.filter(kind=kind).first()
# Fallback: without rule → only in-app # Defaults (no rule → in-app only)
enabled_in_app = True enabled_in_app = True
enabled_email = False enabled_email = False
to_staff = False recipients_users = set()
extra_emails = [] extra_emails = []
recipients_users = set() # Base recipients
if users: if users:
for u in users: recipients_users.update(u for u in users if u and getattr(u, "is_active", False))
if u and getattr(u, "is_active", False):
recipients_users.add(u)
# Rule overrides
if rule: if rule:
enabled_in_app = rule.enabled_in_app enabled_in_app = rule.enabled_in_app
enabled_email = rule.enabled_email enabled_email = rule.enabled_email
if rule.to_staff: 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) extra_emails = _split_emails(rule.extra_recipients)
if to_staff: # In-App Notifications
for u in User.objects.filter(is_staff=True, is_active=True):
recipients_users.add(u)
# In-App
if enabled_in_app: if enabled_in_app:
for u in recipients_users: for u in recipients_users:
Notification.objects.create(user=u, message=message) Notification.objects.create(user=u, message=message)
# E-Mail # Email Notifications
if enabled_email: if enabled_email:
emails = [u.email for u in recipients_users if u and u.email] + extra_emails 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: if emails:
subject = _("Notification")
body = message
send_mail( send_mail(
subject, _("Notification"),
body, message,
getattr(settings, "DEFAULT_FROM_EMAIL", "webmaster@localhost"), getattr(settings, "DEFAULT_FROM_EMAIL", "webmaster@localhost"),
emails, emails,
fail_silently=True, # im Zweifel nicht crashen fail_silently=True, # dont crash on mail error
) )

View file

@ -1,103 +1,92 @@
from collections import Counter
from django.contrib import messages
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib import messages from django.db.models import Count
from django.db.models import Count, Q
from django.http import HttpResponseForbidden 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 django.utils.translation import gettext_lazy as _
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from collections import Counter, defaultdict
from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm
from .models import Risk, Control, ResidualRisk, AuditLog, Incident, Notification 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() User = get_user_model()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _can_edit_risk(user, risk: Risk) -> bool: def _can_edit_risk(user, risk: Risk) -> bool:
return bool(user.is_staff or (risk.owner_id and risk.owner_id == user.id)) return bool(user.is_staff or (risk.owner_id and risk.owner_id == user.id))
def _can_edit_control(user, control: Control) -> bool: def _can_edit_control(user, control: Control) -> bool:
return bool(user.is_staff or (control.responsible_id and control.responsible_id == user.id)) return bool(user.is_staff or (control.responsible_id and control.responsible_id == user.id))
def _can_edit_incident(user, incident: Incident) -> bool: 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)) return bool(user.is_staff or (incident.reported_by_id and incident.reported_by_id == user.id))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API # API ViewSets
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class RiskViewSet(viewsets.ModelViewSet): class _ChangedByMixin:
""" """Mixin to track user who changed an object."""
API endpoint for managing Risks. def perform_create(self, serializer):
Provides CRUD operations. 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() queryset = Risk.objects.all()
serializer_class = RiskSerializer serializer_class = RiskSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
def perform_update(self, serializer): class ControlViewSet(_ChangedByMixin, viewsets.ModelViewSet):
instance = serializer.save() """API endpoint for managing Controls."""
instance._changed_by = self.request.user
class ControlViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Controls.
Provides CRUD operations.
"""
queryset = Control.objects.all() queryset = Control.objects.all()
serializer_class = ControlSerializer serializer_class = ControlSerializer
permission_classes = [IsAuthenticated] 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): class ResidualRiskViewSet(viewsets.ModelViewSet):
""" """API endpoint for Residual Risks."""
API endpoint for Residual risks.
"""
queryset = ResidualRisk.objects.all() queryset = ResidualRisk.objects.all()
serializer_class = ResidualRiskSerializer serializer_class = ResidualRiskSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
class UserViewSet(viewsets.ReadOnlyModelViewSet):
""" class UserViewSet(_ChangedByMixin, viewsets.ReadOnlyModelViewSet):
API endpoint for listing users and their responsibilities. """API endpoint for listing users and their responsibilities."""
"""
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = [IsAuthenticated] 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): class AuditViewSet(viewsets.ReadOnlyModelViewSet):
""" """API endpoint for viewing audit logs."""
API endpoint for view audit logging.
"""
queryset = AuditLog.objects.all() queryset = AuditLog.objects.all()
serializer_class = AuditSerializer serializer_class = AuditSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
class IncidentViewSet(viewsets.ModelViewSet):
""" class IncidentViewSet(_ChangedByMixin, viewsets.ModelViewSet):
API endpoint for listing incidents and its related risks. """API endpoint for listing incidents and their related risks."""
"""
queryset = Incident.objects.all() queryset = Incident.objects.all()
serializer_class = IncidentSerializer serializer_class = IncidentSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -106,242 +95,180 @@ class IncidentViewSet(viewsets.ModelViewSet):
instance = serializer.save(reported_by=self.request.user) instance = serializer.save(reported_by=self.request.user)
instance._changed_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 @login_required
def list_risks(request): def list_risks(request):
"""List all risks with filters and sorting."""
qs = Risk.objects.all().select_related("owner", "residual_risk") qs = Risk.objects.all().select_related("owner", "residual_risk")
# Filter # Filters
risk_id = request.GET.get("risk") filters = {
control_id = request.GET.get("control") "id": request.GET.get("risk"),
owner_id = request.GET.get("owner") "controls__id": request.GET.get("control"),
category = request.GET.get("category") "owner_id": request.GET.get("owner"),
asset = request.GET.get("asset") "category": request.GET.get("category"),
process = request.GET.get("process") "asset": request.GET.get("asset"),
"process": request.GET.get("process"),
if risk_id: }
qs = qs.filter(id=risk_id) qs = qs.filter(**{k: v for k, v in filters.items() if v})
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)
# Sorting
sort = request.GET.get("sort") or "title" sort = request.GET.get("sort") or "title"
direction = request.GET.get("dir") or "asc" direction = request.GET.get("dir") or "asc"
if direction == "desc": qs = qs.order_by(f"-{sort}" if direction == "desc" else sort)
qs = qs.order_by(f"-{sort}")
else:
qs = qs.order_by(sort)
risks = qs.distinct() 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", { return render(request, "risks/list_risks.html", {
"risks": risks, "risks": risks,
"risk_choices": risk_choices, "risk_choices": Risk.objects.all().order_by("title"),
"control_choices": control_choices, "control_choices": Control.objects.all().order_by("title"),
"owner_choices": owner_choices, "owner_choices": User.objects.filter(owned_risks__isnull=False).distinct().order_by("username"),
"category_choices": category_choices, "category_choices": (Risk.objects.exclude(category__isnull=True).exclude(category__exact="")
"asset_choices": asset_choices, .values_list("category", flat=True).distinct().order_by("category")),
"process_choices": process_choices, "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_sort": sort,
"current_dir": direction, "current_dir": direction,
}) })
@login_required @login_required
def show_risk(request, id): def show_risk(request, id):
""" """Show single risk details + logs."""
View for single risk
"""
risk = get_object_or_404( risk = get_object_or_404(
Risk.objects.select_related("residual_risk", "owner").prefetch_related("controls"), Risk.objects.select_related("residual_risk", "owner").prefetch_related("controls"),
pk=id, pk=id,
) )
ct = ContentType.objects.get_for_model(Risk) ct = ContentType.objects.get_for_model(Risk)
logs = LogEntry.objects.filter(content_type=ct, object_id=risk.pk).order_by("-action_time") 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}) return render(request, "risks/item_risk.html", {"risk": risk, "logs": logs})
# ---------------------------------------------------------------------------
# Web Views: Controls
# ---------------------------------------------------------------------------
@login_required @login_required
def list_controls(request): def list_controls(request):
""" """List all controls with filters."""
View for listing all Controls
"""
qs = Control.objects.all().select_related("responsible") qs = Control.objects.all().select_related("responsible")
control_id = request.GET.get("control") filters = {
risk_id = request.GET.get("risk") "id": request.GET.get("control"),
status = request.GET.get("status") "risks__id": request.GET.get("risk"),
responsible_id = request.GET.get("responsible") "status": request.GET.get("status"),
"responsible_id": request.GET.get("responsible"),
if control_id: }
qs = qs.filter(id=control_id) qs = qs.filter(**{k: v for k, v in filters.items() if v})
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)
controls = qs.order_by("title").distinct() 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", { return render(request, "risks/list_controls.html", {
"controls": controls, "controls": controls,
"risks": risks, "control_choices": Control.objects.all().order_by("title"),
"users": users, "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, "status_choices": Control.STATUS_CHOICES,
}) })
@login_required @login_required
def show_control(request, id): def show_control(request, id):
"""Show single control details + logs."""
control = get_object_or_404(Control, pk=id) control = get_object_or_404(Control, pk=id)
ct = ContentType.objects.get_for_model(Control) ct = ContentType.objects.get_for_model(Control)
logs = LogEntry.objects.filter( logs = LogEntry.objects.filter(content_type=ct, object_id=control.pk).order_by("-action_time")
content_type=ct,
object_id=control.pk
).order_by("-action_time")
return render(request, "risks/item_control.html", {"control": control, "logs": logs}) return render(request, "risks/item_control.html", {"control": control, "logs": logs})
# ---------------------------------------------------------------------------
# Web Views: Incidents
# ---------------------------------------------------------------------------
@login_required @login_required
def list_incidents(request): def list_incidents(request):
""" """List all incidents with filters."""
View for listing all Incidents
"""
qs = Incident.objects.all().select_related("reported_by").prefetch_related("related_risks") qs = Incident.objects.all().select_related("reported_by").prefetch_related("related_risks")
risk_id = request.GET.get("risk") filters = {
status = request.GET.get("status") "related_risks__id": request.GET.get("risk"),
reported_by = request.GET.get("reported_by") "status": request.GET.get("status"),
"reported_by": request.GET.get("reported_by"),
if risk_id: }
qs = qs.filter(related_risks__id=risk_id) # FIX qs = qs.filter(**{k: v for k, v in filters.items() if v})
if status:
qs = qs.filter(status=status)
if reported_by:
qs = qs.filter(reported_by=reported_by)
incidents = qs.order_by("title").distinct() 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", { return render(request, "risks/list_incidents.html", {
"incidents": incidents, "incidents": incidents,
"risks": risks, "incident_choices": incidents,
"users": users, "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, "status_choices": Incident.STATUS_CHOICES,
}) })
@login_required @login_required
def show_incident(request, id): def show_incident(request, id):
"""Show single incident details + logs."""
incident = get_object_or_404(Incident, pk=id) incident = get_object_or_404(Incident, pk=id)
ct = ContentType.objects.get_for_model(Incident) ct = ContentType.objects.get_for_model(Incident)
logs = LogEntry.objects.filter( logs = LogEntry.objects.filter(content_type=ct, object_id=incident.pk).order_by("-action_time")
content_type=ct,
object_id=incident.pk
).order_by("-action_time")
return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs}) return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs})
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
@login_required @login_required
def dashboard(request): def dashboard(request):
""" """Dashboard view with KPIs."""
Dashboardview with KPIs
"""
# Risikoübersicht # Risikoübersicht
risks_total = Risk.objects.count() 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 # 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() cia_counter = Counter()
for cia_list in risks_cia: 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: for c in cia_list:
cia_counter[c] += 1 cia_counter[c] += 1
elif cia_list: # Falls irgendwie noch ein String drin ist elif cia_list:
cia_counter[cia_list] += 1 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 = { context = {
'risks_total': risks_total, "risks_total": risks_total,
'risks_by_level': risks_by_level, "risks_by_level": risks_by_level,
'risks_by_cia': dict(cia_counter), # <-- hier Counter in dict umwandeln "risks_by_cia": dict(cia_counter),
'residual_review_required': residual_review_required, "residual_review_required": ResidualRisk.objects.filter(review_required=True).count(),
'controls_by_status': controls_by_status, "controls_by_status": Control.objects.values("status").annotate(count=Count("id")),
'incidents_status': incidents_status, "incidents_status": Incident.objects.values("status").annotate(count=Count("id")),
'notifications_unread': notifications_unread, "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 # Notifications
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@login_required @login_required
def notifications(request): def notifications(request):
"""Eigene Benachrichtigungen ansehen + filtern""" """View own notifications with optional filter."""
flt = request.GET.get("filter", "unread") flt = request.GET.get("filter", "unread")
qs = Notification.objects.filter(user=request.user).order_by("-created_at") qs = Notification.objects.filter(user=request.user).order_by("-created_at")
if flt == "unread": if flt == "unread":
qs = qs.filter(read=False) 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 @login_required
def notification_mark_read(request, pk): def notification_mark_read(request, pk):
"""Mark single notification as read."""
if request.method != "POST": if request.method != "POST":
return HttpResponseForbidden() return HttpResponseForbidden()
notif = get_object_or_404(Notification, pk=pk, user=request.user) 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.")) messages.success(request, _("Notification marked as read."))
return redirect(request.META.get("HTTP_REFERER") or "risks:notifications") return redirect(request.META.get("HTTP_REFERER") or "risks:notifications")
@login_required @login_required
def notification_mark_all_read(request): def notification_mark_all_read(request):
"""Mark all notifications as read."""
if request.method != "POST": if request.method != "POST":
return HttpResponseForbidden() return HttpResponseForbidden()
Notification.objects.filter(user=request.user, read=False).update(read=True) Notification.objects.filter(user=request.user, read=False).update(read=True)
messages.success(request, _("All notifications marked as read.")) messages.success(request, _("All notifications marked as read."))
return redirect("risks:notifications") return redirect("risks:notifications")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Status Updates # Status Updates
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@login_required @login_required
def update_risk_status(request, id): def update_risk_status(request, id):
"""Update risk status."""
risk = get_object_or_404(Risk, pk=id) risk = get_object_or_404(Risk, pk=id)
if not _can_edit_risk(request.user, risk): if not _can_edit_risk(request.user, risk):
return HttpResponseForbidden() return HttpResponseForbidden()
@ -375,8 +306,10 @@ def update_risk_status(request, id):
messages.success(request, _("Risk status updated.")) messages.success(request, _("Risk status updated."))
return redirect("risks:show_risk", id=risk.pk) return redirect("risks:show_risk", id=risk.pk)
@login_required @login_required
def update_control_status(request, id): def update_control_status(request, id):
"""Update control status."""
control = get_object_or_404(Control, pk=id) control = get_object_or_404(Control, pk=id)
if not _can_edit_control(request.user, control): if not _can_edit_control(request.user, control):
return HttpResponseForbidden() return HttpResponseForbidden()
@ -389,8 +322,10 @@ def update_control_status(request, id):
messages.success(request, _("Control status updated.")) messages.success(request, _("Control status updated."))
return redirect("risks:show_control", id=control.pk) return redirect("risks:show_control", id=control.pk)
@login_required @login_required
def update_incident_status(request, id): def update_incident_status(request, id):
"""Update incident status."""
incident = get_object_or_404(Incident, pk=id) incident = get_object_or_404(Incident, pk=id)
if not _can_edit_incident(request.user, incident): if not _can_edit_incident(request.user, incident):
return HttpResponseForbidden() return HttpResponseForbidden()
@ -403,13 +338,14 @@ def update_incident_status(request, id):
messages.success(request, _("Incident status updated.")) messages.success(request, _("Incident status updated."))
return redirect("risks:show_incident", id=incident.pk) return redirect("risks:show_incident", id=incident.pk)
@login_required @login_required
def update_residual_review(request, risk_id): 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) risk = get_object_or_404(Risk, pk=risk_id)
if not _can_edit_risk(request.user, risk): if not _can_edit_risk(request.user, risk):
return HttpResponseForbidden() return HttpResponseForbidden()
residual, created_resid = ResidualRisk.objects.get_or_create(risk=risk) residual, _ = ResidualRisk.objects.get_or_create(risk=risk)
if request.method == "POST": if request.method == "POST":
form = ResidualReviewForm(request.POST, instance=residual) form = ResidualReviewForm(request.POST, instance=residual)
if form.is_valid(): if form.is_valid():
@ -420,11 +356,12 @@ def update_residual_review(request, risk_id):
return redirect("risks:show_risk", id=risk.pk) return redirect("risks:show_risk", id=risk.pk)
# ---------------------------------------------------------------------------
# Risk Matrix
# ---------------------------------------------------------------------------
def risk_matrix(request): def risk_matrix(request):
risks = (Risk.objects """Show gross/net risk matrix."""
.select_related("owner", "residual_risk") # wichtig fürs Netto risks = Risk.objects.select_related("owner", "residual_risk").all()
.all())
impacts = sorted(Risk.IMPACT_CHOICES, key=lambda x: x[0]) impacts = sorted(Risk.IMPACT_CHOICES, key=lambda x: x[0])
likelihoods = sorted(Risk.LIKELIHOOD_CHOICES, key=lambda x: x[0]) likelihoods = sorted(Risk.LIKELIHOOD_CHOICES, key=lambda x: x[0])
@ -432,9 +369,7 @@ def risk_matrix(request):
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: for r in risks:
# Brutto platzieren
gross_matrix[r.impact][r.likelihood].append(r) gross_matrix[r.impact][r.likelihood].append(r)
# Netto (falls vorhanden) platzieren
rr = getattr(r, "residual_risk", None) rr = getattr(r, "residual_risk", None)
if rr: if rr:
net_matrix[rr.impact][rr.likelihood].append(r) net_matrix[rr.impact][rr.likelihood].append(r)

View file

@ -173,6 +173,7 @@ abbr { text-decoration: none; }
.breadcrumb:not(:last-child) { margin-bottom: 0; border-bottom: 1px solid var(--prosoft-normal); } .breadcrumb:not(:last-child) { margin-bottom: 0; border-bottom: 1px solid var(--prosoft-normal); }
.breadcrumb { background-color: #f0ebeb; } .breadcrumb { background-color: #f0ebeb; }
.breadcrumb a { color: var(--prosoft-normal) !important; } .breadcrumb a { color: var(--prosoft-normal) !important; }
.breadcrumb-add-icon {color: limegreen !important}
/* ========================= /* =========================
Lists inside .content Lists inside .content
@ -310,13 +311,13 @@ body.dark-mode a { color: #bb86fc; }
Dark Mode Palette Dark Mode Palette
========================= */ ========================= */
body.dark-mode { body.dark-mode {
--bg-main: #121212; --bg-main: #3c3c3c;
--bg-surface: #1e1e1e; --bg-surface: #3c3c3c;
--bg-hover: #2a2a2a; --bg-hover: #2a2a2a;
--border-color: #333; --border-color: #333;
--text-main: #f5f5f5; --text-main: #f5f5f5;
--text-muted: #bbb; --text-muted: #bbb;
--link-color: #bb86fc; --link-color: #fff;
--link-hover: #d0aaff; --link-hover: #d0aaff;
background-color: var(--bg-main); background-color: var(--bg-main);
@ -342,7 +343,7 @@ body.dark-mode .content {
Navbar / Topbar Navbar / Topbar
========================= */ ========================= */
body.dark-mode .navbar.topbar-nav { body.dark-mode .navbar.topbar-nav {
background-color: var(--bg-surface) !important;
box-shadow: none; box-shadow: none;
} }
body.dark-mode .navbar.topbar-nav .navbar-item, 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-item:hover,
body.dark-mode .navbar.topbar-nav .navbar-link:hover { body.dark-mode .navbar.topbar-nav .navbar-link:hover {
background-color: var(--bg-hover);
color: #fff; color: #fff;
} }
body.dark-mode .navbar.topbar-nav .navbar-link::after { body.dark-mode .navbar.topbar-nav .navbar-link::after {
@ -408,13 +409,12 @@ body.dark-mode td {
/* ========================= /* =========================
Inputs / Forms Inputs / Forms
========================= */ ========================= */
body.dark-mode input,
body.dark-mode select, body.dark-mode select,
body.dark-mode textarea { body.dark-mode textarea {
background: var(--bg-surface); background: var(--bg-surface);
color: var(--text-main); color: var(--text-main);
border: 1px solid var(--border-color);
} }
body.dark-mode input::placeholder { body.dark-mode input::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
@ -424,7 +424,7 @@ body.dark-mode input::placeholder {
========================= */ ========================= */
body.dark-mode .navbar-dropdown { body.dark-mode .navbar-dropdown {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-color);
} }
body.dark-mode .navbar-dropdown .navbar-item { body.dark-mode .navbar-dropdown .navbar-item {
@ -436,3 +436,16 @@ body.dark-mode .navbar-dropdown .navbar-item:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: #fff !important; 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;
}

View file

@ -1,159 +1,133 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n risk_extras %} {% load i18n risk_extras %}
{% block crumbs %} {% block crumbs %}
<li><a href="{% url 'risks:list_controls' %}">Maßnahmen</a></li> <li><a href="{% url 'risks:list_controls' %}">{% trans "Controls" %}</a></li>
<li><a href="{% url 'risks:show_control' control.id %}">{{ control.title }}</a></li> <li><a href="{% url 'risks:show_control' control.id %}">{{ control.title }}</a></li>
{% endblock %} {% endblock %}
{% block content %}
<div class="container">
<section class="hero is-small">
<div class="hero-body">
<p class="title">Maßnahme: {{ control.title }}</p>
<p class="subtitle is-6">{{ control.description }}</p>
</div>
</section>
<!-- Überblick-->
<div class="card">
<header class="card-header">
<p class="card-header-title">Überblick</p>
{% if request.user.is_staff or control.responsible.id == request.user.id %} {% block content %}
<form method="post" action="{% url 'risks:update_control_status' control.id %}" class="card-header-icon" style="margin-left:auto;">
{% csrf_token %} <!-- ERP-style tabs -->
<div class="field has-addons"> <div class="erp-tabs">
<div class="control"> <a class="is-active" data-tab="overview">{% trans "Overview" %}</a>
<div class="select is-small"> <a data-tab="risks">{% trans "Linked Risks" %}</a>
<select name="status"> <a data-tab="history">{% trans "History" %}</a>
{% for value,label in control.STATUS_CHOICES %} <!-- Action Icons -->
<option value="{{ value }}" {% if control.status == value %}selected{% endif %}>{{ label }}</option> <div class="buttons">
{% endfor %} <a href="{% url 'admin:risks_control_change' control.pk %}" class="button is-small is-warning" title="{% trans 'Edit Control' %}">
</select> <span class="icon"><i class="fas fa-edit"></i></span>
</div>
</div>
<div class="control">
<button class="button is-small is-link">
<span class="icon"><i class="fas fa-save"></i></span>
</button>
</div>
</div>
</form>
<a class="card-header-icon has-text-warning" href="{% url 'admin:risks_control_change' control.pk %}" title="Maßnahme bearbeiten">
<span class="icon"><i class="fas fa-edit" aria-hidden="true"></i></span>
</a> </a>
<a class="card-header-icon has-text-danger" href="{% url 'admin:risks_control_delete' control.pk %}" title="Maßnahme Löschen (WARNUNG!)"> <a href="{% url 'admin:risks_control_delete' control.pk %}" class="button is-small is-danger" title="{% trans 'Delete Control' %}">
<span class="icon"><i class="fas fa-trash" aria-hidden="true"></i></span> <span class="icon"><i class="fas fa-trash"></i></span>
</a> </a>
{% endif %} </div>
</header> </div>
<!-- Inhalt Überblick-->
<!-- Tab: Overview -->
<div class="tab-panel" data-tab="overview">
<div class="card">
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-half"> <div class="column is-half">
<p><strong>Verantwortliche/r:</strong> {{ control.responsible|default:"-" }}</p> <p><strong>{% trans "Control" %}:</strong> {{ control.title }}</p>
<p><strong><a>Zum Wiki Eintrag</a></strong></p> <p><strong>{% trans "Responsible" %}:</strong> {{ control.responsible|default:"" }}</p>
<p><strong>{% trans "Status" %}:</strong> {{ control.get_status_display }}</p>
<p>
<strong>{% trans "Link" %}:</strong>
{% if control.wiki_link %}
<a href="{{ control.wiki_link }}" target="_blank">🔗</a>
{% else %}
{% endif %}
</p>
</div> </div>
<div class="column is-half"> <div class="column is-half">
<p><strong>{% trans "Created at" %}:</strong> {{ control.created_at|date:"d.m.Y H:i" }}</p>
<p><strong>Erstellt am:</strong> {{ control.created_at|date:'d.m.Y H:i' }}</p> <p><strong>{% trans "Updated at" %}:</strong> {{ control.updated_at|date:"d.m.Y H:i" }}</p>
<p><strong>Aktualisiert am:</strong> {{ control.updated_at|date:'d.m.Y H:i' }}</p> <p><strong>{% trans "Deadline" %}:</strong> {{ control.due_date|date:"d.m.Y"|default:"" }}</p>
</div> </div>
</div> </div>
</div> <!-- Ende Inhalt Überblick --> <section>
</div> <!-- Ende Überblick --> <h3 class="title is-6">{% trans "Description" %}</h3>
<p>{{ control.description|default:"" }}</p>
</section>
</div>
</div>
</div><!-- Overview Tab End -->
<!-- Risiken --> <!-- Tab: Linked Risks -->
<div class="card"> <div class="tab-panel is-hidden" data-tab="risks">
<header class="card-header"> <div class="table-container">
<p class="card-header-title">Verknüpfte Risiken</p> <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
</header>
<div class="card-content">
{% if control.risks %}
<table class="table is-striped is-hoverable is-fullwidth">
<thead> <thead>
<tr> <tr class="has-background-prosoft">
<th>Titel</th> <th class="has-text-centered">{% trans "Risk" %}</th>
<th>Risikoeigner</th> <th class="has-text-centered">{% trans "Owner" %}</th>
<th>Kategorie</th> <th class="has-text-centered">{% trans "Category" %}</th>
<th>Asset</th> <th class="has-text-centered">{% trans "Asset" %}</th>
<th>Prozess</th> <th class="has-text-centered">{% trans "Process" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for risk in control.risks.all %} {% for risk in control.risks.all %}
<tr onclick="window.location.href='/risks/risks/{{ risk.id }}';" style="cursor:pointer;"> <tr onclick="window.location.href='{% url 'risks:show_risk' risk.id %}'" style="cursor:pointer;">
<td>{{ risk.title }}</td> <td>{{ risk.title }}</td>
<td> <td class="has-text-centered">{{ risk.owner|user_display|default:"" }}</td>
{% if risk.owner %} <td class="has-text-centered">{{ risk.category|default:"" }}</td>
{{ risk.owner }} <td class="has-text-centered">{{ risk.asset|default:"" }}</td>
{% else %} <td class="has-text-centered">{{ risk.process|default:"" }}</td>
{% endif %}
</td>
<td>
{% if risk.category %}
{{ risk.category }}
{% else %}
{% endif %}
</td>
<td>
{% if risk.asset %}
{{ risk.asset }}
{% else %}
{% endif %}
</td>
<td>
{% if risk.process %}
{{ risk.process }}
{% else %}
{% endif %}
</td>
</tr> </tr>
{% empty %}
<tr><td colspan="5" class="has-text-grey has-text-centered">{% trans "No linked risks." %}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p class="has-text-grey">Keine Verknüpften Risiken.</p>
{% endif %}
</div> </div>
</div> </div><!-- Linked Risks Tab End -->
<!-- Ende Maßnahmen -->
<!-- Historie --> <!-- Tab: History -->
<div class="card"> <div class="tab-panel is-hidden" data-tab="history">
<header class="card-header"> <div class="table-container">
<p class="card-header-title">Historie</p> <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
</header>
<div class="card-content">
{% if logs %}
<table class="table is-striped is-fullwidth">
<thead> <thead>
<tr> <tr class="has-background-prosoft">
<th>Zeitpunkt</th> <th class="has-text-centered">{% trans "Time" %}</th>
<th>Benutzer</th> <th class="has-text-centered">{% trans "User" %}</th>
<th>Aktion</th> <th class="has-text-centered">{% trans "Action" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for log in logs %} {% for log in logs %}
<tr> <tr>
<td>{{ log.action_time|date:"d.m.Y H:i" }}</td> <td class="has-text-centered">{{ log.action_time|date:"d.m.Y H:i" }}</td>
<td>{{ log.user.get_full_name|default:log.user.username }}</td> <td class="has-text-centered">{{ log.user.get_full_name|default:log.user.username }}</td>
<td>{{ log.get_change_message }}</td> <td>{{ log.get_change_message }}</td>
</tr> </tr>
{% empty %}
<tr><td colspan="3" class="has-text-grey has-text-centered">{% trans "No history found." %}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p class="has-text-grey">Keine Historie vorhanden.</p>
{% endif %}
</div> </div>
</div> <!-- Ende Historie --> </div><!-- History Tab End -->
<br><br> <!-- Tab switching script -->
<script>
</div> document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.erp-tabs a[data-tab]');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', e => {
e.preventDefault();
tabs.forEach(x => x.classList.remove('is-active'));
panels.forEach(p => p.classList.add('is-hidden'));
tab.classList.add('is-active');
const target = tab.getAttribute('data-tab');
const activePanel = document.querySelector(`.tab-panel[data-tab="${target}"]`);
if (activePanel) activePanel.classList.remove('is-hidden');
});
});
});
</script>
{% endblock %} {% endblock %}

View file

@ -1,157 +1,126 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n risk_extras %}
{% block crumbs %} {% block crumbs %}
<li><a href="{% url 'risks:list_incidents' %}">Vorfälle</a></li> <li><a href="{% url 'risks:list_incidents' %}">{% trans "Incidents" %}</a></li>
<li><a href="{% url 'risks:show_incident' incident.id %}">{{ incident.title }}</a></li> <li><a href="{% url 'risks:show_incident' incident.id %}">{{ incident.title }}</a></li>
{% endblock %} {% endblock %}
{% block content %}
<div class="container">
<section class="hero is-small">
<div class="hero-body">
<p class="title">Vorfall: {{ incident.title }}</p>
<p class="subtitle is-6">{{ incident.description }}</p>
</div>
</section>
<!-- Überblick-->
<div class="card">
<header class="card-header">
<p class="card-header-title">Überblick</p>
{% if request.user.is_staff or incident.reported_by_id == request.user.id %} {% block content %}
<form method="post" action="{% url 'risks:update_incident_status' incident.id %}" class="card-header-icon" style="margin-left:auto;">
{% csrf_token %} <!-- ERP-style tabs -->
<div class="field has-addons"> <div class="erp-tabs">
<div class="control"> <a class="is-active" data-tab="overview">{% trans "Overview" %}</a>
<div class="select is-small"> <a data-tab="risks">{% trans "Linked Risks" %}</a>
<select name="status"> <a data-tab="history">{% trans "History" %}</a>
{% for value,label in incident.STATUS_CHOICES %} <!-- Action Icons -->
<option value="{{ value }}" {% if incident.status == value %}selected{% endif %}>{{ label }}</option> <div class="buttons">
{% endfor %} <a href="{% url 'admin:risks_incident_change' incident.pk %}" class="button is-small is-warning" title="{% trans 'Edit Incident' %}">
</select> <span class="icon"><i class="fas fa-edit"></i></span>
</div>
</div>
<div class="control">
<button class="button is-small is-link">
<span class="icon"><i class="fas fa-save"></i></span>
</button>
</div>
</div>
</form>
<a class="card-header-icon has-text-warning" href="{% url 'admin:risks_incident_change' incident.pk %}" title="Vorfall bearbeiten">
<span class="icon"><i class="fas fa-edit" aria-hidden="true"></i></span>
</a> </a>
<a class="card-header-icon has-text-danger" href="{% url 'admin:risks_incident_delete' incident.pk %}" title="Vorfall Löschen (WARNUNG!)"> <a href="{% url 'admin:risks_incident_delete' incident.pk %}" class="button is-small is-danger" title="{% trans 'Delete Incident' %}">
<span class="icon"><i class="fas fa-trash" aria-hidden="true"></i></span> <span class="icon"><i class="fas fa-trash"></i></span>
</a> </a>
{% endif %} </div>
</header> </div>
<!-- Inhalt Überblick-->
<!-- Tab: Overview -->
<div class="tab-panel" data-tab="overview">
<div class="card">
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-half"> <div class="column is-half">
<p><strong>Gemeldet von:</strong> {{ incident.reported_by|default:"-" }}</p> <p><strong>{% trans "Incident" %}:</strong> {{ incident.title }}</p>
<p><strong>Gemeldet am:</strong> {{ incident.date_reported|date:'d.m.Y' }}</p> <p><strong>{% trans "Reported by" %}:</strong> {{ incident.reported_by|default:"" }}</p>
<p><strong>Status:</strong> {{ incident.status }}</p> <p><strong>{% trans "Reported on" %}:</strong> {{ incident.date_reported|date:"d.m.Y" }}</p>
<p><strong>{% trans "Status" %}:</strong> {{ incident.get_status_display }}</p>
</div> </div>
<div class="column is-half"> <div class="column is-half">
<p><strong>Erstellt am:</strong> {{ incident.created_at|date:'d.m.Y H:i' }}</p> <p><strong>{% trans "Created at" %}:</strong> {{ incident.created_at|date:"d.m.Y H:i" }}</p>
<p><strong>Aktualisiert am:</strong> {{ incident.updated_at|date:'d.m.Y H:i' }}</p> <p><strong>{% trans "Updated at" %}:</strong> {{ incident.updated_at|date:"d.m.Y H:i" }}</p>
</div> </div>
</div> </div>
</div> <!-- Ende Inhalt Überblick --> <section>
</div> <!-- Ende Überblick --> <h3 class="title is-6">{% trans "Description" %}</h3>
<p>{{ incident.description|default:"" }}</p>
</section>
</div>
</div>
</div><!-- Overview Tab End -->
<!-- Risiken --> <!-- Tab: Linked Risks -->
<div class="card"> <div class="tab-panel is-hidden" data-tab="risks">
<header class="card-header"> <div class="table-container">
<p class="card-header-title">Zugehörige Risiken</p> <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
</header>
<div class="card-content">
{% if incident.related_risks %}
<table class="table is-striped is-hoverable is-fullwidth">
<thead> <thead>
<tr> <tr class="has-background-prosoft">
<th>Titel</th> <th class="has-text-centered">{% trans "Risk" %}</th>
<th>Risikoeigner</th> <th class="has-text-centered">{% trans "Owner" %}</th>
<th>Kategorie</th> <th class="has-text-centered">{% trans "Category" %}</th>
<th>Asset</th> <th class="has-text-centered">{% trans "Asset" %}</th>
<th>Prozess</th> <th class="has-text-centered">{% trans "Process" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for risk in incident.related_risks.all %} {% for risk in incident.related_risks.all %}
<tr onclick="window.location.href='/risks/risks/{{ risk.id }}';" style="cursor:pointer;"> <tr onclick="window.location.href='{% url 'risks:show_risk' risk.id %}'" style="cursor:pointer;">
<td>{{ risk.title }}</td> <td>{{ risk.title }}</td>
<td> <td class="has-text-centered">{{ risk.owner|user_display|default:"" }}</td>
{% if risk.owner %} <td class="has-text-centered">{{ risk.category|default:"" }}</td>
{{ risk.owner }} <td class="has-text-centered">{{ risk.asset|default:"" }}</td>
{% else %} <td class="has-text-centered">{{ risk.process|default:"" }}</td>
{% endif %}
</td>
<td>
{% if risk.category %}
{{ risk.category }}
{% else %}
{% endif %}
</td>
<td>
{% if risk.asset %}
{{ risk.asset }}
{% else %}
{% endif %}
</td>
<td>
{% if risk.process %}
{{ risk.process }}
{% else %}
{% endif %}
</td>
</tr> </tr>
{% empty %}
<tr><td colspan="5" class="has-text-grey has-text-centered">{% trans "No linked risks." %}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p class="has-text-grey">Keine Verknüpften Risiken.</p>
{% endif %}
</div> </div>
</div> </div><!-- Linked Risks Tab End -->
<!-- Ende Maßnahmen -->
<!-- Historie --> <!-- Tab: History -->
<div class="card"> <div class="tab-panel is-hidden" data-tab="history">
<header class="card-header"> <div class="table-container">
<p class="card-header-title">Historie</p> <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
</header>
<div class="card-content">
{% if logs %}
<table class="table is-striped is-fullwidth">
<thead> <thead>
<tr> <tr class="has-background-prosoft">
<th>Zeitpunkt</th> <th class="has-text-centered">{% trans "Time" %}</th>
<th>Benutzer</th> <th class="has-text-centered">{% trans "User" %}</th>
<th>Aktion</th> <th class="has-text-centered">{% trans "Action" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for log in logs %} {% for log in logs %}
<tr> <tr>
<td>{{ log.action_time|date:"d.m.Y H:i" }}</td> <td class="has-text-centered">{{ log.action_time|date:"d.m.Y H:i" }}</td>
<td>{{ log.user.get_full_name|default:log.user.username }}</td> <td class="has-text-centered">{{ log.user.get_full_name|default:log.user.username }}</td>
<td>{{ log.get_change_message }}</td> <td>{{ log.get_change_message }}</td>
</tr> </tr>
{% empty %}
<tr><td colspan="3" class="has-text-grey has-text-centered">{% trans "No history found." %}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p class="has-text-grey">Keine Historie vorhanden.</p>
{% endif %}
</div> </div>
</div> <!-- Ende Historie --> </div><!-- History Tab End -->
<br><br> <!-- Tab switching script -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.erp-tabs a[data-tab]');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', e => {
e.preventDefault();
tabs.forEach(x => x.classList.remove('is-active'));
panels.forEach(p => p.classList.add('is-hidden'));
tab.classList.add('is-active');
const target = tab.getAttribute('data-tab');
const activePanel = document.querySelector(`.tab-panel[data-tab="${target}"]`);
if (activePanel) activePanel.classList.remove('is-hidden');
});
});
});
</script>
</div>
{% endblock %} {% endblock %}

View file

@ -13,6 +13,15 @@
<a data-tab="measures">{% trans "Measures" %}</a> <a data-tab="measures">{% trans "Measures" %}</a>
<a data-tab="incidents">{% trans "Incidents" %}</a> <a data-tab="incidents">{% trans "Incidents" %}</a>
<a data-tab="history">{% trans "History" %}</a> <a data-tab="history">{% trans "History" %}</a>
<!-- Action Icons -->
<div class="buttons">
<a href="{% url 'admin:risks_risk_change' risk.pk %}" class="button is-small is-warning" title="{% trans 'Edit Risk' %}">
<span class="icon"><i class="fas fa-edit"></i></span>
</a>
<a href="{% url 'admin:risks_risk_delete' risk.pk %}" class="button is-small is-danger" title="{% trans 'Delete Risk' %}">
<span class="icon"><i class="fas fa-trash"></i></span>
</a>
</div>
</div> </div>
<!-- Tab: Overview --> <!-- Tab: Overview -->

View file

@ -1,62 +1,54 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n risk_extras %}
{% block crumbs %} {% block crumbs %}
<li><a href="{% url 'risks:list_controls' %}">Maßnahmen</a></li> <li><a href="{% url 'risks:list_controls' %}">{% trans "Controls" %}</a></li>
<li><a href="{% url 'admin:risks_control_add' %}"><span class="icon breadcrumb-add-icon"><i class="fas fa-add"></i></span></a></li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Filter -->
<section class="section">
<div class="box">
<h2 class="title is-5">Auswahl</h2>
<form method="get"> <!-- Filter Section -->
<div class="columns is-multiline"> <section class="section has-background-light py-2">
<form method="get" class="mb-4">
<div class="columns is-multiline is-vcentered">
<!-- Maßnahmen --> <!-- Filter: Control -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Control" %}</label>
<label class="label">Maßnahme</label> <div class="select is-small is-fullwidth">
<div class="control">
<div class="select is-fullwidth">
<select name="control" onchange="this.form.submit()"> <select name="control" onchange="this.form.submit()">
<option value="">Alle</option> <option value="">{% trans "All" %}</option>
{% for c in controls %} {% for c in control_choices %}
<option value="{{ c.id }}" {% if request.GET.control == c.id|stringformat:"s" %}selected{% endif %}> <option value="{{ c.id }}" {% if request.GET.control == c.id|stringformat:"s" %}selected{% endif %}>
{{ c.title }} {{ c.title }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div><!-- Filter: Control End -->
</div>
</div>
<!-- Risiko --> <!-- Filter: Risk -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Risk" %}</label>
<label class="label">Risiko</label> <div class="select is-small is-fullwidth">
<div class="control">
<div class="select is-fullwidth">
<select name="risk" onchange="this.form.submit()"> <select name="risk" onchange="this.form.submit()">
<option value="">Alle</option> <option value="">{% trans "All" %}</option>
{% for r in risks %} {% for r in risk_choices %}
<option value="{{ r.id }}" {% if request.GET.risk == r.id|stringformat:"s" %}selected{% endif %}> <option value="{{ r.id }}" {% if request.GET.risk == r.id|stringformat:"s" %}selected{% endif %}>
{{ r.title }} {{ r.title }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div><!-- Filter: Risk End -->
</div>
</div>
<!-- Status --> <!-- Filter: Status -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Status" %}</label>
<label class="label">Status</label> <div class="select is-small is-fullwidth">
<div class="control">
<div class="select is-fullwidth">
<select name="status" onchange="this.form.submit()"> <select name="status" onchange="this.form.submit()">
<option value="">Alle</option> <option value="">{% trans "All" %}</option>
{% for key,label in status_choices %} {% for key,label in status_choices %}
<option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}> <option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}>
{{ label }} {{ label }}
@ -64,74 +56,58 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div><!-- Filter: Status End -->
</div>
</div>
<!-- Verantwortliche/r --> <!-- Filter: Responsible -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Responsible" %}</label>
<label class="label">Verantwortliche/r</label> <div class="select is-small is-fullwidth">
<div class="control">
<div class="select is-fullwidth">
<select name="responsible" onchange="this.form.submit()"> <select name="responsible" onchange="this.form.submit()">
<option value="">Alle</option> <option value="">{% trans "All" %}</option>
{% for u in users %} {% for u in responsible_choices %}
<option value="{{ u.id }}" {% if request.GET.responsible == u.id|stringformat:"s" %}selected{% endif %}> <option value="{{ u.id }}" {% if request.GET.responsible == u.id|stringformat:"s" %}selected{% endif %}>
{{ u.get_full_name|default:u.username }} {{ u.get_full_name|default:u.username }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div><!-- Filter: Responsible End -->
<!-- Filter: Reset -->
<div class="column is-2">
<label class="label is-small">&nbsp;</label>
<div class="control">
<a href="{% url 'risks:list_controls' %}" class="button is-small is-light is-fullwidth">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>{% trans "Reset filters" %}</span>
</a>
</div> </div>
</div> </div><!-- Filter: Reset End -->
</div>
</div> </div>
</form> </form>
</section><!-- Filter Section End -->
<h2 class="title is-5">Maßnahmen</h2> <!-- Controls Table -->
<div class="table-container">
<div class="table-container"> <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<table class="table is-bordered is-striped is-hoverable is-fullwidth">
<thead> <thead>
<tr> <tr class="has-background-prosoft">
{% if request.user.is_staff %}<th></th>{% endif %} <th class="has-text-centered">{% trans "No." %}</th>
<th>Maßnahme</th> <th class="has-text-centered">{% trans "Control" %}</th>
<th>Risiken</th> <th class="has-text-centered">{% trans "Related Risk" %}</th>
<th>Verantwortliche/r</th> <th class="has-text-centered">{% trans "Responsible" %}</th>
<th>Status</th> <th class="has-text-centered">{% trans "Status" %}</th>
<th>Frist</th> <th class="has-text-centered">{% trans "Deadline" %}</th>
<th>Link</th> <th class="has-text-centered">{% trans "Link" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if request.user.is_staff %}
<tr>
<td class="has-text-centered">
<a class="icon has-text-success" href="{% url 'admin:risks_risk_add' %}" title="Maßnahme hinzufügen">
<i class="fas fa-add"></i>
</a>
</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endif %}
{% for c in controls %} {% for c in controls %}
<tr> <tr onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;">
{% if request.user.is_staff %} <td class="has-text-centered">{{ c.id }}</td>
<td class="has-text-centered"> <td>{{ c.title }}</td>
<a class="icon has-text-warning" href="{% url 'admin:risks_control_change' c.id %}" title="Maßnahme bearbeiten"> <td>
<i class="fas fa-edit"></i>
</a>
</td>
{% endif %}
<td onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;">{{ c.title }}</td>
<td onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;">
{% if c.risk %} {% if c.risk %}
<a href="{% url 'risks:show_risk' c.risk.id %}" onclick="event.stopPropagation();"> <a href="{% url 'risks:show_risk' c.risk.id %}" onclick="event.stopPropagation();">
{{ c.risk.title }} {{ c.risk.title }}
@ -140,22 +116,22 @@
{% endif %} {% endif %}
</td> </td>
<td onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;"> <td class="has-text-centered">
{% if c.responsible %} {% if c.responsible %}
{{ c.responsible.get_full_name|default:c.responsible.username }} {{ c.responsible.get_full_name|default:c.responsible.username }}
{% else %} {% else %}
{% endif %} {% endif %}
</td> </td>
<td onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;">{{ c.get_status_display }}</td> <td class="has-text-centered">{{ c.get_status_display }}</td>
<td onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;"> <td class="has-text-centered">
{% if c.due_date %} {% if c.due_date %}
{{ c.due_date|date:"d.m.Y" }} {{ c.due_date|date:"d.m.Y" }}
{% else %} {% else %}
{% endif %} {% endif %}
</td> </td>
<td> <td class="has-text-centered">
{% if c.wiki_link %} {% if c.wiki_link %}
<a href="{{ c.wiki_link }}" target="_blank">🔗</a> <a href="{{ c.wiki_link }}" target="_blank">🔗</a>
{% else %} {% else %}
@ -165,13 +141,11 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6" class="has-text-centered has-text-grey">Keine Maßnahmen gefunden</td> <td colspan="7" class="has-text-grey has-text-centered">{% trans "No controls found." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div><!-- Controls Table End -->
</div>
</section>
{% endblock %} {% endblock %}

View file

@ -1,62 +1,54 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n risk_extras %}
{% block crumbs %} {% block crumbs %}
<li><a href="{% url 'risks:list_incidents' %}">Vorfälle</a></li> <li><a href="{% url 'risks:list_incidents' %}">{% trans "Incidents" %}</a></li>
<li><a href="{% url 'admin:risks_incident_add' %}"><span class="icon breadcrumb-add-icon"><i class="fas fa-add"></i></span></a></li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Filter -->
<section class="section">
<div class="box">
<h2 class="title is-5">Auswahl</h2>
<form method="get"> <!-- Filter Section -->
<div class="columns is-multiline"> <section class="section has-background-light py-2">
<form method="get" class="mb-4">
<div class="columns is-multiline is-vcentered">
<!-- Vorfälle --> <!-- Filter: Incident -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Incidents" %}</label>
<label class="label">Vorfall</label> <div class="select is-small is-fullwidth">
<div class="control"> <select name="incident" onchange="this.form.submit()">
<div class="select is-fullwidth"> <option value="">{% trans "All" %}</option>
<select> {% for i in incident_choices %}
<option>Alle</option> <option value="{{ i.id }}" {% if request.GET.incident == i.id|stringformat:"s" %}selected{% endif %}>
{% for i in incidents %}
<option value="{{ i.id }}" {% if request.GET.risk == i.id|stringformat:"s" %}selected{% endif %}>
{{ i.title }} {{ i.title }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div><!-- Filter: Incident End -->
</div>
</div>
<!-- Risiko --> <!-- Filter: Risk -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Risks" %}</label>
<label class="label">Risiko</label> <div class="select is-small is-fullwidth">
<div class="control">
<div class="select is-fullwidth">
<select name="risk" onchange="this.form.submit()"> <select name="risk" onchange="this.form.submit()">
<option value="">Alle</option> <option value="">{% trans "All" %}</option>
{% for r in risks %} {% for r in risk_choices %}
<option value="{{ r.id }}" {% if request.GET.risk == r.id|stringformat:"s" %}selected{% endif %}> <option value="{{ r.id }}" {% if request.GET.risk == r.id|stringformat:"s" %}selected{% endif %}>
{{ r.title }} {{ r.title }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div><!-- Filter: Risk End -->
</div>
</div>
<!-- Status --> <!-- Filter: Status -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Status" %}</label>
<label class="label">Status</label> <div class="select is-small is-fullwidth">
<div class="control">
<div class="select is-fullwidth">
<select name="status" onchange="this.form.submit()"> <select name="status" onchange="this.form.submit()">
<option value="">Alle</option> <option value="">{% trans "All" %}</option>
{% for key,label in status_choices %} {% for key,label in status_choices %}
<option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}> <option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}>
{{ label }} {{ label }}
@ -64,91 +56,76 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div><!-- Filter: Status End -->
</div>
</div>
<!-- Melder --> <!-- Filter: Reporter -->
<div class="column is-3"> <div class="column is-2">
<div class="field"> <label class="label is-small">{% trans "Reported by" %}</label>
<label class="label">Meldende Person</label> <div class="select is-small is-fullwidth">
<div class="control"> <select name="reporter" onchange="this.form.submit()">
<div class="select is-fullwidth"> <option value="">{% trans "All" %}</option>
<select> {% for u in user_choices %}
<option>Alle</option> <option value="{{ u.id }}" {% if request.GET.reporter == u.id|stringformat:"s" %}selected{% endif %}>
{% for u in users %}
<option value="{{ u.id }}" {% if request.GET.responsible == u.id|stringformat:"s" %}selected{% endif %}>
{{ u.get_full_name|default:u.username }} {{ u.get_full_name|default:u.username }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div><!-- Filter: Reporter End -->
<!-- Filter: Reset -->
<div class="column is-2">
<label class="label is-small">&nbsp;</label>
<div class="control">
<a href="{% url 'risks:list_incidents' %}" class="button is-small is-light is-fullwidth">
<span class="icon"><i class="fas fa-undo"></i></span>
<span>{% trans "Reset filters" %}</span>
</a>
</div> </div>
</div> </div><!-- Filter: Reset End -->
</div>
</div> </div>
</form> </form>
</section><!-- Filter Section End -->
<h2 class="title is-5">Vorfälle</h2> <!-- Incidents Table -->
<div class="table-container">
<div class="table-container"> <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<table class="table is-bordered is-striped is-hoverable is-fullwidth">
<thead> <thead>
<tr> <tr class="has-background-prosoft">
{% if request.user.is_staff %}<th></th>{% endif %} <th class="has-text-centered">{% trans "No." %}</th>
<th>Vorfall</th> <th class="has-text-centered">{% trans "Incident" %}</th>
<th>Zugehörige Risiken</th> <th class="has-text-centered">{% trans "Linked Risks" %}</th>
<th>Status</th> <th class="has-text-centered">{% trans "Status" %}</th>
<th>Gemeldet am</th> <th class="has-text-centered has-text-prosoft">{% trans "Reported on" %}</th>
<th>Gemeldet von</th> <th class="has-text-centered has-text-prosoft">{% trans "Reported by" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if request.user.is_staff %}
<tr>
<td class="has-text-centered">
<a class="icon has-text-success" href="{% url 'admin:risks_incident_add' %}" title="Risiko hinzufügen">
<i class="fas fa-add"></i>
</a>
</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endif %}
{% for i in incidents %} {% for i in incidents %}
<tr> <tr onclick="window.location.href='{% url 'risks:show_incident' i.id %}'" style="cursor:pointer;">
{% if request.user.is_staff %} <td>{{ i.id }}</td>
<td class="has-text-centered"> <td>{{ i.title }}</td>
<a class="icon has-text-warning" href="{% url 'admin:risks_incident_change' i.id %}" title="Risiko bearbeiten"> <td>
<i class="fas fa-edit"></i>
</a>
</td>
{% endif %}
<td onclick="window.location.href='{% url 'risks:show_incident' i.id %}'" style="cursor:pointer;">{{ i.title }}</td>
<td onclick="window.location.href='{% url 'risks:show_incident' i.id %}'" style="cursor:pointer;">
{% if i.related_risks.exists %} {% if i.related_risks.exists %}
<ul> <ul>
{% for r in i.related_risks.all %} {% for r in i.related_risks.all %}
<li>{{ r.title }}</li> <li>{{ r.title }}</li>
{% endfor %} {% endfor %}
{% else %}
Noch kein Risiko zugeordnet
{% endif %}
</ul> </ul>
{% else %}
<span class="has-text-grey"></span>
{% endif %}
</td> </td>
<td onclick="window.location.href='{% url 'risks:show_incident' i.id %}'" style="cursor:pointer;">{{ i.get_status_display }}</td> <td>{{ i.get_status_display }}</td>
<td onclick="window.location.href='{% url 'risks:show_incident' i.id %}'" style="cursor:pointer;">{{ i.date_reported|date:"d.m.Y" }}</td> <td>{{ i.date_reported|date:"d.m.Y" }}</td>
<td onclick="window.location.href='{% url 'risks:show_incident' i.id %}'" style="cursor:pointer;">{{ i.reported_by }}</td> <td>{{ i.reported_by|default:"" }}</td>
</tr> </tr>
{% empty %}
<tr><td colspan="6" class="has-text-grey has-text-centered">{% trans "No incidents found." %}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div><!-- Incidents Table End -->
</div>
</section>
{% endblock %} {% endblock %}

View file

@ -2,6 +2,7 @@
{% load i18n risk_extras %} {% load i18n risk_extras %}
{% block crumbs %} {% block crumbs %}
<li><a href="{% url 'risks:list_risks' %}">{% trans "Risk analysis" %}</a></li> <li><a href="{% url 'risks:list_risks' %}">{% trans "Risk analysis" %}</a></li>
<li><a href="{% url 'admin:risks_risk_add' %}"><span class="icon breadcrumb-add-icon"><i class="fas fa-add"></i></span></a></li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -30,9 +30,15 @@
<div class="media-content"> <div class="media-content">
<p> <p>
{% if not n.read %} {% if not n.read %}
<span class="tag is-warning is-light" style="margin-right:.5rem;">{% trans "New" %}</span> <span class="tag is-warning is-light" style="margin-right:.5rem;">
{% trans "New" %}
</span>
{% endif %} {% endif %}
{% if n.get_link %}
<a href="{{ n.get_link }}">{{ n.message }}</a>
{% else %}
{{ n.message }} {{ n.message }}
{% endif %}
</p> </p>
<p class="is-size-7 has-text-grey">{{ n.created_at|date:"d.m.Y H:i" }}</p> <p class="is-size-7 has-text-grey">{{ n.created_at|date:"d.m.Y H:i" }}</p>
</div> </div>