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:
parent
66e53e171e
commit
f7ead4e5c3
24 changed files with 1313 additions and 1333 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -27,7 +27,6 @@ var/
|
|||
db.sqlite3
|
||||
media/
|
||||
staticfiles/
|
||||
static/
|
||||
|
||||
# If you are using WhiteNoise for static file management
|
||||
static_root/
|
||||
|
|
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
188
risks/admin.py
188
risks/admin.py
|
@ -1,125 +1,150 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import Control, Incident, Notification, NotificationPreference, NotificationRule, Risk, ResidualRisk, User
|
||||
|
||||
from .models import (
|
||||
Control,
|
||||
Incident,
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
NotificationRule,
|
||||
Risk,
|
||||
ResidualRisk,
|
||||
User,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global Admin Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
admin.site.site_header = _("Administration")
|
||||
admin.site.site_title = _("Admin")
|
||||
admin.site.index_title = _("Administration")
|
||||
|
||||
|
||||
# ---- Inlines ----
|
||||
class NotificationPreferenceInline(admin.StackedInline):
|
||||
"""Preferences inline for notifications on User model"""
|
||||
model = NotificationPreference
|
||||
can_delete = False
|
||||
extra = 0
|
||||
fieldsets = (
|
||||
(_("Risks"), {"fields": ("risk_created","risk_updated","risk_deleted")}),
|
||||
(_("Controls"), {"fields": ("control_created","control_updated","control_deleted")}),
|
||||
(_("Residual risks"), {"fields": ("residual_created","residual_updated","residual_deleted")}),
|
||||
(_("Reviews"), {"fields": ("review_required","review_completed")}),
|
||||
(_("Incidents"), {"fields": ("incident_created","incident_updated","incident_deleted")}),
|
||||
(_("Users"), {"fields": ("user_created","user_deleted")}),
|
||||
(_("Risks"), {"fields": ("risk_created", "risk_updated", "risk_deleted")}),
|
||||
(_("Controls"), {"fields": ("control_created", "control_updated", "control_deleted")}),
|
||||
(_("Residual risks"), {"fields": ("residual_created", "residual_updated", "residual_deleted")}),
|
||||
(_("Reviews"), {"fields": ("review_required", "review_completed")}),
|
||||
(_("Incidents"), {"fields": ("incident_created", "incident_updated", "incident_deleted")}),
|
||||
(_("Users"), {"fields": ("user_created", "user_deleted")}),
|
||||
)
|
||||
|
||||
|
||||
class ResidualRiskInline(admin.StackedInline):
|
||||
"""
|
||||
Inline editor for ResidualRisk, linked one-to-one with Risk
|
||||
"""
|
||||
"""Inline editor for ResidualRisk (one-to-one with Risk)"""
|
||||
model = ResidualRisk
|
||||
extra = 0
|
||||
can_delete = False
|
||||
readonly_fields = ("score", "level", "review_required")
|
||||
fields = ("likelihood", "impact", "score", "level", "review_required")
|
||||
|
||||
|
||||
class ControlRisksInline(admin.TabularInline):
|
||||
"""M2M relation between Risk and Control"""
|
||||
model = Control.risks.through
|
||||
fk_name = "risk"
|
||||
extra = 1
|
||||
autocomplete_fields = ("control",)
|
||||
|
||||
|
||||
class NotificationInline(admin.TabularInline):
|
||||
"""Inline display of notifications on User model"""
|
||||
model = Notification
|
||||
fields = ("created_at", "message", "read", "sent")
|
||||
readonly_fields = ("created_at", "message")
|
||||
extra = 0
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
# ---- Shared Mixins ----
|
||||
class ChangedByMixin:
|
||||
"""Automatically track user who created/changed/deleted"""
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj._changed_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
obj._changed_by = request.user
|
||||
super().delete_model(request, obj)
|
||||
|
||||
|
||||
class RedirectOnSaveMixin:
|
||||
"""Redirect to detail view instead of staying in admin"""
|
||||
redirect_url_name = None
|
||||
|
||||
def response_add(self, request, obj, post_url_continue=None):
|
||||
return HttpResponseRedirect(reverse(self.redirect_url_name, args=[obj.pk]))
|
||||
|
||||
def response_change(self, request, obj):
|
||||
return HttpResponseRedirect(reverse(self.redirect_url_name, args=[obj.pk]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin.register(Risk)
|
||||
class RiskAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"owner_name",
|
||||
"status",
|
||||
"score",
|
||||
"level",
|
||||
"likelihood",
|
||||
"impact",
|
||||
"follow_up",
|
||||
)
|
||||
class RiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
|
||||
redirect_url_name = "risks:show_risk"
|
||||
list_display = ("title", "owner_name", "status", "score", "level", "likelihood", "impact", "follow_up")
|
||||
list_filter = ("status", "level", "likelihood", "impact", "owner")
|
||||
search_fields = ("title", "asset", "process", "category")
|
||||
inlines = [ResidualRiskInline, ControlRisksInline]
|
||||
|
||||
def owner_name(self, obj):
|
||||
if not obj.owner:
|
||||
return "-"
|
||||
return obj.owner.get_full_name() or obj.owner.username
|
||||
|
||||
list_filter = ("status", "level", "likelihood", "impact", "owner")
|
||||
search_fields = ("title", "asset", "process", "category")
|
||||
inlines = [ResidualRiskInline, ControlRisksInline]
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj._changed_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
obj._changed_by = request.user
|
||||
super().delete_model(request, obj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Residual Risk
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin.register(ResidualRisk)
|
||||
class ResidualRiskAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"risk",
|
||||
"score",
|
||||
"level",
|
||||
"likelihood",
|
||||
"impact",
|
||||
"review_required"
|
||||
)
|
||||
class ResidualRiskAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
|
||||
redirect_url_name = "risks:show_risk"
|
||||
list_display = ("risk", "score", "level", "likelihood", "impact", "review_required")
|
||||
list_filter = ("level", "likelihood", "impact", "review_required")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj._changed_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
obj._changed_by = request.user
|
||||
super().delete_model(request, obj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Control
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin.register(Control)
|
||||
class ControlAdmin(admin.ModelAdmin):
|
||||
class ControlAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
|
||||
redirect_url_name = "risks:show_control"
|
||||
list_display = ("title", "status", "due_date", "responsible")
|
||||
list_filter = ("status", "due_date")
|
||||
autocomplete_fields = ("risks", "responsible",)
|
||||
autocomplete_fields = ("risks", "responsible")
|
||||
search_fields = ("title", "description")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj._changed_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
obj._changed_by = request.user
|
||||
super().delete_model(request, obj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incident
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin.register(Incident)
|
||||
class IncidentAdmin(admin.ModelAdmin):
|
||||
class IncidentAdmin(ChangedByMixin, RedirectOnSaveMixin, admin.ModelAdmin):
|
||||
redirect_url_name = "risks:show_incident"
|
||||
list_display = ("title", "date_reported", "reported_by", "status")
|
||||
list_filter = ("status", "date_reported", "reported_by")
|
||||
filter_horizontal = ("related_risks",)
|
||||
search_fields = ("title", "description")
|
||||
autocomplete_fields = ("related_risks",)
|
||||
filter_horizontal = ("related_risks",)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj._changed_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
obj._changed_by = request.user
|
||||
super().delete_model(request, obj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
|
||||
date_hierarchy = "created_at"
|
||||
list_display = ("id", "created_at", "user_display", "short_message", "read", "sent")
|
||||
list_display_links = ("id", "short_message")
|
||||
|
@ -129,21 +154,18 @@ class NotificationAdmin(admin.ModelAdmin):
|
|||
list_editable = ("read", "sent")
|
||||
ordering = ("-created_at",)
|
||||
autocomplete_fields = ("user",)
|
||||
actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"]
|
||||
|
||||
@admin.display(description=_("User"))
|
||||
def user_display(self, obj):
|
||||
if not obj.user:
|
||||
return "—"
|
||||
return obj.user.get_full_name() or obj.user.username
|
||||
return obj.user.get_full_name() if obj.user else "—"
|
||||
|
||||
@admin.display(description=_("Message"))
|
||||
def short_message(self, obj):
|
||||
msg = obj.message or ""
|
||||
return (msg[:80] + "…") if len(msg) > 80 else msg
|
||||
|
||||
# Bulk-Aktionen
|
||||
actions = ["mark_as_read", "mark_as_unread", "mark_as_sent", "mark_as_unsent"]
|
||||
|
||||
# Bulk actions
|
||||
@admin.action(description=_("Mark selected as read"))
|
||||
def mark_as_read(self, request, queryset):
|
||||
n = queryset.update(read=True)
|
||||
|
@ -164,13 +186,8 @@ class NotificationAdmin(admin.ModelAdmin):
|
|||
n = queryset.update(sent=False)
|
||||
self.message_user(request, _("%(n)d notifications marked as unsent.") % {"n": n})
|
||||
|
||||
class NotificationInline(admin.TabularInline):
|
||||
model = Notification
|
||||
fields = ("created_at", "message", "read", "sent")
|
||||
readonly_fields = ("created_at", "message")
|
||||
extra = 0
|
||||
ordering = ("-created_at",)
|
||||
|
||||
# ---- Notification Rule ----
|
||||
@admin.register(NotificationRule)
|
||||
class NotificationRuleAdmin(admin.ModelAdmin):
|
||||
list_display = ("kind", "enabled_in_app", "enabled_email", "to_owner", "to_staff", "short_extras")
|
||||
|
@ -184,14 +201,19 @@ class NotificationRuleAdmin(admin.ModelAdmin):
|
|||
txt = (obj.extra_recipients or "").replace("\n", ", ")
|
||||
return (txt[:50] + "…") if len(txt) > 50 else txt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User (extension)
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
fieldsets = BaseUserAdmin.fieldsets + (
|
||||
(_("SSO Information"), {"fields": ("is_sso_user",)}),
|
||||
)
|
||||
list_display = ("username", "email", "is_staff", "is_superuser", "is_sso_user",
|
||||
"owned_risks_count", "responsible_controls_count")
|
||||
|
||||
list_display = (
|
||||
"username", "email", "is_staff", "is_superuser", "is_sso_user",
|
||||
"owned_risks_count", "responsible_controls_count"
|
||||
)
|
||||
inlines = [NotificationInline, NotificationPreferenceInline]
|
||||
|
||||
def owned_risks_count(self, obj):
|
||||
|
@ -200,4 +222,4 @@ class UserAdmin(BaseUserAdmin):
|
|||
|
||||
def responsible_controls_count(self, obj):
|
||||
return obj.controls_responsible.count()
|
||||
responsible_controls_count.short_description = _("Controls Responsible")
|
||||
responsible_controls_count.short_description = _("Controls Responsible")
|
||||
|
|
|
@ -1,21 +1,35 @@
|
|||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risks AppConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
class RisksConfig(AppConfig):
|
||||
"""App configuration for the risks module."""
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "risks"
|
||||
verbose_name = _("Risk Management")
|
||||
|
||||
def ready(self):
|
||||
import risks.signals
|
||||
"""
|
||||
Initialize signals and ensure NotificationRules exist for all
|
||||
NotificationKind choices. Ignores database errors during migration.
|
||||
"""
|
||||
import risks.signals # noqa: F401 (ensure signal handlers are loaded)
|
||||
|
||||
try:
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from .models import NotificationRule, NotificationKind
|
||||
|
||||
# Test DB availability
|
||||
NotificationRule.objects.count()
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Happens during migrate or before tables exist
|
||||
return
|
||||
|
||||
# Ensure all NotificationKind values have a corresponding NotificationRule
|
||||
existing = set(NotificationRule.objects.values_list("kind", flat=True))
|
||||
for kind, _label in NotificationKind.choices:
|
||||
if kind not in existing:
|
||||
NotificationRule.objects.create(kind=kind)
|
||||
NotificationRule.objects.create(kind=kind)
|
||||
|
|
|
@ -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)
|
|
@ -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}
|
|
@ -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
|
|
@ -2,30 +2,48 @@ from django import forms
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import Risk, Control, Incident, ResidualRisk
|
||||
|
||||
class RiskStatusForm(forms.ModelForm):
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base form for status field (DRY for Risk/Control/Incident)
|
||||
# ---------------------------------------------------------------------------
|
||||
class BaseStatusForm(forms.ModelForm):
|
||||
"""Abstract base form for models with a 'status' field."""
|
||||
class Meta:
|
||||
fields = ["status"]
|
||||
labels = {"status": _("Status")}
|
||||
widgets = {"status": forms.Select(attrs={"class": "select"})}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RiskStatusForm
|
||||
# ---------------------------------------------------------------------------
|
||||
class RiskStatusForm(BaseStatusForm):
|
||||
class Meta(BaseStatusForm.Meta):
|
||||
model = Risk
|
||||
fields = ["status"]
|
||||
labels = {"status": _("Status")}
|
||||
widgets = {"status": forms.Select(attrs={"class": "select"})}
|
||||
|
||||
class ControlStatusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ControlStatusForm
|
||||
# ---------------------------------------------------------------------------
|
||||
class ControlStatusForm(BaseStatusForm):
|
||||
class Meta(BaseStatusForm.Meta):
|
||||
model = Control
|
||||
fields = ["status"]
|
||||
labels = {"status": _("Status")}
|
||||
widgets = {"status": forms.Select(attrs={"class": "select"})}
|
||||
|
||||
class IncidentStatusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IncidentStatusForm
|
||||
# ---------------------------------------------------------------------------
|
||||
class IncidentStatusForm(BaseStatusForm):
|
||||
class Meta(BaseStatusForm.Meta):
|
||||
model = Incident
|
||||
fields = ["status"]
|
||||
labels = {"status": _("Status")}
|
||||
widgets = {"status": forms.Select(attrs={"class": "select"})}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResidualReviewForm
|
||||
# ---------------------------------------------------------------------------
|
||||
class ResidualReviewForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ResidualRisk
|
||||
fields = ["review_required"]
|
||||
labels = {"review_required": _("Review required")}
|
||||
widgets = {"review_required": forms.CheckboxInput(attrs={"class": "checkbox"})}
|
||||
widgets = {"review_required": forms.CheckboxInput(attrs={"class": "checkbox"})}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
249
risks/models.py
249
risks/models.py
|
@ -1,34 +1,48 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from multiselectfield import MultiSelectField
|
||||
import datetime
|
||||
import json
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from multiselectfield import MultiSelectField
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SafeJSONEncoder
|
||||
# ---------------------------------------------------------------------------
|
||||
class SafeJSONEncoder(DjangoJSONEncoder):
|
||||
"""JSON encoder that can handle datetime.date properly."""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime.date):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User
|
||||
# ---------------------------------------------------------------------------
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Custom user model to support both local and SSO users.
|
||||
"""
|
||||
"""Custom user model to support both local and SSO users."""
|
||||
is_sso_user = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def risks_owned(self):
|
||||
""" All risks where the user is the risk owner. """
|
||||
"""All risks where the user is the risk owner."""
|
||||
return self.owned_risks.all()
|
||||
|
||||
|
||||
@property
|
||||
def controls_responsible(self):
|
||||
""" All controls where the user is responsible. """
|
||||
"""All controls where the user is responsible."""
|
||||
return self.responsible_controls.all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk
|
||||
# ---------------------------------------------------------------------------
|
||||
class Risk(models.Model):
|
||||
|
||||
class Meta:
|
||||
|
@ -81,14 +95,8 @@ class Risk(models.Model):
|
|||
cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True)
|
||||
|
||||
# Risk evaluation before controls
|
||||
likelihood = models.IntegerField(
|
||||
choices=LIKELIHOOD_CHOICES,
|
||||
default=1
|
||||
)
|
||||
impact = models.IntegerField(
|
||||
choices=IMPACT_CHOICES,
|
||||
default=1
|
||||
)
|
||||
likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES, default=1)
|
||||
impact = models.IntegerField(choices=IMPACT_CHOICES, default=1)
|
||||
|
||||
# Calculated fields
|
||||
score = models.IntegerField(editable=False)
|
||||
|
@ -106,10 +114,8 @@ class Risk(models.Model):
|
|||
follow_up = models.DateField(blank=True, null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Calculate risk score
|
||||
# Calculate risk score and level
|
||||
self.score = self.likelihood * self.impact
|
||||
|
||||
# Determine level based on score
|
||||
if self.score <= 4:
|
||||
self.level = "Low"
|
||||
elif self.score <= 8:
|
||||
|
@ -118,55 +124,40 @@ class Risk(models.Model):
|
|||
self.level = "High"
|
||||
else:
|
||||
self.level = "Critical"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} (Score: {self.score}, Level: {self.level})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Residual Risk
|
||||
# ---------------------------------------------------------------------------
|
||||
class ResidualRisk(models.Model):
|
||||
"""
|
||||
Residual Risk after implementing controls
|
||||
"""
|
||||
"""Residual risk after implementing controls."""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Residual Risk")
|
||||
verbose_name_plural = _("Residual Risks")
|
||||
|
||||
risk = models.OneToOneField(
|
||||
Risk,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="residual_risk")
|
||||
|
||||
likelihood = models.IntegerField(
|
||||
choices=Risk.LIKELIHOOD_CHOICES,
|
||||
default=1
|
||||
)
|
||||
|
||||
impact = models.IntegerField(
|
||||
choices=Risk.IMPACT_CHOICES,
|
||||
default=1
|
||||
)
|
||||
|
||||
risk = models.OneToOneField(Risk, on_delete=models.CASCADE, related_name="residual_risk")
|
||||
likelihood = models.IntegerField(choices=Risk.LIKELIHOOD_CHOICES, default=1)
|
||||
impact = models.IntegerField(choices=Risk.IMPACT_CHOICES, default=1)
|
||||
score = models.IntegerField(editable=False)
|
||||
|
||||
level = models.CharField(max_length=50, editable=False)
|
||||
|
||||
review_required = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True,)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Load previous state (if it exists)
|
||||
# Mark for review if likelihood/impact changed
|
||||
if self.pk:
|
||||
old = ResidualRisk.objects.get(pk=self.pk)
|
||||
if old.likelihood != self.likelihood or old.impact != self.impact:
|
||||
self.review_required = True
|
||||
|
||||
# Calculate residual risk score and level
|
||||
self.score = self.likelihood * self.impact
|
||||
|
||||
# Determine level based on score
|
||||
if self.score <= 4:
|
||||
self.level = "Low"
|
||||
elif self.score <= 8:
|
||||
|
@ -175,16 +166,19 @@ class ResidualRisk(models.Model):
|
|||
self.level = "High"
|
||||
else:
|
||||
self.level = "Critical"
|
||||
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Residual Risk for {self.risk.title} (Score: {self.score}, Level: {self.level})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Control
|
||||
# ---------------------------------------------------------------------------
|
||||
class Control(models.Model):
|
||||
"""
|
||||
A security control/measure linked to a risk.
|
||||
"""
|
||||
"""Security control/measure linked to a risk."""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Control")
|
||||
verbose_name_plural = _("Controls")
|
||||
|
@ -208,19 +202,21 @@ class Control(models.Model):
|
|||
)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
wiki_link = models.URLField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True,)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Relation to risk
|
||||
risks = models.ManyToManyField("Risk", related_name="controls", blank=True)
|
||||
risks = models.ManyToManyField(Risk, related_name="controls", blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.get_status_display()})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuditLog
|
||||
# ---------------------------------------------------------------------------
|
||||
class AuditLog(models.Model):
|
||||
"""
|
||||
Generic audit log entry for tracking changes.
|
||||
"""
|
||||
"""Generic audit log entry for tracking changes."""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Auditlog")
|
||||
|
@ -238,7 +234,6 @@ class AuditLog(models.Model):
|
|||
on_delete=models.SET_NULL,
|
||||
related_name="audit_logs"
|
||||
)
|
||||
|
||||
action = models.CharField(max_length=10, choices=ACTION_CHOICES)
|
||||
model = models.CharField(max_length=100)
|
||||
object_id = models.CharField(max_length=50)
|
||||
|
@ -247,11 +242,13 @@ class AuditLog(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"[{self.timestamp}] {self.user} {self.action} {self.model}({self.object_id})"
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incident
|
||||
# ---------------------------------------------------------------------------
|
||||
class Incident(models.Model):
|
||||
"""
|
||||
Incidents and related risks
|
||||
"""
|
||||
"""Incidents and related risks."""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Incident")
|
||||
|
@ -262,36 +259,28 @@ class Incident(models.Model):
|
|||
("in_progress", _("In Progress")),
|
||||
("closed", _("Closed")),
|
||||
]
|
||||
|
||||
title = models.CharField(_("Title"), max_length=255)
|
||||
description = models.TextField(_("Description"), blank=True, null=True)
|
||||
date_reported = models.DateField(_("Date reported"), blank=True, null=True)
|
||||
reported_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, verbose_name=_("Reported by"),
|
||||
null=True, blank=True, on_delete=models.SET_NULL, related_name="incidents"
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name=_("Reported by"),
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="incidents"
|
||||
)
|
||||
status = models.CharField(max_length=12, choices=STATUS_CHOICES)
|
||||
related_risks = models.ManyToManyField("Risk", blank=True, related_name="incidents")
|
||||
created_at = models.DateTimeField(auto_now_add=True,)
|
||||
related_risks = models.ManyToManyField(Risk, blank=True, related_name="incidents")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Notification(models.Model):
|
||||
class Meta:
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications")
|
||||
|
||||
message = models.TextField()
|
||||
#related_objects =
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
read = models.BooleanField(default=False) # Read in WebApp
|
||||
sent = models.BooleanField(default=False) # Sent via Mail (optional)
|
||||
|
||||
def __str__(self):
|
||||
user_display = self.user.username if self.user else "System"
|
||||
return f"{user_display}: {self.message[:50]}..."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NotificationKind
|
||||
# ---------------------------------------------------------------------------
|
||||
class NotificationKind(models.TextChoices):
|
||||
"""Event types for notifications."""
|
||||
RISK_CREATED = "risk.created", _("Risk created")
|
||||
RISK_UPDATED = "risk.updated", _("Risk updated")
|
||||
RISK_DELETED = "risk.deleted", _("Risk deleted")
|
||||
|
@ -315,10 +304,57 @@ class NotificationKind(models.TextChoices):
|
|||
USER_CREATED = "user.created", _("User created")
|
||||
USER_DELETED = "user.deleted", _("User deleted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification
|
||||
# ---------------------------------------------------------------------------
|
||||
class Notification(models.Model):
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="notifications"
|
||||
)
|
||||
message = models.TextField()
|
||||
kind = models.CharField(max_length=40, choices=NotificationKind.choices, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
read = models.BooleanField(default=False) # Read in WebApp
|
||||
sent = models.BooleanField(default=False) # Sent via Mail (optional)
|
||||
|
||||
# Optional relation to any object
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
related_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
def __str__(self):
|
||||
user_display = self.user.username if self.user else "System"
|
||||
return f"{user_display}: {self.message[:50]}..."
|
||||
|
||||
def get_link(self):
|
||||
"""Return URL to the related object if available."""
|
||||
if not self.related_object:
|
||||
return None
|
||||
model_name = self.content_type.model
|
||||
if model_name == "risk":
|
||||
return reverse("risks:show_risk", args=[self.object_id])
|
||||
if model_name == "control":
|
||||
return reverse("risks:show_control", args=[self.object_id])
|
||||
if model_name == "incident":
|
||||
return reverse("risks:show_incident", args=[self.object_id])
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NotificationPreference
|
||||
# ---------------------------------------------------------------------------
|
||||
class NotificationPreference(models.Model):
|
||||
"""
|
||||
Wich events does the user want to receive as notifications?
|
||||
"""
|
||||
"""User-specific notification preferences."""
|
||||
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -327,9 +363,9 @@ class NotificationPreference(models.Model):
|
|||
)
|
||||
|
||||
# Risks
|
||||
risk_created = models.BooleanField(default=True)
|
||||
risk_updated = models.BooleanField(default=True)
|
||||
risk_deleted = models.BooleanField(default=True)
|
||||
risk_created = models.BooleanField(default=True)
|
||||
risk_updated = models.BooleanField(default=True)
|
||||
risk_deleted = models.BooleanField(default=True)
|
||||
|
||||
# Controls
|
||||
control_created = models.BooleanField(default=True)
|
||||
|
@ -337,17 +373,17 @@ class NotificationPreference(models.Model):
|
|||
control_deleted = models.BooleanField(default=True)
|
||||
|
||||
# Residual risks
|
||||
residual_created = models.BooleanField(default=True)
|
||||
residual_updated = models.BooleanField(default=True)
|
||||
residual_deleted = models.BooleanField(default=True)
|
||||
residual_created = models.BooleanField(default=True)
|
||||
residual_updated = models.BooleanField(default=True)
|
||||
residual_deleted = models.BooleanField(default=True)
|
||||
|
||||
# Reviews
|
||||
review_required = models.BooleanField(default=True)
|
||||
review_completed = models.BooleanField(default=True)
|
||||
review_required = models.BooleanField(default=True)
|
||||
review_completed = models.BooleanField(default=True)
|
||||
|
||||
# Users
|
||||
user_created = models.BooleanField(default=False)
|
||||
user_deleted = models.BooleanField(default=False)
|
||||
user_created = models.BooleanField(default=False)
|
||||
user_deleted = models.BooleanField(default=False)
|
||||
|
||||
# Incidents
|
||||
incident_created = models.BooleanField(default=True)
|
||||
|
@ -361,12 +397,16 @@ class NotificationPreference(models.Model):
|
|||
return f"Prefs({self.user})"
|
||||
|
||||
def should_notify(self, event_code: str) -> bool:
|
||||
"""Return True if user wants notifications for this event code."""
|
||||
return bool(getattr(self, event_code, False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NotificationRule
|
||||
# ---------------------------------------------------------------------------
|
||||
class NotificationRule(models.Model):
|
||||
"""
|
||||
Global Rules: Wich Event sends In-App- and/or Mail-Notifications?
|
||||
"""
|
||||
"""Global rules: Which events trigger in-app and/or email notifications."""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Notification rule")
|
||||
verbose_name_plural = _("Notification rules")
|
||||
|
@ -380,18 +420,15 @@ class NotificationRule(models.Model):
|
|||
enabled_in_app = models.BooleanField(_("Show in app"), default=True)
|
||||
enabled_email = models.BooleanField(_("Send via email"), default=False)
|
||||
|
||||
# Empfängerkreise
|
||||
# Recipient groups
|
||||
to_owner = models.BooleanField(
|
||||
_("Send to owner/responsible/reporter (if available)"),
|
||||
default=True
|
||||
)
|
||||
to_staff = models.BooleanField(
|
||||
_("Send to all staff"),
|
||||
default=False
|
||||
default=True,
|
||||
)
|
||||
to_staff = models.BooleanField(_("Send to all staff"), default=False)
|
||||
extra_recipients = models.TextField(
|
||||
_("Extra recipients (emails, comma or newline separated)"),
|
||||
blank=True
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
@ -2,6 +2,10 @@ from django.contrib.auth import get_user_model
|
|||
from rest_framework import serializers
|
||||
from .models import Risk, Control, ResidualRisk, AuditLog, Incident
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResidualRiskSerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
class ResidualRiskSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ResidualRisk
|
||||
|
@ -17,6 +21,9 @@ class ResidualRiskSerializer(serializers.ModelSerializer):
|
|||
read_only_fields = ["score", "level"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ControlSerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
class ControlSerializer(serializers.ModelSerializer):
|
||||
risks = serializers.PrimaryKeyRelatedField(many=True, queryset=Risk.objects.all())
|
||||
|
||||
|
@ -35,8 +42,12 @@ class ControlSerializer(serializers.ModelSerializer):
|
|||
"risks",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RiskSerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
class RiskSerializer(serializers.ModelSerializer):
|
||||
# Nested representation of related controls
|
||||
# Nested representation of related controls (read-only)
|
||||
controls = ControlSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
@ -60,6 +71,10 @@ class RiskSerializer(serializers.ModelSerializer):
|
|||
"controls",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuditSerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
class AuditSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AuditLog
|
||||
|
@ -73,6 +88,10 @@ class AuditSerializer(serializers.ModelSerializer):
|
|||
"timestamp",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UserSerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
User = get_user_model()
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
|
@ -90,11 +109,19 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
"controls_responsible",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RiskSummarySerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
class RiskSummarySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Risk
|
||||
fields = ["id", "title", "score", "level"]
|
||||
fields = ["id", "title", "score", "level"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IncidentSerializer
|
||||
# ---------------------------------------------------------------------------
|
||||
class IncidentSerializer(serializers.ModelSerializer):
|
||||
related_risks = serializers.PrimaryKeyRelatedField(
|
||||
many=True, queryset=Risk.objects.all()
|
||||
|
@ -106,11 +133,18 @@ class IncidentSerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = Incident
|
||||
fields = [
|
||||
"id", "title", "description", "date_reported",
|
||||
"created_at", "updated_at", "status", "related_risks",
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"date_reported",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"related_risks",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Ensure related_risks are set after creation."""
|
||||
risks = validated_data.pop("related_risks", [])
|
||||
obj = super().create(validated_data)
|
||||
if risks:
|
||||
|
@ -118,6 +152,7 @@ class IncidentSerializer(serializers.ModelSerializer):
|
|||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Ensure related_risks are updated properly."""
|
||||
risks = validated_data.pop("related_risks", None)
|
||||
obj = super().update(instance, validated_data)
|
||||
if risks is not None:
|
||||
|
|
388
risks/signals.py
388
risks/signals.py
|
@ -4,24 +4,33 @@ from django.db.models import Model
|
|||
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .audit_context import get_current_user
|
||||
from .models import Control, Risk, ResidualRisk, AuditLog, Incident, Notification, NotificationKind, NotificationPreference
|
||||
from .models import (
|
||||
Control, Risk, ResidualRisk, AuditLog, Incident,
|
||||
Notification, NotificationKind, NotificationPreference
|
||||
)
|
||||
from .utils import model_diff, notify_event
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# General definitions
|
||||
# General definitions & helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def serialize_value(value):
|
||||
"""Serialize values for audit log (pk/isoformat)."""
|
||||
if isinstance(value, Model):
|
||||
return value.pk # oder str(value), wenn du mehr Infos willst
|
||||
return value.pk
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _pref(user: User) -> NotificationPreference | None:
|
||||
"""Ensure NotificationPreference exists for user."""
|
||||
if not user:
|
||||
return None
|
||||
pref = getattr(user, "notification_preference", None)
|
||||
|
@ -29,394 +38,304 @@ def _pref(user: User) -> NotificationPreference | None:
|
|||
pref = NotificationPreference.objects.create(user=user)
|
||||
return pref
|
||||
|
||||
|
||||
def _notify(users, message: str, event_code: str):
|
||||
"""legt Notification für alle users an, die dieses Event wünschen."""
|
||||
"""Create notifications for all users that want this event."""
|
||||
for u in set(filter(None, users)):
|
||||
pref = _pref(u)
|
||||
if pref and pref.should_notify(event_code):
|
||||
Notification.objects.create(user=u, message=message)
|
||||
|
||||
|
||||
def _risk_stakeholders(risk: Risk):
|
||||
"""Risikoeigner + alle Verantwortlichen zugehöriger Controls."""
|
||||
"""Return risk owner + all control responsibles."""
|
||||
owners = [risk.owner] if risk.owner else []
|
||||
responsibles = list(
|
||||
User.objects.filter(responsible_controls__risks=risk).distinct()
|
||||
)
|
||||
return set(owners + responsibles)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incidents
|
||||
# User
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def user_saved(sender, instance: User, created, **kwargs):
|
||||
# Prefs automatisch anlegen
|
||||
"""Auto-create prefs + notify staff."""
|
||||
_pref(instance)
|
||||
# An Staff, die dieses Event wollen
|
||||
if created:
|
||||
staff = User.objects.filter(is_staff=True, notification_preference__user_created=True)
|
||||
staff = User.objects.filter(
|
||||
is_staff=True, notification_preference__user_created=True
|
||||
)
|
||||
_notify(staff, _("User '{u}' created").format(u=instance.username), "user_created")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=User)
|
||||
def user_deleted(sender, instance: User, **kwargs):
|
||||
staff = User.objects.filter(is_staff=True, notification_preference__user_deleted=True)
|
||||
staff = User.objects.filter(
|
||||
is_staff=True, notification_preference__user_deleted=True
|
||||
)
|
||||
_notify(staff, _("User '{u}' deleted").format(u=instance.username), "user_deleted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@receiver(post_save, sender=Risk)
|
||||
def risk_saved(sender, instance: Risk, created, **kwargs):
|
||||
event = "risk_created" if created else "risk_updated"
|
||||
msg = _("Risk '{title}' {state}").format(
|
||||
title=instance.title,
|
||||
state=_("created") if created else _("updated"),
|
||||
)
|
||||
_notify([instance.owner], msg, event)
|
||||
|
||||
@receiver(post_delete, sender=Risk)
|
||||
def risk_deleted(sender, instance: Risk, **kwargs):
|
||||
msg = _("Risk '{title}' deleted").format(title=instance.title)
|
||||
# Owner existiert evtl. nicht mehr -> kein Notify nötig
|
||||
if instance.owner:
|
||||
_notify([instance.owner], msg, "risk_deleted")
|
||||
|
||||
@receiver(post_save, sender=Risk)
|
||||
def log_risk_save(sender, instance, created, **kwargs):
|
||||
"""Audit + notify on create/update."""
|
||||
user = getattr(instance, "_changed_by", None)
|
||||
if created:
|
||||
# Initial audit log
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
user=user,
|
||||
action="create",
|
||||
model="Risk",
|
||||
object_id=instance.pk,
|
||||
changes={
|
||||
f.name: {
|
||||
"old": None,
|
||||
"new": serialize_value(getattr(instance, f.name))
|
||||
} for f in instance._meta.fields
|
||||
},
|
||||
changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
|
||||
for f in instance._meta.fields},
|
||||
)
|
||||
else:
|
||||
old = Risk.objects.get(pk=instance.pk)
|
||||
changes = model_diff(old, instance)
|
||||
if changes:
|
||||
clean_changes = {
|
||||
field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])}
|
||||
for field, vals in changes.items()
|
||||
}
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
action="update",
|
||||
model="Risk",
|
||||
object_id=instance.pk,
|
||||
changes=clean_changes,
|
||||
)
|
||||
|
||||
if created:
|
||||
notify_event(
|
||||
NotificationKind.RISK_CREATED,
|
||||
message=_("Risk created: {t}").format(t=instance.title),
|
||||
users=[instance.owner] if instance.owner_id else None,
|
||||
)
|
||||
else:
|
||||
# Diff audit log
|
||||
old = Risk.objects.get(pk=instance.pk)
|
||||
changes = model_diff(old, instance)
|
||||
if changes:
|
||||
clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])}
|
||||
for f, v in changes.items()}
|
||||
AuditLog.objects.create(
|
||||
user=user,
|
||||
action="update",
|
||||
model="Risk",
|
||||
object_id=instance.pk,
|
||||
changes=clean,
|
||||
)
|
||||
notify_event(
|
||||
NotificationKind.RISK_UPDATED,
|
||||
message=_("Risk updated: {t}").format(t=instance.title),
|
||||
users=[instance.owner] if instance.owner_id else None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Risk)
|
||||
def log_risk_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal that runs after a Risk is deleted.
|
||||
"""
|
||||
def risk_deleted(sender, instance: Risk, **kwargs):
|
||||
user = getattr(instance, "_changed_by", None) or get_current_user()
|
||||
AuditLog.objects.create(
|
||||
user=user,
|
||||
action="delete",
|
||||
model="Risk",
|
||||
object_id=instance.pk,
|
||||
changes=None, # no fields to track on deletion
|
||||
user=user, action="delete", model="Risk", object_id=instance.pk, changes=None
|
||||
)
|
||||
|
||||
notify_event(
|
||||
NotificationKind.RISK_DELETED,
|
||||
message=_("Risk deleted: {t}").format(t=instance.title),
|
||||
users=[instance.owner] if instance.owner_id else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Controls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@receiver(post_save, sender=Control)
|
||||
def control_saved(sender, instance: Control, created, **kwargs):
|
||||
# Review-Flag für alle betroffenen Residuals setzen
|
||||
"""Update residuals + audit + notify."""
|
||||
# Force review on related residuals
|
||||
for risk in instance.risks.all():
|
||||
resid, created = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
# Statuswechsel auf Review Required
|
||||
resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
if not resid.review_required:
|
||||
resid.review_required = True
|
||||
resid.save()
|
||||
if risk.status != "review_required":
|
||||
Risk.objects.filter(pk=risk.pk).update(status="review_required")
|
||||
|
||||
# Notifications
|
||||
event = "control_created" if created else "control_updated"
|
||||
msg = _("Control '{title}' {state}").format(
|
||||
title=instance.title,
|
||||
state=_("created") if created else _("updated"),
|
||||
)
|
||||
stakeholders = {instance.responsible} | set(r.owner for r in instance.risks.all() if r.owner)
|
||||
_notify(stakeholders, msg, event)
|
||||
|
||||
@receiver(post_delete, sender=Control)
|
||||
def control_deleted(sender, instance: Control, **kwargs):
|
||||
msg = _("Control '{title}' deleted").format(title=instance.title)
|
||||
stakeholders = {instance.responsible} | set(r.owner for r in instance.risks.all() if r.owner)
|
||||
_notify(stakeholders, msg, "control_deleted")
|
||||
|
||||
@receiver(post_save, sender=Control)
|
||||
def log_control_save(sender, instance, created, **kwargs):
|
||||
# Audit log
|
||||
user = getattr(instance, "_changed_by", None)
|
||||
if created:
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
user=user,
|
||||
action="create",
|
||||
model="Control",
|
||||
object_id=instance.pk,
|
||||
changes={
|
||||
f.name: {
|
||||
"old": None,
|
||||
"new": serialize_value(getattr(instance, f.name))
|
||||
} for f in instance._meta.fields
|
||||
},
|
||||
changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
|
||||
for f in instance._meta.fields},
|
||||
)
|
||||
kind = NotificationKind.CONTROL_CREATED
|
||||
else:
|
||||
old = Control.objects.get(pk=instance.pk)
|
||||
changes = model_diff(old, instance)
|
||||
if changes:
|
||||
clean_changes = {
|
||||
field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])}
|
||||
for field, vals in changes.items()
|
||||
}
|
||||
clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])}
|
||||
for f, v in changes.items()}
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
action="update",
|
||||
model="Control",
|
||||
object_id=instance.pk,
|
||||
changes=clean_changes,
|
||||
user=user, action="update", model="Control", object_id=instance.pk, changes=clean
|
||||
)
|
||||
kind = NotificationKind.CONTROL_UPDATED
|
||||
|
||||
kind = NotificationKind.CONTROL_CREATED if created else NotificationKind.CONTROL_UPDATED
|
||||
# Notify
|
||||
notify_event(
|
||||
kind,
|
||||
message=_("Control {event}: {t}").format(
|
||||
event=_("created") if created else _("updated"),
|
||||
t=instance.title,
|
||||
message=_("Control {e}: {t}").format(
|
||||
e=_("created") if created else _("updated"), t=instance.title
|
||||
),
|
||||
users=[instance.responsible] if instance.responsible_id else None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Control)
|
||||
def log_control_delete(sender, instance, **kwargs):
|
||||
def control_deleted(sender, instance: Control, **kwargs):
|
||||
user = getattr(instance, "_changed_by", None) or get_current_user()
|
||||
AuditLog.objects.create(
|
||||
user=user,
|
||||
action="delete",
|
||||
model="Control",
|
||||
object_id=instance.pk,
|
||||
changes=None,
|
||||
user=user, action="delete", model="Control", object_id=instance.pk, changes=None
|
||||
)
|
||||
|
||||
notify_event(
|
||||
NotificationKind.CONTROL_DELETED,
|
||||
message=_("Control deleted: {t}").format(t=instance.title),
|
||||
users=[instance.responsible] if instance.responsible_id else None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Control.risks.through)
|
||||
def control_risks_changed(sender, instance: Control, action, reverse, pk_set, **kwargs):
|
||||
def control_risks_changed(sender, instance: Control, action, **kwargs):
|
||||
if action in {"post_add", "post_remove", "post_clear"}:
|
||||
affected = instance.risks.all() if not pk_set else Risk.objects.filter(pk__in=pk_set)
|
||||
for risk in affected:
|
||||
resid, created = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
for risk in instance.risks.all():
|
||||
resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
if not resid.review_required:
|
||||
resid.review_required = True
|
||||
resid.save()
|
||||
if risk.status != "review_required":
|
||||
Risk.objects.filter(pk=risk.pk).update(status="review_required")
|
||||
_notify(_risk_stakeholders(risk), _("Review required for risk '{t}' due to control change").format(t=risk.title), "review_required")
|
||||
_notify(
|
||||
_risk_stakeholders(risk),
|
||||
_("Review required for risk '{t}' due to control change").format(t=risk.title),
|
||||
"review_required",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Residual risks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@receiver(post_save, sender=ResidualRisk)
|
||||
def residual_saved(sender, instance: ResidualRisk, created, **kwargs):
|
||||
# AuditLog erstellst du bereits anderswo – hier Fokus auf Status/Notify
|
||||
risk = instance.risk
|
||||
old = None
|
||||
if not created:
|
||||
try:
|
||||
old = ResidualRisk.objects.get(pk=instance.pk)
|
||||
except ResidualRisk.DoesNotExist:
|
||||
pass
|
||||
"""Audit + notify on create/update."""
|
||||
user = getattr(instance, "_changed_by", None)
|
||||
|
||||
# Review-Logik: wenn review_required=True -> Risk.status = review_required
|
||||
if instance.review_required and risk.status != "review_required":
|
||||
Risk.objects.filter(pk=risk.pk).update(status="review_required")
|
||||
_notify(_risk_stakeholders(risk), _("Review required for risk '{t}'").format(t=risk.title), "review_required")
|
||||
elif old and old.review_required and not instance.review_required:
|
||||
# Review abgeschlossen
|
||||
if risk.status == "review_required":
|
||||
Risk.objects.filter(pk=risk.pk).update(status="open")
|
||||
_notify(_risk_stakeholders(risk), _("Review completed for risk '{t}'").format(t=risk.title), "review_completed")
|
||||
|
||||
# Standard-Events
|
||||
event = "residual_created" if created else "residual_updated"
|
||||
_notify(_risk_stakeholders(risk), _("Residual risk {state} for '{t}'").format(
|
||||
state=_("created") if created else _("updated"), t=risk.title), event)
|
||||
|
||||
@receiver(post_delete, sender=ResidualRisk)
|
||||
def residual_deleted(sender, instance: ResidualRisk, **kwargs):
|
||||
_notify(_risk_stakeholders(instance.risk), _("Residual risk deleted for '{t}'").format(t=instance.risk.title), "residual_deleted")
|
||||
|
||||
|
||||
@receiver(post_save, sender=ResidualRisk)
|
||||
def log_residual_save(sender, instance, created, **kwargs):
|
||||
# Audit log
|
||||
if created:
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
user=user,
|
||||
action="create",
|
||||
model="ResidualRisk",
|
||||
object_id=instance.pk,
|
||||
changes={
|
||||
f.name: {
|
||||
"old": None,
|
||||
"new": serialize_value(getattr(instance, f.name))
|
||||
} for f in instance._meta.fields
|
||||
},
|
||||
changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
|
||||
for f in instance._meta.fields},
|
||||
)
|
||||
else:
|
||||
old = ResidualRisk.objects.get(pk=instance.pk)
|
||||
changes = model_diff(old, instance)
|
||||
if changes:
|
||||
clean_changes = {
|
||||
field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])}
|
||||
for field, vals in changes.items()
|
||||
}
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
action="update",
|
||||
model="ResidualRisk",
|
||||
object_id=instance.pk,
|
||||
changes=clean_changes,
|
||||
)
|
||||
|
||||
if created:
|
||||
notify_event(
|
||||
NotificationKind.RESIDUAL_CREATED,
|
||||
message=_("Residual created for risk: {t}").format(t=instance.risk.title),
|
||||
users=[instance.risk.owner] if instance.risk.owner_id else None,
|
||||
)
|
||||
else:
|
||||
# Änderungen prüfen
|
||||
old = ResidualRisk.objects.get(pk=instance.pk)
|
||||
changes = model_diff(old, instance)
|
||||
# Review-Flag Wechsel gezielt melden:
|
||||
if "review_required" in changes:
|
||||
if getattr(instance, "review_required", False):
|
||||
notify_event(
|
||||
NotificationKind.RESIDUAL_REVIEW_REQUIRED,
|
||||
message=_("Residual review required for risk: {t}").format(t=instance.risk.title),
|
||||
users=[instance.risk.owner] if instance.risk.owner_id else None,
|
||||
)
|
||||
else:
|
||||
notify_event(
|
||||
NotificationKind.RESIDUAL_REVIEW_COMPLETED,
|
||||
message=_("Residual review completed for risk: {t}").format(t=instance.risk.title),
|
||||
users=[instance.risk.owner] if instance.risk.owner_id else None,
|
||||
)
|
||||
else:
|
||||
notify_event(
|
||||
NotificationKind.RESIDUAL_UPDATED,
|
||||
message=_("Residual updated for risk: {t}").format(t=instance.risk.title),
|
||||
users=[instance.risk.owner] if instance.risk.owner_id else None,
|
||||
if changes:
|
||||
clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])}
|
||||
for f, v in changes.items()}
|
||||
AuditLog.objects.create(
|
||||
user=user, action="update", model="ResidualRisk",
|
||||
object_id=instance.pk, changes=clean
|
||||
)
|
||||
|
||||
# Special handling: review_required
|
||||
if "review_required" in changes:
|
||||
if instance.review_required:
|
||||
kind = NotificationKind.RESIDUAL_REVIEW_REQUIRED
|
||||
msg = _("Residual review required for risk: {t}")
|
||||
else:
|
||||
kind = NotificationKind.RESIDUAL_REVIEW_COMPLETED
|
||||
msg = _("Residual review completed for risk: {t}")
|
||||
else:
|
||||
kind = NotificationKind.RESIDUAL_UPDATED
|
||||
msg = _("Residual updated for risk: {t}")
|
||||
|
||||
notify_event(
|
||||
kind,
|
||||
message=msg.format(t=instance.risk.title),
|
||||
users=[instance.risk.owner] if instance.risk.owner_id else None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ResidualRisk)
|
||||
def log_residual_delete(sender, instance, **kwargs):
|
||||
def residual_deleted(sender, instance: ResidualRisk, **kwargs):
|
||||
user = getattr(instance, "_changed_by", None) or get_current_user()
|
||||
AuditLog.objects.create(
|
||||
user=user,
|
||||
action="delete",
|
||||
model="ResidualRisk",
|
||||
object_id=instance.pk,
|
||||
changes=None,
|
||||
user=user, action="delete", model="ResidualRisk", object_id=instance.pk, changes=None
|
||||
)
|
||||
|
||||
notify_event(
|
||||
NotificationKind.RESIDUAL_DELETED,
|
||||
message=_("Residual deleted for risk: {t}").format(t=instance.risk.title),
|
||||
users=[instance.risk.owner] if instance.risk.owner_id else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incidents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@receiver(post_save, sender=Incident)
|
||||
def incident_saved(sender, instance: Incident, created, **kwargs):
|
||||
event = "incident_created" if created else "incident_updated"
|
||||
stakeholders = set([instance.reported_by]) | set(r.owner for r in instance.related_risks.all() if r.owner)
|
||||
_notify(stakeholders, _("Incident '{t}' {s}").format(t=instance.title, s=_("created") if created else _("updated")), event)
|
||||
|
||||
@receiver(post_delete, sender=Incident)
|
||||
def incident_deleted(sender, instance: Incident, **kwargs):
|
||||
stakeholders = set([instance.reported_by]) | set(r.owner for r in instance.related_risks.all() if r.owner)
|
||||
_notify(stakeholders, _("Incident '{t}' deleted").format(t=instance.title), "incident_deleted")
|
||||
|
||||
@receiver(post_save, sender=Incident)
|
||||
def log_incident_save(sender, instance, created, **kwargs):
|
||||
"""Audit + notify on create/update."""
|
||||
user = getattr(instance, "_changed_by", None)
|
||||
if created:
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
user=user,
|
||||
action="create",
|
||||
model="Incident",
|
||||
object_id=instance.pk,
|
||||
changes={
|
||||
f.name: {
|
||||
"old": None,
|
||||
"new": serialize_value(getattr(instance, f.name))
|
||||
} for f in instance._meta.fields
|
||||
},
|
||||
changes={f.name: {"old": None, "new": serialize_value(getattr(instance, f.name))}
|
||||
for f in instance._meta.fields},
|
||||
)
|
||||
kind = NotificationKind.INCIDENT_CREATED
|
||||
else:
|
||||
old = Incident.objects.get(pk=instance.pk)
|
||||
changes = model_diff(old, instance)
|
||||
if changes:
|
||||
clean_changes = {
|
||||
field: {"old": serialize_value(vals["old"]), "new": serialize_value(vals["new"])}
|
||||
for field, vals in changes.items()
|
||||
}
|
||||
clean = {f: {"old": serialize_value(v["old"]), "new": serialize_value(v["new"])}
|
||||
for f, v in changes.items()}
|
||||
AuditLog.objects.create(
|
||||
user=getattr(instance, "_changed_by", None),
|
||||
action="update",
|
||||
model="Incident",
|
||||
object_id=instance.pk,
|
||||
changes=clean_changes,
|
||||
user=user, action="update", model="Incident", object_id=instance.pk, changes=clean
|
||||
)
|
||||
kind = NotificationKind.INCIDENT_UPDATED
|
||||
|
||||
kind = NotificationKind.INCIDENT_CREATED if created else NotificationKind.INCIDENT_UPDATED
|
||||
notify_event(
|
||||
kind,
|
||||
message=_("Incident {event}: {t}").format(
|
||||
event=_("created") if created else _("updated"),
|
||||
t=instance.title,
|
||||
message=_("Incident {e}: {t}").format(
|
||||
e=_("created") if created else _("updated"), t=instance.title
|
||||
),
|
||||
users=[instance.reported_by] if instance.reported_by_id else None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Incident)
|
||||
def incident_deleted(sender, instance: Incident, **kwargs):
|
||||
user = getattr(instance, "_changed_by", None) or get_current_user()
|
||||
AuditLog.objects.create(
|
||||
user=user, action="delete", model="Incident", object_id=instance.pk, changes=None
|
||||
)
|
||||
notify_event(
|
||||
NotificationKind.INCIDENT_DELETED,
|
||||
message=_("Incident deleted: {t}").format(t=instance.title),
|
||||
users=[instance.reported_by] if instance.reported_by_id else None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Incident.related_risks.through)
|
||||
def log_incident_risks_change(sender, instance, action, reverse, model, pk_set, **kwargs):
|
||||
if action in ["post_add", "post_remove", "post_clear"]:
|
||||
def incident_risks_changed(sender, instance, action, pk_set, **kwargs):
|
||||
if action in {"post_add", "post_remove", "post_clear"}:
|
||||
user = getattr(instance, "_changed_by", None) or get_current_user()
|
||||
AuditLog.objects.create(
|
||||
user=user,
|
||||
|
@ -425,20 +344,3 @@ def log_incident_risks_change(sender, instance, action, reverse, model, pk_set,
|
|||
object_id=instance.pk,
|
||||
changes={"related_risks": {"action": action, "ids": list(pk_set)}},
|
||||
)
|
||||
|
||||
@receiver(post_delete, sender=Incident)
|
||||
def log_incident_delete(sender, instance, **kwargs):
|
||||
user = getattr(instance, "_changed_by", None) or get_current_user()
|
||||
AuditLog.objects.create(
|
||||
user=user,
|
||||
action="delete",
|
||||
model="Incident",
|
||||
object_id=instance.pk,
|
||||
changes=None,
|
||||
)
|
||||
|
||||
notify_event(
|
||||
NotificationKind.INCIDENT_DELETED,
|
||||
message=_("Incident deleted: {t}").format(t=instance.title),
|
||||
users=[instance.reported_by] if instance.reported_by_id else None,
|
||||
)
|
|
@ -4,25 +4,43 @@ from . import views
|
|||
app_name = "risks"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
# -----------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# -----------------------------------------------------------------------
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("risks/index", views.dashboard, name="index"),
|
||||
|
||||
path("risks/index", views.dashboard, name="index"),
|
||||
path("risks/list_risks", views.list_risks, name="list_risks"),
|
||||
path("risks/risks/<int:id>", views.show_risk, name="show_risk"),
|
||||
path("risks/list_controls", views.list_controls, name="list_controls"),
|
||||
path("risks/controls/<int:id>", views.show_control, name="show_control"),
|
||||
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"),
|
||||
# -----------------------------------------------------------------------
|
||||
# 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"),
|
||||
|
||||
# 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"),
|
||||
# -----------------------------------------------------------------------
|
||||
# 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"),
|
||||
|
||||
# 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"),
|
||||
]
|
||||
# -----------------------------------------------------------------------
|
||||
# Incidents
|
||||
# -----------------------------------------------------------------------
|
||||
path("risks/list_incidents", views.list_incidents, name="list_incidents"),
|
||||
path("risks/incidents/<int:id>", views.show_incident, name="show_incident"),
|
||||
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"),
|
||||
]
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
from datetime import date, datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import AuditLog, Notification,NotificationRule, NotificationKind, Risk, ResidualRisk
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from .models import (
|
||||
AuditLog, Notification, NotificationRule,
|
||||
NotificationKind, Risk, ResidualRisk,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# model_diff()
|
||||
# ---------------------------------------------------------------------------
|
||||
def model_diff(old, new, fields=None):
|
||||
"""
|
||||
Compare two model instances and return a dict of changed fields.
|
||||
|
@ -24,32 +34,35 @@ def model_diff(old, new, fields=None):
|
|||
for field_name in fields:
|
||||
old_value = getattr(old, field_name, None)
|
||||
new_value = getattr(new, field_name, None)
|
||||
|
||||
if old_value != new_value:
|
||||
changes[field_name] = {"old": old_value, "new": new_value}
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_risk_followups()
|
||||
# ---------------------------------------------------------------------------
|
||||
def check_risk_followups():
|
||||
"""
|
||||
Check if follow ups need attention and create notifications.
|
||||
Ensures no duplicate notifications per risk per day
|
||||
Check if follow-ups need attention and create notifications.
|
||||
Ensures no duplicate notifications per risk per day.
|
||||
"""
|
||||
today = now().date()
|
||||
risks = Risk.objects.filter(follow_up__lte=today).select_related("owner")
|
||||
|
||||
for risk in risks:
|
||||
# Risk-Status auf review_required setzen (nicht überschreiben, wenn bereits closed)
|
||||
if risk.status != "closed" and risk.status != "review_required":
|
||||
# Status aktualisieren (außer wenn bereits closed/review_required)
|
||||
if risk.status not in ("closed", "review_required"):
|
||||
Risk.objects.filter(pk=risk.pk).update(status="review_required")
|
||||
|
||||
# ResidualRisk-Objekt sicherstellen und Review-Flag setzen
|
||||
resid, created = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
# ResidualRisk sicherstellen + Review-Flag setzen
|
||||
resid, _ = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
if not resid.review_required:
|
||||
resid.review_required = True
|
||||
resid.save()
|
||||
|
||||
# Notification an Stakeholder
|
||||
# Notification (einmalig pro Risk/Tag)
|
||||
message = _("Follow-up reached: review required for risk '{t}'").format(t=risk.title)
|
||||
notification, created = Notification.objects.get_or_create(
|
||||
user=risk.owner,
|
||||
|
@ -58,70 +71,77 @@ def check_risk_followups():
|
|||
)
|
||||
if created:
|
||||
AuditLog.objects.create(
|
||||
user=None, action="create", model="Notification", object_id=notification.pk,
|
||||
changes={"message": notification.message, "user": risk.owner.username if risk.owner else None},
|
||||
user=None,
|
||||
action="create",
|
||||
model="Notification",
|
||||
object_id=notification.pk,
|
||||
changes={
|
||||
"message": notification.message,
|
||||
"user": risk.owner.username if risk.owner else None,
|
||||
},
|
||||
)
|
||||
|
||||
notify_event(
|
||||
NotificationKind.RISK_REVIEW_REQUIRED,
|
||||
message=_("Follow-up reached: review required for risk '{t}'").format(t=risk.title),
|
||||
message=message,
|
||||
users=[risk.owner] if risk.owner_id else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _split_emails()
|
||||
# ---------------------------------------------------------------------------
|
||||
def _split_emails(value: str) -> list[str]:
|
||||
"""Normalize a comma/newline-separated list of emails into a clean list."""
|
||||
if not value:
|
||||
return []
|
||||
raw = value.replace("\n", ",").split(",")
|
||||
return [e.strip() for e in raw if "@" in e and e.strip()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_event()
|
||||
# ---------------------------------------------------------------------------
|
||||
def notify_event(kind: str, *, message: str, users: Optional[Iterable[User]] = None):
|
||||
"""
|
||||
Generates in-app notifications and/or emails depending on the rule.
|
||||
Generates in-app notifications and/or emails depending on the NotificationRule.
|
||||
- users: Basic recipients (owner/responsible/reporter) – can be None.
|
||||
- staff/extra recipients are added from the rule.
|
||||
"""
|
||||
rule = NotificationRule.objects.filter(kind=kind).first()
|
||||
|
||||
# Fallback: without rule → only in-app
|
||||
# Defaults (no rule → in-app only)
|
||||
enabled_in_app = True
|
||||
enabled_email = False
|
||||
to_staff = False
|
||||
recipients_users = set()
|
||||
extra_emails = []
|
||||
|
||||
recipients_users = set()
|
||||
|
||||
# Base recipients
|
||||
if users:
|
||||
for u in users:
|
||||
if u and getattr(u, "is_active", False):
|
||||
recipients_users.add(u)
|
||||
recipients_users.update(u for u in users if u and getattr(u, "is_active", False))
|
||||
|
||||
# Rule overrides
|
||||
if rule:
|
||||
enabled_in_app = rule.enabled_in_app
|
||||
enabled_email = rule.enabled_email
|
||||
if rule.to_staff:
|
||||
to_staff = True
|
||||
recipients_users.update(User.objects.filter(is_staff=True, is_active=True))
|
||||
extra_emails = _split_emails(rule.extra_recipients)
|
||||
|
||||
if to_staff:
|
||||
for u in User.objects.filter(is_staff=True, is_active=True):
|
||||
recipients_users.add(u)
|
||||
|
||||
# In-App
|
||||
# In-App Notifications
|
||||
if enabled_in_app:
|
||||
for u in recipients_users:
|
||||
Notification.objects.create(user=u, message=message)
|
||||
|
||||
# E-Mail
|
||||
# Email Notifications
|
||||
if enabled_email:
|
||||
emails = [u.email for u in recipients_users if u and u.email] + extra_emails
|
||||
emails = list(dict.fromkeys(emails)) # de-dupe, Reihenfolge erhalten
|
||||
emails = list(dict.fromkeys(emails)) # de-dupe, preserve order
|
||||
if emails:
|
||||
subject = _("Notification")
|
||||
body = message
|
||||
send_mail(
|
||||
subject,
|
||||
body,
|
||||
_("Notification"),
|
||||
message,
|
||||
getattr(settings, "DEFAULT_FROM_EMAIL", "webmaster@localhost"),
|
||||
emails,
|
||||
fail_silently=True, # im Zweifel nicht crashen
|
||||
fail_silently=True, # don’t crash on mail error
|
||||
)
|
||||
|
|
345
risks/views.py
345
risks/views.py
|
@ -1,103 +1,92 @@
|
|||
from collections import Counter
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.models import LogEntry
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib import messages
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm
|
||||
from .models import Risk, Control, ResidualRisk, AuditLog, Incident, Notification
|
||||
from .serializers import ControlSerializer, RiskSerializer, ResidualRiskSerializer, UserSerializer, AuditSerializer, IncidentSerializer
|
||||
from .serializers import (
|
||||
ControlSerializer, RiskSerializer, ResidualRiskSerializer,
|
||||
UserSerializer, AuditSerializer, IncidentSerializer,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _can_edit_risk(user, risk: Risk) -> bool:
|
||||
return bool(user.is_staff or (risk.owner_id and risk.owner_id == user.id))
|
||||
|
||||
|
||||
def _can_edit_control(user, control: Control) -> bool:
|
||||
return bool(user.is_staff or (control.responsible_id and control.responsible_id == user.id))
|
||||
|
||||
|
||||
def _can_edit_incident(user, incident: Incident) -> bool:
|
||||
return bool(user.is_staff or (incident.reported_by_id and incident.reported_by_id == user.id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API
|
||||
# API ViewSets
|
||||
# ---------------------------------------------------------------------------
|
||||
class RiskViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Risks.
|
||||
Provides CRUD operations.
|
||||
"""
|
||||
class _ChangedByMixin:
|
||||
"""Mixin to track user who changed an object."""
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
|
||||
class RiskViewSet(_ChangedByMixin, viewsets.ModelViewSet):
|
||||
"""API endpoint for managing Risks."""
|
||||
queryset = Risk.objects.all()
|
||||
serializer_class = RiskSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
class ControlViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Controls.
|
||||
Provides CRUD operations.
|
||||
"""
|
||||
class ControlViewSet(_ChangedByMixin, viewsets.ModelViewSet):
|
||||
"""API endpoint for managing Controls."""
|
||||
queryset = Control.objects.all()
|
||||
serializer_class = ControlSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
class ResidualRiskViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for Residual risks.
|
||||
"""
|
||||
"""API endpoint for Residual Risks."""
|
||||
queryset = ResidualRisk.objects.all()
|
||||
serializer_class = ResidualRiskSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class UserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for listing users and their responsibilities.
|
||||
"""
|
||||
|
||||
class UserViewSet(_ChangedByMixin, viewsets.ReadOnlyModelViewSet):
|
||||
"""API endpoint for listing users and their responsibilities."""
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
class AuditViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for view audit logging.
|
||||
"""
|
||||
"""API endpoint for viewing audit logs."""
|
||||
queryset = AuditLog.objects.all()
|
||||
serializer_class = AuditSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class IncidentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for listing incidents and its related risks.
|
||||
"""
|
||||
|
||||
class IncidentViewSet(_ChangedByMixin, viewsets.ModelViewSet):
|
||||
"""API endpoint for listing incidents and their related risks."""
|
||||
queryset = Incident.objects.all()
|
||||
serializer_class = IncidentSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
@ -106,242 +95,180 @@ class IncidentViewSet(viewsets.ModelViewSet):
|
|||
instance = serializer.save(reported_by=self.request.user)
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance._changed_by = self.request.user
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web => Risks, Controls, Incidents
|
||||
# Web Views: Risks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@login_required
|
||||
def list_risks(request):
|
||||
"""List all risks with filters and sorting."""
|
||||
qs = Risk.objects.all().select_related("owner", "residual_risk")
|
||||
|
||||
# Filter
|
||||
risk_id = request.GET.get("risk")
|
||||
control_id = request.GET.get("control")
|
||||
owner_id = request.GET.get("owner")
|
||||
category = request.GET.get("category")
|
||||
asset = request.GET.get("asset")
|
||||
process = request.GET.get("process")
|
||||
|
||||
if risk_id:
|
||||
qs = qs.filter(id=risk_id)
|
||||
if control_id:
|
||||
qs = qs.filter(controls__id=control_id)
|
||||
if owner_id:
|
||||
qs = qs.filter(owner_id=owner_id)
|
||||
if category:
|
||||
qs = qs.filter(category=category)
|
||||
if asset:
|
||||
qs = qs.filter(asset=asset)
|
||||
if process:
|
||||
qs = qs.filter(process=process)
|
||||
# Filters
|
||||
filters = {
|
||||
"id": request.GET.get("risk"),
|
||||
"controls__id": request.GET.get("control"),
|
||||
"owner_id": request.GET.get("owner"),
|
||||
"category": request.GET.get("category"),
|
||||
"asset": request.GET.get("asset"),
|
||||
"process": request.GET.get("process"),
|
||||
}
|
||||
qs = qs.filter(**{k: v for k, v in filters.items() if v})
|
||||
|
||||
# Sorting
|
||||
sort = request.GET.get("sort") or "title"
|
||||
direction = request.GET.get("dir") or "asc"
|
||||
if direction == "desc":
|
||||
qs = qs.order_by(f"-{sort}")
|
||||
else:
|
||||
qs = qs.order_by(sort)
|
||||
qs = qs.order_by(f"-{sort}" if direction == "desc" else sort)
|
||||
|
||||
risks = qs.distinct()
|
||||
|
||||
risk_choices = Risk.objects.all().order_by("title")
|
||||
control_choices = Control.objects.all().order_by("title")
|
||||
owner_choices = User.objects.filter(owned_risks__isnull=False).distinct().order_by("username")
|
||||
category_choices = (Risk.objects.exclude(category__isnull=True)
|
||||
.exclude(category__exact="")
|
||||
.values_list("category", flat=True)
|
||||
.distinct()
|
||||
.order_by("category"))
|
||||
asset_choices = (Risk.objects.exclude(asset__isnull=True)
|
||||
.exclude(asset__exact="")
|
||||
.values_list("asset", flat=True)
|
||||
.distinct()
|
||||
.order_by("asset"))
|
||||
process_choices = (Risk.objects.exclude(process__isnull=True)
|
||||
.exclude(process__exact="")
|
||||
.values_list("process", flat=True)
|
||||
.distinct()
|
||||
.order_by("process"))
|
||||
|
||||
return render(request, "risks/list_risks.html", {
|
||||
"risks": risks,
|
||||
"risk_choices": risk_choices,
|
||||
"control_choices": control_choices,
|
||||
"owner_choices": owner_choices,
|
||||
"category_choices": category_choices,
|
||||
"asset_choices": asset_choices,
|
||||
"process_choices": process_choices,
|
||||
"risk_choices": Risk.objects.all().order_by("title"),
|
||||
"control_choices": Control.objects.all().order_by("title"),
|
||||
"owner_choices": User.objects.filter(owned_risks__isnull=False).distinct().order_by("username"),
|
||||
"category_choices": (Risk.objects.exclude(category__isnull=True).exclude(category__exact="")
|
||||
.values_list("category", flat=True).distinct().order_by("category")),
|
||||
"asset_choices": (Risk.objects.exclude(asset__isnull=True).exclude(asset__exact="")
|
||||
.values_list("asset", flat=True).distinct().order_by("asset")),
|
||||
"process_choices": (Risk.objects.exclude(process__isnull=True).exclude(process__exact="")
|
||||
.values_list("process", flat=True).distinct().order_by("process")),
|
||||
"current_sort": sort,
|
||||
"current_dir": direction,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def show_risk(request, id):
|
||||
"""
|
||||
View for single risk
|
||||
"""
|
||||
"""Show single risk details + logs."""
|
||||
risk = get_object_or_404(
|
||||
Risk.objects.select_related("residual_risk", "owner").prefetch_related("controls"),
|
||||
pk=id,
|
||||
)
|
||||
|
||||
ct = ContentType.objects.get_for_model(Risk)
|
||||
logs = LogEntry.objects.filter(content_type=ct, object_id=risk.pk).order_by("-action_time")
|
||||
|
||||
return render(request, "risks/item_risk.html", {"risk": risk, "logs": logs})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web Views: Controls
|
||||
# ---------------------------------------------------------------------------
|
||||
@login_required
|
||||
def list_controls(request):
|
||||
"""
|
||||
View for listing all Controls
|
||||
"""
|
||||
"""List all controls with filters."""
|
||||
qs = Control.objects.all().select_related("responsible")
|
||||
|
||||
control_id = request.GET.get("control")
|
||||
risk_id = request.GET.get("risk")
|
||||
status = request.GET.get("status")
|
||||
responsible_id = request.GET.get("responsible")
|
||||
|
||||
if control_id:
|
||||
qs = qs.filter(id=control_id)
|
||||
if risk_id:
|
||||
qs = qs.filter(risks__id=risk_id) # FIX
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
if responsible_id:
|
||||
qs = qs.filter(responsible_id=responsible_id)
|
||||
filters = {
|
||||
"id": request.GET.get("control"),
|
||||
"risks__id": request.GET.get("risk"),
|
||||
"status": request.GET.get("status"),
|
||||
"responsible_id": request.GET.get("responsible"),
|
||||
}
|
||||
qs = qs.filter(**{k: v for k, v in filters.items() if v})
|
||||
|
||||
controls = qs.order_by("title").distinct()
|
||||
|
||||
risks = Risk.objects.all().order_by("title")
|
||||
users = User.objects.filter(responsible_controls__isnull=False).distinct().order_by("username")
|
||||
|
||||
return render(request, "risks/list_controls.html", {
|
||||
"controls": controls,
|
||||
"risks": risks,
|
||||
"users": users,
|
||||
"control_choices": Control.objects.all().order_by("title"),
|
||||
"risk_choices": Risk.objects.all().order_by("title"),
|
||||
"responsible_choices": User.objects.filter(responsible_controls__isnull=False).distinct().order_by("username"),
|
||||
"status_choices": Control.STATUS_CHOICES,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def show_control(request, id):
|
||||
"""Show single control details + logs."""
|
||||
control = get_object_or_404(Control, pk=id)
|
||||
ct = ContentType.objects.get_for_model(Control)
|
||||
logs = LogEntry.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=control.pk
|
||||
).order_by("-action_time")
|
||||
|
||||
logs = LogEntry.objects.filter(content_type=ct, object_id=control.pk).order_by("-action_time")
|
||||
return render(request, "risks/item_control.html", {"control": control, "logs": logs})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web Views: Incidents
|
||||
# ---------------------------------------------------------------------------
|
||||
@login_required
|
||||
def list_incidents(request):
|
||||
"""
|
||||
View for listing all Incidents
|
||||
"""
|
||||
"""List all incidents with filters."""
|
||||
qs = Incident.objects.all().select_related("reported_by").prefetch_related("related_risks")
|
||||
|
||||
risk_id = request.GET.get("risk")
|
||||
status = request.GET.get("status")
|
||||
reported_by = request.GET.get("reported_by")
|
||||
|
||||
if risk_id:
|
||||
qs = qs.filter(related_risks__id=risk_id) # FIX
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
if reported_by:
|
||||
qs = qs.filter(reported_by=reported_by)
|
||||
filters = {
|
||||
"related_risks__id": request.GET.get("risk"),
|
||||
"status": request.GET.get("status"),
|
||||
"reported_by": request.GET.get("reported_by"),
|
||||
}
|
||||
qs = qs.filter(**{k: v for k, v in filters.items() if v})
|
||||
|
||||
incidents = qs.order_by("title").distinct()
|
||||
|
||||
risks = Risk.objects.all().order_by("title")
|
||||
users = User.objects.filter(incidents__isnull=False).distinct().order_by("username") # sinnvoller
|
||||
|
||||
return render(request, "risks/list_incidents.html", {
|
||||
"incidents": incidents,
|
||||
"risks": risks,
|
||||
"users": users,
|
||||
"incident_choices": incidents,
|
||||
"risk_choices": Risk.objects.all().order_by("title"),
|
||||
"user_choices": User.objects.filter(incidents__isnull=False).distinct().order_by("username"),
|
||||
"status_choices": Incident.STATUS_CHOICES,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def show_incident(request, id):
|
||||
"""Show single incident details + logs."""
|
||||
incident = get_object_or_404(Incident, pk=id)
|
||||
ct = ContentType.objects.get_for_model(Incident)
|
||||
logs = LogEntry.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=incident.pk
|
||||
).order_by("-action_time")
|
||||
|
||||
logs = LogEntry.objects.filter(content_type=ct, object_id=incident.pk).order_by("-action_time")
|
||||
return render(request, "risks/item_incident.html", {"incident": incident, "logs": logs})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
"""
|
||||
Dashboardview with KPIs
|
||||
"""
|
||||
"""Dashboard view with KPIs."""
|
||||
# Risikoübersicht
|
||||
risks_total = Risk.objects.count()
|
||||
risks_by_level = Risk.objects.values('level').annotate(count=Count('id'))
|
||||
risks_by_level = Risk.objects.values("level").annotate(count=Count("id"))
|
||||
|
||||
# CIA-Zähler für MultiSelectField
|
||||
risks_cia = Risk.objects.values_list('cia', flat=True)
|
||||
risks_cia = Risk.objects.values_list("cia", flat=True)
|
||||
cia_counter = Counter()
|
||||
for cia_list in risks_cia:
|
||||
if isinstance(cia_list, list): # MultiSelectField gibt Liste zurück
|
||||
if isinstance(cia_list, list):
|
||||
for c in cia_list:
|
||||
cia_counter[c] += 1
|
||||
elif cia_list: # Falls irgendwie noch ein String drin ist
|
||||
elif cia_list:
|
||||
cia_counter[cia_list] += 1
|
||||
|
||||
# Residualrisiken
|
||||
residual_review_required = ResidualRisk.objects.filter(review_required=True).count()
|
||||
|
||||
# Kontrollen
|
||||
controls_by_status = Control.objects.values('status').annotate(count=Count('id'))
|
||||
|
||||
# Incidents
|
||||
incidents_status = Incident.objects.values('status').annotate(count=Count('id'))
|
||||
|
||||
# Benachrichtigungen
|
||||
notifications_unread = Notification.objects.filter(user=request.user, read=False).count()
|
||||
|
||||
print(type(cia_counter), cia_counter)
|
||||
|
||||
# Context für Template
|
||||
context = {
|
||||
'risks_total': risks_total,
|
||||
'risks_by_level': risks_by_level,
|
||||
'risks_by_cia': dict(cia_counter), # <-- hier Counter in dict umwandeln
|
||||
'residual_review_required': residual_review_required,
|
||||
'controls_by_status': controls_by_status,
|
||||
'incidents_status': incidents_status,
|
||||
'notifications_unread': notifications_unread,
|
||||
"risks_total": risks_total,
|
||||
"risks_by_level": risks_by_level,
|
||||
"risks_by_cia": dict(cia_counter),
|
||||
"residual_review_required": ResidualRisk.objects.filter(review_required=True).count(),
|
||||
"controls_by_status": Control.objects.values("status").annotate(count=Count("id")),
|
||||
"incidents_status": Incident.objects.values("status").annotate(count=Count("id")),
|
||||
"notifications_unread": Notification.objects.filter(user=request.user, read=False).count(),
|
||||
}
|
||||
return render(request, 'risks/dashboard.html', context)
|
||||
return render(request, "risks/dashboard.html", context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@login_required
|
||||
def notifications(request):
|
||||
"""Eigene Benachrichtigungen ansehen + filtern"""
|
||||
"""View own notifications with optional filter."""
|
||||
flt = request.GET.get("filter", "unread")
|
||||
qs = Notification.objects.filter(user=request.user).order_by("-created_at")
|
||||
if flt == "unread":
|
||||
qs = qs.filter(read=False)
|
||||
# Einfache Pagination (optional)
|
||||
return render(request, "risks/notifications.html", {
|
||||
"notifications": qs,
|
||||
"filter": flt,
|
||||
})
|
||||
return render(request, "risks/notifications.html", {"notifications": qs, "filter": flt})
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_mark_read(request, pk):
|
||||
"""Mark single notification as read."""
|
||||
if request.method != "POST":
|
||||
return HttpResponseForbidden()
|
||||
notif = get_object_or_404(Notification, pk=pk, user=request.user)
|
||||
|
@ -350,19 +277,23 @@ def notification_mark_read(request, pk):
|
|||
messages.success(request, _("Notification marked as read."))
|
||||
return redirect(request.META.get("HTTP_REFERER") or "risks:notifications")
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_mark_all_read(request):
|
||||
"""Mark all notifications as read."""
|
||||
if request.method != "POST":
|
||||
return HttpResponseForbidden()
|
||||
Notification.objects.filter(user=request.user, read=False).update(read=True)
|
||||
messages.success(request, _("All notifications marked as read."))
|
||||
return redirect("risks:notifications")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status Updates
|
||||
# ---------------------------------------------------------------------------
|
||||
@login_required
|
||||
def update_risk_status(request, id):
|
||||
"""Update risk status."""
|
||||
risk = get_object_or_404(Risk, pk=id)
|
||||
if not _can_edit_risk(request.user, risk):
|
||||
return HttpResponseForbidden()
|
||||
|
@ -375,8 +306,10 @@ def update_risk_status(request, id):
|
|||
messages.success(request, _("Risk status updated."))
|
||||
return redirect("risks:show_risk", id=risk.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def update_control_status(request, id):
|
||||
"""Update control status."""
|
||||
control = get_object_or_404(Control, pk=id)
|
||||
if not _can_edit_control(request.user, control):
|
||||
return HttpResponseForbidden()
|
||||
|
@ -389,8 +322,10 @@ def update_control_status(request, id):
|
|||
messages.success(request, _("Control status updated."))
|
||||
return redirect("risks:show_control", id=control.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def update_incident_status(request, id):
|
||||
"""Update incident status."""
|
||||
incident = get_object_or_404(Incident, pk=id)
|
||||
if not _can_edit_incident(request.user, incident):
|
||||
return HttpResponseForbidden()
|
||||
|
@ -403,13 +338,14 @@ def update_incident_status(request, id):
|
|||
messages.success(request, _("Incident status updated."))
|
||||
return redirect("risks:show_incident", id=incident.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def update_residual_review(request, risk_id):
|
||||
"""Review-Flag (Restrisiko) setzen/lösen"""
|
||||
"""Toggle residual risk review flag."""
|
||||
risk = get_object_or_404(Risk, pk=risk_id)
|
||||
if not _can_edit_risk(request.user, risk):
|
||||
return HttpResponseForbidden()
|
||||
residual, created_resid = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
residual, _ = ResidualRisk.objects.get_or_create(risk=risk)
|
||||
if request.method == "POST":
|
||||
form = ResidualReviewForm(request.POST, instance=residual)
|
||||
if form.is_valid():
|
||||
|
@ -420,21 +356,20 @@ def update_residual_review(request, risk_id):
|
|||
return redirect("risks:show_risk", id=risk.pk)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk Matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
def risk_matrix(request):
|
||||
risks = (Risk.objects
|
||||
.select_related("owner", "residual_risk") # wichtig fürs Netto
|
||||
.all())
|
||||
|
||||
"""Show gross/net risk matrix."""
|
||||
risks = Risk.objects.select_related("owner", "residual_risk").all()
|
||||
impacts = sorted(Risk.IMPACT_CHOICES, key=lambda x: x[0])
|
||||
likelihoods = sorted(Risk.LIKELIHOOD_CHOICES, key=lambda x: x[0])
|
||||
|
||||
gross_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts}
|
||||
net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts}
|
||||
net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts}
|
||||
|
||||
for r in risks:
|
||||
# Brutto platzieren
|
||||
gross_matrix[r.impact][r.likelihood].append(r)
|
||||
# Netto (falls vorhanden) platzieren
|
||||
rr = getattr(r, "residual_risk", None)
|
||||
if rr:
|
||||
net_matrix[rr.impact][rr.likelihood].append(r)
|
||||
|
|
|
@ -173,6 +173,7 @@ abbr { text-decoration: none; }
|
|||
.breadcrumb:not(:last-child) { margin-bottom: 0; border-bottom: 1px solid var(--prosoft-normal); }
|
||||
.breadcrumb { background-color: #f0ebeb; }
|
||||
.breadcrumb a { color: var(--prosoft-normal) !important; }
|
||||
.breadcrumb-add-icon {color: limegreen !important}
|
||||
|
||||
/* =========================
|
||||
Lists inside .content
|
||||
|
@ -310,13 +311,13 @@ body.dark-mode a { color: #bb86fc; }
|
|||
Dark Mode Palette
|
||||
========================= */
|
||||
body.dark-mode {
|
||||
--bg-main: #121212;
|
||||
--bg-surface: #1e1e1e;
|
||||
--bg-main: #3c3c3c;
|
||||
--bg-surface: #3c3c3c;
|
||||
--bg-hover: #2a2a2a;
|
||||
--border-color: #333;
|
||||
--text-main: #f5f5f5;
|
||||
--text-muted: #bbb;
|
||||
--link-color: #bb86fc;
|
||||
--link-color: #fff;
|
||||
--link-hover: #d0aaff;
|
||||
|
||||
background-color: var(--bg-main);
|
||||
|
@ -342,7 +343,7 @@ body.dark-mode .content {
|
|||
Navbar / Topbar
|
||||
========================= */
|
||||
body.dark-mode .navbar.topbar-nav {
|
||||
background-color: var(--bg-surface) !important;
|
||||
|
||||
box-shadow: none;
|
||||
}
|
||||
body.dark-mode .navbar.topbar-nav .navbar-item,
|
||||
|
@ -351,7 +352,7 @@ body.dark-mode .navbar.topbar-nav .navbar-link {
|
|||
}
|
||||
body.dark-mode .navbar.topbar-nav .navbar-item:hover,
|
||||
body.dark-mode .navbar.topbar-nav .navbar-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
|
||||
color: #fff;
|
||||
}
|
||||
body.dark-mode .navbar.topbar-nav .navbar-link::after {
|
||||
|
@ -408,13 +409,12 @@ body.dark-mode td {
|
|||
/* =========================
|
||||
Inputs / Forms
|
||||
========================= */
|
||||
body.dark-mode input,
|
||||
body.dark-mode select,
|
||||
body.dark-mode textarea {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
body.dark-mode input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
@ -424,7 +424,7 @@ body.dark-mode input::placeholder {
|
|||
========================= */
|
||||
body.dark-mode .navbar-dropdown {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
}
|
||||
|
||||
body.dark-mode .navbar-dropdown .navbar-item {
|
||||
|
@ -435,4 +435,17 @@ body.dark-mode .navbar-dropdown .navbar-item {
|
|||
body.dark-mode .navbar-dropdown .navbar-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body.dark-mode .section.has-background-light {
|
||||
background-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .label.is-small {
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .button.is-light {
|
||||
background-color: var(--bg-main) !important;
|
||||
color: var(--text-main) !important;
|
||||
}
|
|
@ -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>
|
||||
</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>
|
||||
{% endif %}
|
||||
</header>
|
||||
<!-- Inhalt Überblick-->
|
||||
{% 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 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>
|
||||
</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 -->
|
||||
|
||||
<!-- 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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Risikoeigner</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Asset</th>
|
||||
<th>Prozess</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{% for risk in control.risks.all %}
|
||||
<tr onclick="window.location.href='/risks/risks/{{ 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="has-text-grey">Keine Verknüpften Risiken.</p>
|
||||
{% endif %}
|
||||
<section>
|
||||
<h3 class="title is-6">{% trans "Description" %}</h3>
|
||||
<p>{{ control.description|default:"–" }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ende Maßnahmen -->
|
||||
</div><!-- Overview 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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Aktion</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>{{ log.get_change_message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="has-text-grey">Keine Historie vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div> <!-- Ende Historie -->
|
||||
<!-- 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 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='{% url 'risks:show_risk' risk.id %}'" style="cursor:pointer;">
|
||||
<td>{{ risk.title }}</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>
|
||||
</div>
|
||||
</div><!-- Linked Risks Tab End -->
|
||||
|
||||
<br><br>
|
||||
<!-- 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 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 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>
|
||||
</div>
|
||||
</div><!-- History Tab End -->
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
<!-- 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 %}
|
||||
|
|
|
@ -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>
|
||||
</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>
|
||||
{% endif %}
|
||||
</header>
|
||||
<!-- Inhalt Überblick-->
|
||||
{% 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 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>
|
||||
</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 -->
|
||||
|
||||
<!-- 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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Risikoeigner</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Asset</th>
|
||||
<th>Prozess</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for risk in incident.related_risks.all %}
|
||||
<tr onclick="window.location.href='/risks/risks/{{ 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="has-text-grey">Keine Verknüpften Risiken.</p>
|
||||
{% endif %}
|
||||
<section>
|
||||
<h3 class="title is-6">{% trans "Description" %}</h3>
|
||||
<p>{{ incident.description|default:"–" }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ende Maßnahmen -->
|
||||
</div><!-- Overview 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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Aktion</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>{{ log.get_change_message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="has-text-grey">Keine Historie vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div> <!-- Ende Historie -->
|
||||
<!-- 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 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='{% url 'risks:show_risk' risk.id %}'" style="cursor:pointer;">
|
||||
<td>{{ risk.title }}</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>
|
||||
</div>
|
||||
</div><!-- Linked Risks Tab End -->
|
||||
|
||||
<br><br>
|
||||
<!-- 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 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 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>
|
||||
</div>
|
||||
</div><!-- History Tab End -->
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
<!-- 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 %}
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -1,177 +1,151 @@
|
|||
{% 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">
|
||||
<select name="control" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
{% for c in controls %}
|
||||
<option value="{{ c.id }}" {% if request.GET.control == c.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ c.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="">{% 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><!-- 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">
|
||||
<select name="risk" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
{% for r in risks %}
|
||||
<option value="{{ r.id }}" {% if request.GET.risk == r.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ r.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="">{% 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><!-- 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">
|
||||
<select name="status" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
{% for key,label in status_choices %}
|
||||
<option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="">{% trans "All" %}</option>
|
||||
{% for key,label in status_choices %}
|
||||
<option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<select name="responsible" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}" {% if request.GET.responsible == u.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ u.get_full_name|default:u.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="">{% 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 -->
|
||||
|
||||
</div>
|
||||
</form>
|
||||
<!-- Filter: Reset -->
|
||||
<div class="column is-2">
|
||||
<label class="label is-small"> </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><!-- Filter: Reset End -->
|
||||
|
||||
<h2 class="title is-5">Maßnahmen</h2>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped 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>
|
||||
</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;">
|
||||
{% if c.risk %}
|
||||
<a href="{% url 'risks:show_risk' c.risk.id %}" onclick="event.stopPropagation();">
|
||||
{{ c.risk.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
<td onclick="window.location.href='{% url 'risks:show_control' c.id %}'" style="cursor:pointer;">
|
||||
{% 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;">
|
||||
{% if c.due_date %}
|
||||
{{ c.due_date|date:"d.m.Y" }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.wiki_link %}
|
||||
<a href="{{ c.wiki_link }}" target="_blank">🔗</a>
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="has-text-centered has-text-grey">Keine Maßnahmen gefunden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section><!-- Filter Section End -->
|
||||
|
||||
{% endblock %}
|
||||
<!-- Controls Table -->
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<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>
|
||||
{% for c in controls %}
|
||||
<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 }}
|
||||
</a>
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="has-text-centered">
|
||||
{% if c.responsible %}
|
||||
{{ c.responsible.get_full_name|default:c.responsible.username }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 class="has-text-centered">
|
||||
{% if c.wiki_link %}
|
||||
<a href="{{ c.wiki_link }}" target="_blank">🔗</a>
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="has-text-grey has-text-centered">{% trans "No controls found." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div><!-- Controls Table End -->
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,154 +1,131 @@
|
|||
{% 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 %}>
|
||||
{{ i.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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><!-- 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">
|
||||
<select name="risk" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
{% for r in risks %}
|
||||
<option value="{{ r.id }}" {% if request.GET.risk == r.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ r.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="">{% 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><!-- 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">
|
||||
<select name="status" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
{% for key,label in status_choices %}
|
||||
<option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="">{% trans "All" %}</option>
|
||||
{% for key,label in status_choices %}
|
||||
<option value="{{ key }}" {% if request.GET.status == key %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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 %}>
|
||||
{{ u.get_full_name|default:u.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</form>
|
||||
</div><!-- Filter: Reporter End -->
|
||||
|
||||
<h2 class="title is-5">Vorfälle</h2>
|
||||
<!-- Filter: Reset -->
|
||||
<div class="column is-2">
|
||||
<label class="label is-small"> </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><!-- Filter: Reset End -->
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped 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>
|
||||
</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;">
|
||||
{% 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>
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section><!-- Filter Section End -->
|
||||
|
||||
{% endblock %}
|
||||
<!-- Incidents Table -->
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<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>
|
||||
{% for i in incidents %}
|
||||
<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 %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="has-text-grey">–</span>
|
||||
{% endif %}
|
||||
</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><!-- Incidents Table End -->
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
{{ n.message }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-grey">{{ n.created_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue