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
media/
staticfiles/
static/
# If you are using WhiteNoise for static file management
static_root/

Binary file not shown.

View file

@ -1,125 +1,150 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from .models import Control, Incident, Notification, NotificationPreference, NotificationRule, Risk, ResidualRisk, User
from .models import (
Control,
Incident,
Notification,
NotificationPreference,
NotificationRule,
Risk,
ResidualRisk,
User,
)
# ---------------------------------------------------------------------------
# Global Admin Settings
# ---------------------------------------------------------------------------
admin.site.site_header = _("Administration")
admin.site.site_title = _("Admin")
admin.site.index_title = _("Administration")
# ---- Inlines ----
class NotificationPreferenceInline(admin.StackedInline):
"""Preferences inline for notifications on User model"""
model = NotificationPreference
can_delete = False
extra = 0
fieldsets = (
(_("Risks"), {"fields": ("risk_created","risk_updated","risk_deleted")}),
(_("Controls"), {"fields": ("control_created","control_updated","control_deleted")}),
(_("Residual risks"), {"fields": ("residual_created","residual_updated","residual_deleted")}),
(_("Reviews"), {"fields": ("review_required","review_completed")}),
(_("Incidents"), {"fields": ("incident_created","incident_updated","incident_deleted")}),
(_("Users"), {"fields": ("user_created","user_deleted")}),
(_("Risks"), {"fields": ("risk_created", "risk_updated", "risk_deleted")}),
(_("Controls"), {"fields": ("control_created", "control_updated", "control_deleted")}),
(_("Residual risks"), {"fields": ("residual_created", "residual_updated", "residual_deleted")}),
(_("Reviews"), {"fields": ("review_required", "review_completed")}),
(_("Incidents"), {"fields": ("incident_created", "incident_updated", "incident_deleted")}),
(_("Users"), {"fields": ("user_created", "user_deleted")}),
)
class ResidualRiskInline(admin.StackedInline):
"""
Inline editor for ResidualRisk, linked one-to-one with Risk
"""
"""Inline editor for ResidualRisk (one-to-one with Risk)"""
model = ResidualRisk
extra = 0
can_delete = False
readonly_fields = ("score", "level", "review_required")
fields = ("likelihood", "impact", "score", "level", "review_required")
class ControlRisksInline(admin.TabularInline):
"""M2M relation between Risk and Control"""
model = Control.risks.through
fk_name = "risk"
extra = 1
autocomplete_fields = ("control",)
class NotificationInline(admin.TabularInline):
"""Inline display of notifications on User model"""
model = Notification
fields = ("created_at", "message", "read", "sent")
readonly_fields = ("created_at", "message")
extra = 0
ordering = ("-created_at",)
# ---- Shared Mixins ----
class ChangedByMixin:
"""Automatically track user who created/changed/deleted"""
def save_model(self, request, obj, form, change):
obj._changed_by = request.user
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
obj._changed_by = request.user
super().delete_model(request, obj)
class RedirectOnSaveMixin:
"""Redirect to detail view instead of staying in admin"""
redirect_url_name = None
def response_add(self, request, obj, post_url_continue=None):
return HttpResponseRedirect(reverse(self.redirect_url_name, args=[obj.pk]))
def response_change(self, request, obj):
return HttpResponseRedirect(reverse(self.redirect_url_name, args=[obj.pk]))
# ---------------------------------------------------------------------------
# Risk
# ---------------------------------------------------------------------------
@admin.register(Risk)
class RiskAdmin(admin.ModelAdmin):
list_display = (
"title",
"owner_name",
"status",
"score",
"level",
"likelihood",
"impact",
"follow_up",
)
class RiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
redirect_url_name = "risks:show_risk"
list_display = ("title", "owner_name", "status", "score", "level", "likelihood", "impact", "follow_up")
list_filter = ("status", "level", "likelihood", "impact", "owner")
search_fields = ("title", "asset", "process", "category")
inlines = [ResidualRiskInline, ControlRisksInline]
def owner_name(self, obj):
if not obj.owner:
return "-"
return obj.owner.get_full_name() or obj.owner.username
list_filter = ("status", "level", "likelihood", "impact", "owner")
search_fields = ("title", "asset", "process", "category")
inlines = [ResidualRiskInline, ControlRisksInline]
def save_model(self, request, obj, form, change):
obj._changed_by = request.user
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
obj._changed_by = request.user
super().delete_model(request, obj)
# ---------------------------------------------------------------------------
# Residual Risk
# ---------------------------------------------------------------------------
@admin.register(ResidualRisk)
class ResidualRiskAdmin(admin.ModelAdmin):
list_display = (
"risk",
"score",
"level",
"likelihood",
"impact",
"review_required"
)
class ResidualRiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
redirect_url_name = "risks:show_risk"
list_display = ("risk", "score", "level", "likelihood", "impact", "review_required")
list_filter = ("level", "likelihood", "impact", "review_required")
def save_model(self, request, obj, form, change):
obj._changed_by = request.user
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
obj._changed_by = request.user
super().delete_model(request, obj)
# ---------------------------------------------------------------------------
# Control
# ---------------------------------------------------------------------------
@admin.register(Control)
class ControlAdmin(admin.ModelAdmin):
class ControlAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
redirect_url_name = "risks:show_control"
list_display = ("title", "status", "due_date", "responsible")
list_filter = ("status", "due_date")
autocomplete_fields = ("risks", "responsible",)
autocomplete_fields = ("risks", "responsible")
search_fields = ("title", "description")
def save_model(self, request, obj, form, change):
obj._changed_by = request.user
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
obj._changed_by = request.user
super().delete_model(request, obj)
# ---------------------------------------------------------------------------
# Incident
# ---------------------------------------------------------------------------
@admin.register(Incident)
class IncidentAdmin(admin.ModelAdmin):
class IncidentAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
redirect_url_name = "risks:show_incident"
list_display = ("title", "date_reported", "reported_by", "status")
list_filter = ("status", "date_reported", "reported_by")
filter_horizontal = ("related_risks",)
search_fields = ("title", "description")
autocomplete_fields = ("related_risks",)
filter_horizontal = ("related_risks",)
def save_model(self, request, obj, form, change):
obj._changed_by = request.user
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
obj._changed_by = request.user
super().delete_model(request, obj)
# ---------------------------------------------------------------------------
# Notification
# ---------------------------------------------------------------------------
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
date_hierarchy = "created_at"
list_display = ("id", "created_at", "user_display", "short_message", "read", "sent")
list_display_links = ("id", "short_message")
@ -129,21 +154,18 @@ class NotificationAdmin(admin.ModelAdmin):
list_editable = ("read", "sent")
ordering = ("-created_at",)
autocomplete_fields = ("user",)
actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"]
@admin.display(description=_("User"))
def user_display(self, obj):
if not obj.user:
return ""
return obj.user.get_full_name() or obj.user.username
return obj.user.get_full_name() if obj.user else ""
@admin.display(description=_("Message"))
def short_message(self, obj):
msg = obj.message or ""
return (msg[:80] + "") if len(msg) > 80 else msg
# Bulk-Aktionen
actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"]
# Bulk actions
@admin.action(description=_("Mark selected as read"))
def mark_as_read(self, request, queryset):
n = queryset.update(read=True)
@ -164,13 +186,8 @@ class NotificationAdmin(admin.ModelAdmin):
n = queryset.update(sent=False)
self.message_user(request, _("%(n)d notifications marked as unsent.") % {"n": n})
class NotificationInline(admin.TabularInline):
model = Notification
fields = ("created_at", "message", "read", "sent")
readonly_fields = ("created_at", "message")
extra = 0
ordering = ("-created_at",)
# ---- Notification Rule ----
@admin.register(NotificationRule)
class NotificationRuleAdmin(admin.ModelAdmin):
list_display = ("kind", "enabled_in_app", "enabled_email", "to_owner", "to_staff", "short_extras")
@ -184,14 +201,19 @@ class NotificationRuleAdmin(admin.ModelAdmin):
txt = (obj.extra_recipients or "").replace("\n", ", ")
return (txt[:50] + "") if len(txt) > 50 else txt
# ---------------------------------------------------------------------------
# User (extension)
# ---------------------------------------------------------------------------
@admin.register(User)
class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + (
(_("SSO Information"), {"fields": ("is_sso_user",)}),
)
list_display = ("username", "email", "is_staff", "is_superuser", "is_sso_user",
"owned_risks_count", "responsible_controls_count")
list_display = (
"username", "email", "is_staff", "is_superuser", "is_sso_user",
"owned_risks_count", "responsible_controls_count"
)
inlines = [NotificationInline, NotificationPreferenceInline]
def owned_risks_count(self, obj):

View file

@ -1,20 +1,34 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
# ---------------------------------------------------------------------------
# Risks AppConfig
# ---------------------------------------------------------------------------
class RisksConfig(AppConfig):
"""App configuration for the risks module."""
default_auto_field = "django.db.models.BigAutoField"
name = "risks"
verbose_name = _("Risk Management")
def ready(self):
import risks.signals
"""
Initialize signals and ensure NotificationRules exist for all
NotificationKind choices. Ignores database errors during migration.
"""
import risks.signals # noqa: F401 (ensure signal handlers are loaded)
try:
from django.db.utils import OperationalError, ProgrammingError
from .models import NotificationRule, NotificationKind
# Test DB availability
NotificationRule.objects.count()
except (OperationalError, ProgrammingError):
# Happens during migrate or before tables exist
return
# Ensure all NotificationKind values have a corresponding NotificationRule
existing = set(NotificationRule.objects.values_list("kind", flat=True))
for kind, _label in NotificationKind.choices:
if kind not in existing:

View file

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

View file

@ -1,7 +1,14 @@
# ---------------------------------------------------------------------------
# unread_notifications_count()
# ---------------------------------------------------------------------------
def unread_notifications_count(request):
"""
Context processor:
Returns the number of unread notifications for the current user.
"""
if not request.user.is_authenticated:
return {"notifications_unread_count": 0}
from .models import Notification
return {
"notifications_unread_count": Notification.objects.filter(user=request.user, read=False).count()
}
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 .models import Risk, Control, Incident, ResidualRisk
class RiskStatusForm(forms.ModelForm):
# ---------------------------------------------------------------------------
# Base form for status field (DRY for Risk/Control/Incident)
# ---------------------------------------------------------------------------
class BaseStatusForm(forms.ModelForm):
"""Abstract base form for models with a 'status' field."""
class Meta:
fields = ["status"]
labels = {"status": _("Status")}
widgets = {"status": forms.Select(attrs={"class": "select"})}
# ---------------------------------------------------------------------------
# RiskStatusForm
# ---------------------------------------------------------------------------
class RiskStatusForm(BaseStatusForm):
class Meta(BaseStatusForm.Meta):
model = Risk
fields = ["status"]
labels = {"status": _("Status")}
widgets = {"status": forms.Select(attrs={"class": "select"})}
class ControlStatusForm(forms.ModelForm):
class Meta:
# ---------------------------------------------------------------------------
# ControlStatusForm
# ---------------------------------------------------------------------------
class ControlStatusForm(BaseStatusForm):
class Meta(BaseStatusForm.Meta):
model = Control
fields = ["status"]
labels = {"status": _("Status")}
widgets = {"status": forms.Select(attrs={"class": "select"})}
class IncidentStatusForm(forms.ModelForm):
class Meta:
# ---------------------------------------------------------------------------
# IncidentStatusForm
# ---------------------------------------------------------------------------
class IncidentStatusForm(BaseStatusForm):
class Meta(BaseStatusForm.Meta):
model = Incident
fields = ["status"]
labels = {"status": _("Status")}
widgets = {"status": forms.Select(attrs={"class": "select"})}
# ---------------------------------------------------------------------------
# ResidualReviewForm
# ---------------------------------------------------------------------------
class ResidualReviewForm(forms.ModelForm):
class Meta:
model = ResidualRisk

View file

@ -1,9 +1,18 @@
from .audit_context import set_current_user
# ---------------------------------------------------------------------------
# AuditUserMiddleware
# ---------------------------------------------------------------------------
class AuditUserMiddleware:
"""
Middleware to store the current request.user in thread-local storage.
Used for auditing (_changed_by, etc.).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Save current user for this request in thread-local storage
set_current_user(getattr(request, "user", None))
return self.get_response(request)

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

View file

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

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

View file

@ -4,25 +4,43 @@ from . import views
app_name = "risks"
urlpatterns = [
# -----------------------------------------------------------------------
# Dashboard
# -----------------------------------------------------------------------
path("", views.dashboard, name="dashboard"),
path("risks/index", views.dashboard, name="index"),
# -----------------------------------------------------------------------
# Risks
# -----------------------------------------------------------------------
path("risks/list_risks", views.list_risks, name="list_risks"),
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/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/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
# -----------------------------------------------------------------------
path("notifications/", views.notifications, name="notifications"),
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"),
# 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.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from .models import AuditLog, Notification,NotificationRule, NotificationKind, Risk, ResidualRisk
from typing import Iterable, Optional
from .models import (
AuditLog, Notification, NotificationRule,
NotificationKind, Risk, ResidualRisk,
)
User = get_user_model()
# ---------------------------------------------------------------------------
# model_diff()
# ---------------------------------------------------------------------------
def model_diff(old, new, fields=None):
"""
Compare two model instances and return a dict of changed fields.
@ -24,32 +34,35 @@ def model_diff(old, new, fields=None):
for field_name in fields:
old_value = getattr(old, field_name, None)
new_value = getattr(new, field_name, None)
if old_value != new_value:
changes[field_name] = {"old": old_value, "new": new_value}
return changes
# ---------------------------------------------------------------------------
# check_risk_followups()
# ---------------------------------------------------------------------------
def check_risk_followups():
"""
Check if follow ups need attention and create notifications.
Ensures no duplicate notifications per risk per day
Check if follow-ups need attention and create notifications.
Ensures no duplicate notifications per risk per day.
"""
today = now().date()
risks = Risk.objects.filter(follow_up__lte=today).select_related("owner")
for risk in risks:
# Risk-Status auf review_required setzen (nicht überschreiben, wenn bereits closed)
if risk.status != "closed" and risk.status != "review_required":
# Status aktualisieren (außer wenn bereits closed/review_required)
if risk.status not in ("closed", "review_required"):
Risk.objects.filter(pk=risk.pk).update(status="review_required")
# ResidualRisk-Objekt sicherstellen und Review-Flag setzen
resid, created = ResidualRisk.objects.get_or_create(risk=risk)
# ResidualRisk sicherstellen + Review-Flag setzen
resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
if not resid.review_required:
resid.review_required = True
resid.save()
# Notification an Stakeholder
# Notification (einmalig pro Risk/Tag)
message = _("Follow-up reached: review required for risk '{t}'").format(t=risk.title)
notification, created = Notification.objects.get_or_create(
user=risk.owner,
@ -58,70 +71,77 @@ def check_risk_followups():
)
if created:
AuditLog.objects.create(
user=None, action="create", model="Notification", object_id=notification.pk,
changes={"message": notification.message, "user": risk.owner.username if risk.owner else None},
user=None,
action="create",
model="Notification",
object_id=notification.pk,
changes={
"message": notification.message,
"user": risk.owner.username if risk.owner else None,
},
)
notify_event(
NotificationKind.RISK_REVIEW_REQUIRED,
message=_("Follow-up reached: review required for risk '{t}'").format(t=risk.title),
message=message,
users=[risk.owner] if risk.owner_id else None,
)
# ---------------------------------------------------------------------------
# _split_emails()
# ---------------------------------------------------------------------------
def _split_emails(value: str) -> list[str]:
"""Normalize a comma/newline-separated list of emails into a clean list."""
if not value:
return []
raw = value.replace("\n", ",").split(",")
return [e.strip() for e in raw if "@" in e and e.strip()]
# ---------------------------------------------------------------------------
# notify_event()
# ---------------------------------------------------------------------------
def notify_event(kind: str, *, message: str, users: Optional[Iterable[User]] = None):
"""
Generates in-app notifications and/or emails depending on the rule.
Generates in-app notifications and/or emails depending on the NotificationRule.
- users: Basic recipients (owner/responsible/reporter) can be None.
- staff/extra recipients are added from the rule.
"""
rule = NotificationRule.objects.filter(kind=kind).first()
# Fallback: without rule → only in-app
# Defaults (no rule → in-app only)
enabled_in_app = True
enabled_email = False
to_staff = False
recipients_users = set()
extra_emails = []
recipients_users = set()
# Base recipients
if users:
for u in users:
if u and getattr(u, "is_active", False):
recipients_users.add(u)
recipients_users.update(u for u in users if u and getattr(u, "is_active", False))
# Rule overrides
if rule:
enabled_in_app = rule.enabled_in_app
enabled_email = rule.enabled_email
if rule.to_staff:
to_staff = True
recipients_users.update(User.objects.filter(is_staff=True, is_active=True))
extra_emails = _split_emails(rule.extra_recipients)
if to_staff:
for u in User.objects.filter(is_staff=True, is_active=True):
recipients_users.add(u)
# In-App
# In-App Notifications
if enabled_in_app:
for u in recipients_users:
Notification.objects.create(user=u, message=message)
# E-Mail
# Email Notifications
if enabled_email:
emails = [u.email for u in recipients_users if u and u.email] + extra_emails
emails = list(dict.fromkeys(emails)) # de-dupe, Reihenfolge erhalten
emails = list(dict.fromkeys(emails)) # de-dupe, preserve order
if emails:
subject = _("Notification")
body = message
send_mail(
subject,
body,
_("Notification"),
message,
getattr(settings, "DEFAULT_FROM_EMAIL", "webmaster@localhost"),
emails,
fail_silently=True, # im Zweifel nicht crashen
fail_silently=True, # 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.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.db.models import Count, Q
from django.db.models import Count
from django.http import HttpResponseForbidden
from django.shortcuts import redirect, render, get_object_or_404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from collections import Counter, defaultdict
from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm
from .models import Risk, Control, ResidualRisk, AuditLog, Incident, Notification
from .serializers import ControlSerializer, RiskSerializer, ResidualRiskSerializer, UserSerializer, AuditSerializer, IncidentSerializer
from .serializers import (
ControlSerializer, RiskSerializer, ResidualRiskSerializer,
UserSerializer, AuditSerializer, IncidentSerializer,
)
User = get_user_model()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _can_edit_risk(user, risk: Risk) -> bool:
return bool(user.is_staff or (risk.owner_id and risk.owner_id == user.id))
def _can_edit_control(user, control: Control) -> bool:
return bool(user.is_staff or (control.responsible_id and control.responsible_id == user.id))
def _can_edit_incident(user, incident: Incident) -> bool:
return bool(user.is_staff or (incident.reported_by_id and incident.reported_by_id == user.id))
# ---------------------------------------------------------------------------
# API
# API ViewSets
# ---------------------------------------------------------------------------
class RiskViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Risks.
Provides CRUD operations.
"""
class _ChangedByMixin:
"""Mixin to track user who changed an object."""
def perform_create(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
def perform_update(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
class RiskViewSet(_ChangedByMixin, viewsets.ModelViewSet):
"""API endpoint for managing Risks."""
queryset = Risk.objects.all()
serializer_class = RiskSerializer
permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
def perform_update(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
class ControlViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Controls.
Provides CRUD operations.
"""
class ControlViewSet(_ChangedByMixin, viewsets.ModelViewSet):
"""API endpoint for managing Controls."""
queryset = Control.objects.all()
serializer_class = ControlSerializer
permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
def perform_update(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
class ResidualRiskViewSet(viewsets.ModelViewSet):
"""
API endpoint for Residual risks.
"""
"""API endpoint for Residual Risks."""
queryset = ResidualRisk.objects.all()
serializer_class = ResidualRiskSerializer
permission_classes = [IsAuthenticated]
class UserViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for listing users and their responsibilities.
"""
class UserViewSet(_ChangedByMixin, viewsets.ReadOnlyModelViewSet):
"""API endpoint for listing users and their responsibilities."""
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
def perform_update(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
class AuditViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for view audit logging.
"""
"""API endpoint for viewing audit logs."""
queryset = AuditLog.objects.all()
serializer_class = AuditSerializer
permission_classes = [IsAuthenticated]
class IncidentViewSet(viewsets.ModelViewSet):
"""
API endpoint for listing incidents and its related risks.
"""
class IncidentViewSet(_ChangedByMixin, viewsets.ModelViewSet):
"""API endpoint for listing incidents and their related risks."""
queryset = Incident.objects.all()
serializer_class = IncidentSerializer
permission_classes = [IsAuthenticated]
@ -106,242 +95,180 @@ class IncidentViewSet(viewsets.ModelViewSet):
instance = serializer.save(reported_by=self.request.user)
instance._changed_by = self.request.user
def perform_update(self, serializer):
instance = serializer.save()
instance._changed_by = self.request.user
# ---------------------------------------------------------------------------
# Web => Risks, Controls, Incidents
# Web Views: Risks
# ---------------------------------------------------------------------------
@login_required
def list_risks(request):
"""List all risks with filters and sorting."""
qs = Risk.objects.all().select_related("owner", "residual_risk")
# Filter
risk_id = request.GET.get("risk")
control_id = request.GET.get("control")
owner_id = request.GET.get("owner")
category = request.GET.get("category")
asset = request.GET.get("asset")
process = request.GET.get("process")
if risk_id:
qs = qs.filter(id=risk_id)
if control_id:
qs = qs.filter(controls__id=control_id)
if owner_id:
qs = qs.filter(owner_id=owner_id)
if category:
qs = qs.filter(category=category)
if asset:
qs = qs.filter(asset=asset)
if process:
qs = qs.filter(process=process)
# Filters
filters = {
"id": request.GET.get("risk"),
"controls__id": request.GET.get("control"),
"owner_id": request.GET.get("owner"),
"category": request.GET.get("category"),
"asset": request.GET.get("asset"),
"process": request.GET.get("process"),
}
qs = qs.filter(**{k: v for k, v in filters.items() if v})
# Sorting
sort = request.GET.get("sort") or "title"
direction = request.GET.get("dir") or "asc"
if direction == "desc":
qs = qs.order_by(f"-{sort}")
else:
qs = qs.order_by(sort)
qs = qs.order_by(f"-{sort}" if direction == "desc" else sort)
risks = qs.distinct()
risk_choices = Risk.objects.all().order_by("title")
control_choices = Control.objects.all().order_by("title")
owner_choices = User.objects.filter(owned_risks__isnull=False).distinct().order_by("username")
category_choices = (Risk.objects.exclude(category__isnull=True)
.exclude(category__exact="")
.values_list("category", flat=True)
.distinct()
.order_by("category"))
asset_choices = (Risk.objects.exclude(asset__isnull=True)
.exclude(asset__exact="")
.values_list("asset", flat=True)
.distinct()
.order_by("asset"))
process_choices = (Risk.objects.exclude(process__isnull=True)
.exclude(process__exact="")
.values_list("process", flat=True)
.distinct()
.order_by("process"))
return render(request, "risks/list_risks.html", {
"risks": risks,
"risk_choices": risk_choices,
"control_choices": control_choices,
"owner_choices": owner_choices,
"category_choices": category_choices,
"asset_choices": asset_choices,
"process_choices": process_choices,
"risk_choices": Risk.objects.all().order_by("title"),
"control_choices": Control.objects.all().order_by("title"),
"owner_choices": User.objects.filter(owned_risks__isnull=False).distinct().order_by("username"),
"category_choices": (Risk.objects.exclude(category__isnull=True).exclude(category__exact="")
.values_list("category", flat=True).distinct().order_by("category")),
"asset_choices": (Risk.objects.exclude(asset__isnull=True).exclude(asset__exact="")
.values_list("asset", flat=True).distinct().order_by("asset")),
"process_choices": (Risk.objects.exclude(process__isnull=True).exclude(process__exact="")
.values_list("process", flat=True).distinct().order_by("process")),
"current_sort": sort,
"current_dir": direction,
})
@login_required
def show_risk(request, id):
"""
View for single risk
"""
"""Show single risk details + logs."""
risk = get_object_or_404(
Risk.objects.select_related("residual_risk", "owner").prefetch_related("controls"),
pk=id,
)
ct = ContentType.objects.get_for_model(Risk)
logs = LogEntry.objects.filter(content_type=ct, object_id=risk.pk).order_by("-action_time")
return render(request, "risks/item_risk.html", {"risk": risk, "logs": logs})
# ---------------------------------------------------------------------------
# Web Views: Controls
# ---------------------------------------------------------------------------
@login_required
def list_controls(request):
"""
View for listing all Controls
"""
"""List all controls with filters."""
qs = Control.objects.all().select_related("responsible")
control_id = request.GET.get("control")
risk_id = request.GET.get("risk")
status = request.GET.get("status")
responsible_id = request.GET.get("responsible")
if control_id:
qs = qs.filter(id=control_id)
if risk_id:
qs = qs.filter(risks__id=risk_id) # FIX
if status:
qs = qs.filter(status=status)
if responsible_id:
qs = qs.filter(responsible_id=responsible_id)
filters = {
"id": request.GET.get("control"),
"risks__id": request.GET.get("risk"),
"status": request.GET.get("status"),
"responsible_id": request.GET.get("responsible"),
}
qs = qs.filter(**{k: v for k, v in filters.items() if v})
controls = qs.order_by("title").distinct()
risks = Risk.objects.all().order_by("title")
users = User.objects.filter(responsible_controls__isnull=False).distinct().order_by("username")
return render(request, "risks/list_controls.html", {
"controls": controls,
"risks": risks,
"users": users,
"control_choices": Control.objects.all().order_by("title"),
"risk_choices": Risk.objects.all().order_by("title"),
"responsible_choices": User.objects.filter(responsible_controls__isnull=False).distinct().order_by("username"),
"status_choices": Control.STATUS_CHOICES,
})
@login_required
def show_control(request, id):
"""Show single control details + logs."""
control = get_object_or_404(Control, pk=id)
ct = ContentType.objects.get_for_model(Control)
logs = LogEntry.objects.filter(
content_type=ct,
object_id=control.pk
).order_by("-action_time")
logs = LogEntry.objects.filter(content_type=ct, object_id=control.pk).order_by("-action_time")
return render(request, "risks/item_control.html", {"control": control, "logs": logs})
# ---------------------------------------------------------------------------
# Web Views: Incidents
# ---------------------------------------------------------------------------
@login_required
def list_incidents(request):
"""
View for listing all Incidents
"""
"""List all incidents with filters."""
qs = Incident.objects.all().select_related("reported_by").prefetch_related("related_risks")
risk_id = request.GET.get("risk")
status = request.GET.get("status")
reported_by = request.GET.get("reported_by")
if risk_id:
qs = qs.filter(related_risks__id=risk_id) # FIX
if status:
qs = qs.filter(status=status)
if reported_by:
qs = qs.filter(reported_by=reported_by)
filters = {
"related_risks__id": request.GET.get("risk"),
"status": request.GET.get("status"),
"reported_by": request.GET.get("reported_by"),
}
qs = qs.filter(**{k: v for k, v in filters.items() if v})
incidents = qs.order_by("title").distinct()
risks = Risk.objects.all().order_by("title")
users = User.objects.filter(incidents__isnull=False).distinct().order_by("username") # sinnvoller
return render(request, "risks/list_incidents.html", {
"incidents": incidents,
"risks": risks,
"users": users,
"incident_choices": incidents,
"risk_choices": Risk.objects.all().order_by("title"),
"user_choices": User.objects.filter(incidents__isnull=False).distinct().order_by("username"),
"status_choices": Incident.STATUS_CHOICES,
})
@login_required
def show_incident(request, id):
"""Show single incident details + logs."""
incident = get_object_or_404(Incident, pk=id)
ct = ContentType.objects.get_for_model(Incident)
logs = LogEntry.objects.filter(
content_type=ct,
object_id=incident.pk
).order_by("-action_time")
logs = LogEntry.objects.filter(content_type=ct, object_id=incident.pk).order_by("-action_time")
return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs})
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
@login_required
def dashboard(request):
"""
Dashboardview with KPIs
"""
"""Dashboard view with KPIs."""
# Risikoübersicht
risks_total = Risk.objects.count()
risks_by_level = Risk.objects.values('level').annotate(count=Count('id'))
risks_by_level = Risk.objects.values("level").annotate(count=Count("id"))
# CIA-Zähler für MultiSelectField
risks_cia = Risk.objects.values_list('cia', flat=True)
risks_cia = Risk.objects.values_list("cia", flat=True)
cia_counter = Counter()
for cia_list in risks_cia:
if isinstance(cia_list, list): # MultiSelectField gibt Liste zurück
if isinstance(cia_list, list):
for c in cia_list:
cia_counter[c] += 1
elif cia_list: # Falls irgendwie noch ein String drin ist
elif cia_list:
cia_counter[cia_list] += 1
# Residualrisiken
residual_review_required = ResidualRisk.objects.filter(review_required=True).count()
# Kontrollen
controls_by_status = Control.objects.values('status').annotate(count=Count('id'))
# Incidents
incidents_status = Incident.objects.values('status').annotate(count=Count('id'))
# Benachrichtigungen
notifications_unread = Notification.objects.filter(user=request.user, read=False).count()
print(type(cia_counter), cia_counter)
# Context für Template
context = {
'risks_total': risks_total,
'risks_by_level': risks_by_level,
'risks_by_cia': dict(cia_counter), # <-- hier Counter in dict umwandeln
'residual_review_required': residual_review_required,
'controls_by_status': controls_by_status,
'incidents_status': incidents_status,
'notifications_unread': notifications_unread,
"risks_total": risks_total,
"risks_by_level": risks_by_level,
"risks_by_cia": dict(cia_counter),
"residual_review_required": ResidualRisk.objects.filter(review_required=True).count(),
"controls_by_status": Control.objects.values("status").annotate(count=Count("id")),
"incidents_status": Incident.objects.values("status").annotate(count=Count("id")),
"notifications_unread": Notification.objects.filter(user=request.user, read=False).count(),
}
return render(request, 'risks/dashboard.html', context)
return render(request, "risks/dashboard.html", context)
# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------
@login_required
def notifications(request):
"""Eigene Benachrichtigungen ansehen + filtern"""
"""View own notifications with optional filter."""
flt = request.GET.get("filter", "unread")
qs = Notification.objects.filter(user=request.user).order_by("-created_at")
if flt == "unread":
qs = qs.filter(read=False)
# Einfache Pagination (optional)
return render(request, "risks/notifications.html", {
"notifications": qs,
"filter": flt,
})
return render(request, "risks/notifications.html", {"notifications": qs, "filter": flt})
@login_required
def notification_mark_read(request, pk):
"""Mark single notification as read."""
if request.method != "POST":
return HttpResponseForbidden()
notif = get_object_or_404(Notification, pk=pk, user=request.user)
@ -350,19 +277,23 @@ def notification_mark_read(request, pk):
messages.success(request, _("Notification marked as read."))
return redirect(request.META.get("HTTP_REFERER") or "risks:notifications")
@login_required
def notification_mark_all_read(request):
"""Mark all notifications as read."""
if request.method != "POST":
return HttpResponseForbidden()
Notification.objects.filter(user=request.user, read=False).update(read=True)
messages.success(request, _("All notifications marked as read."))
return redirect("risks:notifications")
# ---------------------------------------------------------------------------
# Status Updates
# ---------------------------------------------------------------------------
@login_required
def update_risk_status(request, id):
"""Update risk status."""
risk = get_object_or_404(Risk, pk=id)
if not _can_edit_risk(request.user, risk):
return HttpResponseForbidden()
@ -375,8 +306,10 @@ def update_risk_status(request, id):
messages.success(request, _("Risk status updated."))
return redirect("risks:show_risk", id=risk.pk)
@login_required
def update_control_status(request, id):
"""Update control status."""
control = get_object_or_404(Control, pk=id)
if not _can_edit_control(request.user, control):
return HttpResponseForbidden()
@ -389,8 +322,10 @@ def update_control_status(request, id):
messages.success(request, _("Control status updated."))
return redirect("risks:show_control", id=control.pk)
@login_required
def update_incident_status(request, id):
"""Update incident status."""
incident = get_object_or_404(Incident, pk=id)
if not _can_edit_incident(request.user, incident):
return HttpResponseForbidden()
@ -403,13 +338,14 @@ def update_incident_status(request, id):
messages.success(request, _("Incident status updated."))
return redirect("risks:show_incident", id=incident.pk)
@login_required
def update_residual_review(request, risk_id):
"""Review-Flag (Restrisiko) setzen/lösen"""
"""Toggle residual risk review flag."""
risk = get_object_or_404(Risk, pk=risk_id)
if not _can_edit_risk(request.user, risk):
return HttpResponseForbidden()
residual, created_resid = ResidualRisk.objects.get_or_create(risk=risk)
residual, _ = ResidualRisk.objects.get_or_create(risk=risk)
if request.method == "POST":
form = ResidualReviewForm(request.POST, instance=residual)
if form.is_valid():
@ -420,11 +356,12 @@ def update_residual_review(request, risk_id):
return redirect("risks:show_risk", id=risk.pk)
# ---------------------------------------------------------------------------
# Risk Matrix
# ---------------------------------------------------------------------------
def risk_matrix(request):
risks = (Risk.objects
.select_related("owner", "residual_risk") # wichtig fürs Netto
.all())
"""Show gross/net risk matrix."""
risks = Risk.objects.select_related("owner", "residual_risk").all()
impacts = sorted(Risk.IMPACT_CHOICES, key=lambda x: x[0])
likelihoods = sorted(Risk.LIKELIHOOD_CHOICES, key=lambda x: x[0])
@ -432,9 +369,7 @@ def risk_matrix(request):
net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts}
for r in risks:
# Brutto platzieren
gross_matrix[r.impact][r.likelihood].append(r)
# Netto (falls vorhanden) platzieren
rr = getattr(r, "residual_risk", None)
if rr:
net_matrix[rr.impact][rr.likelihood].append(r)

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

View file

@ -1,159 +1,133 @@
{% extends "base.html" %}
{% load i18n risk_extras %}
{% 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>
{% 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 %}
<form method="post" action="{% url 'risks:update_control_status' control.id %}" class="card-header-icon" style="margin-left:auto;">
{% csrf_token %}
<div class="field has-addons">
<div class="control">
<div class="select is-small">
<select name="status">
{% for value,label in control.STATUS_CHOICES %}
<option value="{{ value }}" {% if control.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</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>
{% block content %}
<!-- ERP-style tabs -->
<div class="erp-tabs">
<a class="is-active" data-tab="overview">{% trans "Overview" %}</a>
<a data-tab="risks">{% trans "Linked Risks" %}</a>
<a data-tab="history">{% trans "History" %}</a>
<!-- Action Icons -->
<div class="buttons">
<a href="{% url 'admin:risks_control_change' control.pk %}" class="button is-small is-warning" title="{% trans 'Edit Control' %}">
<span class="icon"><i class="fas fa-edit"></i></span>
</a>
<a class="card-header-icon has-text-danger" href="{% url 'admin:risks_control_delete' control.pk %}" title="Maßnahme Löschen (WARNUNG!)">
<span class="icon"><i class="fas fa-trash" aria-hidden="true"></i></span>
<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"></i></span>
</a>
{% endif %}
</header>
<!-- Inhalt Überblick-->
</div>
</div>
<!-- Tab: Overview -->
<div class="tab-panel" data-tab="overview">
<div class="card">
<div class="card-content">
<div class="columns is-multiline">
<div class="column is-half">
<p><strong>Verantwortliche/r:</strong> {{ control.responsible|default:"-" }}</p>
<p><strong><a>Zum Wiki Eintrag</a></strong></p>
<p><strong>{% trans "Control" %}:</strong> {{ control.title }}</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 class="column is-half">
<p><strong>Erstellt am:</strong> {{ control.created_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 "Created at" %}:</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>{% trans "Deadline" %}:</strong> {{ control.due_date|date:"d.m.Y"|default:"" }}</p>
</div>
</div>
</div> <!-- Ende Inhalt Überblick -->
</div> <!-- Ende Überblick -->
<section>
<h3 class="title is-6">{% trans "Description" %}</h3>
<p>{{ control.description|default:"" }}</p>
</section>
</div>
</div>
</div><!-- Overview Tab End -->
<!-- Risiken -->
<div class="card">
<header class="card-header">
<p class="card-header-title">Verknüpfte Risiken</p>
</header>
<div class="card-content">
{% if control.risks %}
<table class="table is-striped is-hoverable is-fullwidth">
<!-- Tab: Linked Risks -->
<div class="tab-panel is-hidden" data-tab="risks">
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Titel</th>
<th>Risikoeigner</th>
<th>Kategorie</th>
<th>Asset</th>
<th>Prozess</th>
<tr class="has-background-prosoft">
<th class="has-text-centered">{% trans "Risk" %}</th>
<th class="has-text-centered">{% trans "Owner" %}</th>
<th class="has-text-centered">{% trans "Category" %}</th>
<th class="has-text-centered">{% trans "Asset" %}</th>
<th class="has-text-centered">{% trans "Process" %}</th>
</tr>
</thead>
<tbody>
{% 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>
{% if risk.owner %}
{{ risk.owner }}
{% else %}
{% 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>
<td class="has-text-centered">{{ risk.owner|user_display|default:"" }}</td>
<td class="has-text-centered">{{ risk.category|default:"" }}</td>
<td class="has-text-centered">{{ risk.asset|default:"" }}</td>
<td class="has-text-centered">{{ risk.process|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="has-text-grey has-text-centered">{% trans "No linked risks." %}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="has-text-grey">Keine Verknüpften Risiken.</p>
{% endif %}
</div>
</div>
<!-- Ende Maßnahmen -->
</div><!-- Linked Risks Tab End -->
<!-- Historie -->
<div class="card">
<header class="card-header">
<p class="card-header-title">Historie</p>
</header>
<div class="card-content">
{% if logs %}
<table class="table is-striped is-fullwidth">
<!-- Tab: History -->
<div class="tab-panel is-hidden" data-tab="history">
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Benutzer</th>
<th>Aktion</th>
<tr class="has-background-prosoft">
<th class="has-text-centered">{% trans "Time" %}</th>
<th class="has-text-centered">{% trans "User" %}</th>
<th class="has-text-centered">{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ 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.action_time|date:"d.m.Y H:i" }}</td>
<td class="has-text-centered">{{ log.user.get_full_name|default:log.user.username }}</td>
<td>{{ log.get_change_message }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="has-text-grey has-text-centered">{% trans "No history found." %}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="has-text-grey">Keine Historie vorhanden.</p>
{% endif %}
</div>
</div> <!-- Ende Historie -->
</div><!-- History Tab End -->
<br><br>
</div>
<!-- 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>
{% endblock %}

View file

@ -1,157 +1,126 @@
{% extends "base.html" %}
{% load i18n risk_extras %}
{% 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>
{% 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 %}
<form method="post" action="{% url 'risks:update_incident_status' incident.id %}" class="card-header-icon" style="margin-left:auto;">
{% csrf_token %}
<div class="field has-addons">
<div class="control">
<div class="select is-small">
<select name="status">
{% for value,label in incident.STATUS_CHOICES %}
<option value="{{ value }}" {% if incident.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</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>
{% block content %}
<!-- ERP-style tabs -->
<div class="erp-tabs">
<a class="is-active" data-tab="overview">{% trans "Overview" %}</a>
<a data-tab="risks">{% trans "Linked Risks" %}</a>
<a data-tab="history">{% trans "History" %}</a>
<!-- Action Icons -->
<div class="buttons">
<a href="{% url 'admin:risks_incident_change' incident.pk %}" class="button is-small is-warning" title="{% trans 'Edit Incident' %}">
<span class="icon"><i class="fas fa-edit"></i></span>
</a>
<a class="card-header-icon has-text-danger" href="{% url 'admin:risks_incident_delete' incident.pk %}" title="Vorfall Löschen (WARNUNG!)">
<span class="icon"><i class="fas fa-trash" aria-hidden="true"></i></span>
<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"></i></span>
</a>
{% endif %}
</header>
<!-- Inhalt Überblick-->
</div>
</div>
<!-- Tab: Overview -->
<div class="tab-panel" data-tab="overview">
<div class="card">
<div class="card-content">
<div class="columns is-multiline">
<div class="column is-half">
<p><strong>Gemeldet von:</strong> {{ incident.reported_by|default:"-" }}</p>
<p><strong>Gemeldet am:</strong> {{ incident.date_reported|date:'d.m.Y' }}</p>
<p><strong>Status:</strong> {{ incident.status }}</p>
<p><strong>{% trans "Incident" %}:</strong> {{ incident.title }}</p>
<p><strong>{% trans "Reported by" %}:</strong> {{ incident.reported_by|default:"" }}</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 class="column is-half">
<p><strong>Erstellt am:</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 "Created at" %}:</strong> {{ incident.created_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> <!-- Ende Inhalt Überblick -->
</div> <!-- Ende Überblick -->
<section>
<h3 class="title is-6">{% trans "Description" %}</h3>
<p>{{ incident.description|default:"" }}</p>
</section>
</div>
</div>
</div><!-- Overview Tab End -->
<!-- Risiken -->
<div class="card">
<header class="card-header">
<p class="card-header-title">Zugehörige Risiken</p>
</header>
<div class="card-content">
{% if incident.related_risks %}
<table class="table is-striped is-hoverable is-fullwidth">
<!-- Tab: Linked Risks -->
<div class="tab-panel is-hidden" data-tab="risks">
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Titel</th>
<th>Risikoeigner</th>
<th>Kategorie</th>
<th>Asset</th>
<th>Prozess</th>
<tr class="has-background-prosoft">
<th class="has-text-centered">{% trans "Risk" %}</th>
<th class="has-text-centered">{% trans "Owner" %}</th>
<th class="has-text-centered">{% trans "Category" %}</th>
<th class="has-text-centered">{% trans "Asset" %}</th>
<th class="has-text-centered">{% trans "Process" %}</th>
</tr>
</thead>
<tbody>
{% 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>
{% if risk.owner %}
{{ risk.owner }}
{% else %}
{% 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>
<td class="has-text-centered">{{ risk.owner|user_display|default:"" }}</td>
<td class="has-text-centered">{{ risk.category|default:"" }}</td>
<td class="has-text-centered">{{ risk.asset|default:"" }}</td>
<td class="has-text-centered">{{ risk.process|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="has-text-grey has-text-centered">{% trans "No linked risks." %}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="has-text-grey">Keine Verknüpften Risiken.</p>
{% endif %}
</div>
</div>
<!-- Ende Maßnahmen -->
</div><!-- Linked Risks Tab End -->
<!-- Historie -->
<div class="card">
<header class="card-header">
<p class="card-header-title">Historie</p>
</header>
<div class="card-content">
{% if logs %}
<table class="table is-striped is-fullwidth">
<!-- Tab: History -->
<div class="tab-panel is-hidden" data-tab="history">
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Benutzer</th>
<th>Aktion</th>
<tr class="has-background-prosoft">
<th class="has-text-centered">{% trans "Time" %}</th>
<th class="has-text-centered">{% trans "User" %}</th>
<th class="has-text-centered">{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ 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.action_time|date:"d.m.Y H:i" }}</td>
<td class="has-text-centered">{{ log.user.get_full_name|default:log.user.username }}</td>
<td>{{ log.get_change_message }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="has-text-grey has-text-centered">{% trans "No history found." %}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="has-text-grey">Keine Historie vorhanden.</p>
{% endif %}
</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 %}

View file

@ -13,6 +13,15 @@
<a data-tab="measures">{% trans "Measures" %}</a>
<a data-tab="incidents">{% trans "Incidents" %}</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>
<!-- Tab: Overview -->

View file

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

View file

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

View file

@ -2,6 +2,7 @@
{% load i18n risk_extras %}
{% block crumbs %}
<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 %}
{% block content %}

View file

@ -30,9 +30,15 @@
<div class="media-content">
<p>
{% 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 %}
{% if n.get_link %}
<a href="{{ n.get_link }}">{{ n.message }}</a>
{% else %}
{{ n.message }}
{% endif %}
</p>
<p class="is-size-7 has-text-grey">{{ n.created_at|date:"d.m.Y H:i" }}</p>
</div>