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