Change Likelihood from Map_Choices to DB

This commit is contained in:
Kevin Heyer 2025-09-22 09:44:51 +02:00
parent 65dc2231bb
commit a5a31f4dcf
10 changed files with 127 additions and 107 deletions

Binary file not shown.

View file

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from .models import ( from .models import (
Control, Control,
Incident, Incident,
LikelihoodChoice,
Notification, Notification,
NotificationPreference, NotificationPreference,
NotificationRule, NotificationRule,
@ -230,3 +231,11 @@ class UserAdmin(BaseUserAdmin):
def responsible_controls_count(self, obj): def responsible_controls_count(self, obj):
return obj.controls_responsible.count() return obj.controls_responsible.count()
responsible_controls_count.short_description = _("Controls Responsible") responsible_controls_count.short_description = _("Controls Responsible")
# ---------------------------------------------------------------------------
# LikelihoodChoice
# ---------------------------------------------------------------------------
@admin.register(LikelihoodChoice)
class LikelihoodChoiceAdmin(admin.ModelAdmin):
list_display = ("value", "name", "description")

View file

@ -1,6 +1,7 @@
from .auditlog import AuditLog from .auditlog import AuditLog
from .control import Control from .control import Control
from .incident import Incident from .incident import Incident
from .likelihood_choice import LikelihoodChoice
from .notification import Notification from .notification import Notification
from .notification_kind import NotificationKind from .notification_kind import NotificationKind
from .notification_preference import NotificationPreference from .notification_preference import NotificationPreference
@ -10,4 +11,4 @@ from .risk import Risk
from .user import User from .user import User
__all__ = ["AuditLog", "Control", "Incident", "Notification", "NotificationKind", "NotificationPreference", "NotificationRule", "ResidualRisk", "Risk", "User"] __all__ = ["AuditLog", "Control", "Incident", "LikelihoodChoice", "Notification", "NotificationKind", "NotificationPreference", "NotificationRule", "ResidualRisk", "Risk", "User"]

View file

@ -0,0 +1,13 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class LikelihoodChoice(models.Model):
"""
Likelihood choices for Risks and Controls.
"""
name = models.CharField(_("Likelihood Name"), max_length=50)
description = models.TextField(_("Description"), blank=True, null=True)
value = models.IntegerField(_("Numeric Value"), unique=True, default=1)
def __str__(self):
return f"{self.value} - {self.name} ({self.description})"

View file

@ -1,7 +1,9 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .likelihood_choice import LikelihoodChoice
from .risk import Risk from .risk import Risk
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Residual Risk # Residual Risk
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -13,7 +15,12 @@ class ResidualRisk(models.Model):
verbose_name_plural = _("Residual Risks") verbose_name_plural = _("Residual Risks")
risk = models.OneToOneField("Risk", on_delete=models.CASCADE, related_name="residual_risk") risk = models.OneToOneField("Risk", on_delete=models.CASCADE, related_name="residual_risk")
likelihood = models.IntegerField(choices=Risk.LIKELIHOOD_CHOICES, default=1) likelihood = models.ForeignKey(
LikelihoodChoice,
on_delete=models.PROTECT,
verbose_name=_("Likelihood"),
related_name="residual_risks",
)
impact = models.IntegerField(choices=Risk.IMPACT_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)
@ -29,7 +36,7 @@ class ResidualRisk(models.Model):
self.status = "review_required" self.status = "review_required"
# Calculate residual risk score and level # Calculate residual risk score and level
self.score = self.likelihood * self.impact self.score = self.likelihood.value * self.impact
if self.score <= 4: if self.score <= 4:
self.level = "Low" self.level = "Low"
elif self.score <= 8: elif self.score <= 8:

View file

@ -2,6 +2,7 @@ from django.db import models
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField from multiselectfield import MultiSelectField
from .likelihood_choice import LikelihoodChoice
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Risk # Risk
@ -18,12 +19,6 @@ class Risk(models.Model):
("closed", _("Closed")), ("closed", _("Closed")),
("review_required", _("Review required")), ("review_required", _("Review required")),
] ]
LIKELIHOOD_CHOICES = [
(1, _("Very low occurs less than once every 5 years")),
(2, _("Low once every 15 years")),
(3, _("Likely once per year or more")),
(4, _("Very likely multiple times per year/monthly")),
]
IMPACT_CHOICES = [ IMPACT_CHOICES = [
(1, _("Very Low (< 1,000 € minor operational impact)")), (1, _("Very Low (< 1,000 € minor operational impact)")),
(2, _("Low (1,0005,000 € local impact)")), (2, _("Low (1,0005,000 € local impact)")),
@ -58,7 +53,12 @@ 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(choices=LIKELIHOOD_CHOICES, default=1) likelihood = models.ForeignKey(
LikelihoodChoice,
on_delete=models.PROTECT,
verbose_name=_("Likelihood"),
related_name="risks",
)
impact = models.IntegerField(choices=IMPACT_CHOICES, default=1) impact = models.IntegerField(choices=IMPACT_CHOICES, default=1)
# Calculated fields # Calculated fields
@ -85,7 +85,7 @@ class Risk(models.Model):
self.status = "review_required" self.status = "review_required"
# Calculate risk score and level # Calculate risk score and level
self.score = self.likelihood * self.impact self.score = self.likelihood.value * self.impact
if self.score <= 4: if self.score <= 4:
self.level = "Low" self.level = "Low"
elif self.score <= 8: elif self.score <= 8:

View file

@ -1,16 +1,50 @@
from django import template from django import template
from django.utils.html import format_html from django.utils.html import format_html
from ..models import Control, Incident, Risk from ..models import Control, Incident, Risk, LikelihoodChoice
register = template.Library() register = template.Library()
_RISK_STATUS_MAP = dict(Risk.STATUS_CHOICES) _RISK_STATUS_MAP = dict(Risk.STATUS_CHOICES)
_CONTROL_STATUS_MAP = dict(Control.STATUS_CHOICES) _CONTROL_STATUS_MAP = dict(Control.STATUS_CHOICES)
_INCIDENT_STATUS_MAP = dict(Incident.STATUS_CHOICES) _INCIDENT_STATUS_MAP = dict(Incident.STATUS_CHOICES)
_LIKELIHOOD_LABELS = dict(Risk.LIKELIHOOD_CHOICES)
_IMPACT_LABELS = dict(Risk.IMPACT_CHOICES) _IMPACT_LABELS = dict(Risk.IMPACT_CHOICES)
LEVEL_ID_MAP = {"Low": 1, "Medium": 2, "High": 3, "Critical": 4} LEVEL_ID_MAP = {"Low": 1, "Medium": 2, "High": 3, "Critical": 4}
# Likelihood
def get_likelihood_label(likelihood_obj):
if not likelihood_obj:
return ""
return f"{likelihood_obj.value} ({likelihood_obj.name})"
@register.filter
def likelihood_id_label(likelihood):
if isinstance(likelihood, LikelihoodChoice):
return f"{likelihood.value} ({likelihood.name})"
return likelihood
def get_likelihood_value(likelihood):
if isinstance(likelihood, LikelihoodChoice):
return likelihood.value
return likelihood
@register.filter
def likelihood_value(likelihood_obj):
return get_likelihood_value(likelihood_obj)
def get_likelihood_class(likelihood):
value = get_likelihood_value(likelihood)
LIKELIHOOD_MAP = {
1: "is-control-verylow",
2: "is-control-low",
3: "is-control-mid",
4: "is-control-high",
}
return LIKELIHOOD_MAP.get(value, "is-light")
@register.filter
def likelihood_class(likelihood):
return get_likelihood_class(likelihood)
@register.simple_tag @register.simple_tag
def sort_url(request, field, current_sort, current_dir): def sort_url(request, field, current_sort, current_dir):
query = request.GET.copy() query = request.GET.copy()
@ -56,16 +90,6 @@ def _short(label: str) -> str:
return label.split(sep, 1)[0].strip() return label.split(sep, 1)[0].strip()
return label.strip() return label.strip()
@register.filter
def likelihood_id_label(val):
try:
i = int(val)
except (TypeError, ValueError):
return ""
label = _LIKELIHOOD_LABELS.get(i, "")
short = _short(str(label)) if label else ""
return format_html("{} ({})", i, short) if label else format_html("{}", i)
@register.filter @register.filter
def impact_id_label(val): def impact_id_label(val):
try: try:
@ -115,13 +139,6 @@ LEVEL_MAP = {
"Critical": "is-control-veryhigh", "Critical": "is-control-veryhigh",
} }
@register.filter
def likelihood_class(val):
try:
return LIKELIHOOD_MAP.get(int(val), "is-light")
except (TypeError, ValueError):
return "is-light"
@register.filter @register.filter
def impact_class(val): def impact_class(val):
try: try:

View file

@ -13,7 +13,7 @@ from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm from .forms import RiskStatusForm, ControlStatusForm, IncidentStatusForm, ResidualReviewForm
from .models import AuditLog, Risk, Control, ResidualRisk, AuditLog, Incident, Notification from .models import AuditLog, Risk, Control, ResidualRisk, AuditLog, Incident, LikelihoodChoice, Notification
from .serializers import ( from .serializers import (
ControlSerializer, RiskSerializer, ResidualRiskSerializer, ControlSerializer, RiskSerializer, ResidualRiskSerializer,
UserSerializer, AuditSerializer, IncidentSerializer, UserSerializer, AuditSerializer, IncidentSerializer,
@ -467,16 +467,17 @@ def risk_matrix(request):
"""Show gross/net risk matrix.""" """Show gross/net risk matrix."""
risks = Risk.objects.select_related("owner", "residual_risk").all() risks = Risk.objects.select_related("owner", "residual_risk").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 = LikelihoodChoice.objects.all().order_by('value')
gross_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts} # Erstelle die Matrizen mit den Werten der LikelihoodChoice-Objekte
net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts} gross_matrix = {i: {likelihood.value: [] for likelihood in likelihoods} for i, _ in impacts}
net_matrix = {i: {likelihood.value: [] for likelihood in likelihoods} for i, _ in impacts}
for r in risks: for r in risks:
gross_matrix[r.impact][r.likelihood].append(r) gross_matrix[r.impact][r.likelihood.value].append(r)
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.value].append(r)
return render(request, "risks/risk_matrix.html", { return render(request, "risks/risk_matrix.html", {
"impacts": impacts, "impacts": impacts,

View file

@ -98,8 +98,8 @@
<div class="column is-half"> <div class="column is-half">
<button class="risk-chip {{ risk.likelihood|likelihood_class }}" type="button"> <button class="risk-chip {{ risk.likelihood|likelihood_class }}" type="button">
<span class="chip-head">{% trans "Likelihood" %}</span> <span class="chip-head">{% trans "Likelihood" %}</span>
<span class="chip-id">{{ risk.likelihood }}</span> <span class="chip-id">{{ risk.likelihood.value }} - {{ risk.likelihood.name }}</span>
<span class="chip-label">{{ risk.get_likelihood_display }}</span> <span class="chip-label">{{ risk.likelihood.description }}</span>
</button> </button>
</div> </div>
@ -144,8 +144,8 @@
<div class="column is-half"> <div class="column is-half">
<button class="risk-chip {{ risk.residual_risk.likelihood|likelihood_class }}" type="button"> <button class="risk-chip {{ risk.residual_risk.likelihood|likelihood_class }}" type="button">
<span class="chip-head">{% trans "Likelihood" %}</span> <span class="chip-head">{% trans "Likelihood" %}</span>
<span class="chip-id">{{ risk.residual_risk.likelihood }}</span> <span class="chip-id">{{ risk.residual_risk.likelihood.value }} - {{ risk.residual_risk.likelihood.name }}</span>
<span class="chip-label">{{ risk.residual_risk.get_likelihood_display }}</span> <span class="chip-label">{{ risk.residual_risk.likelihood.description }}</span>
</button> </button>
</div> </div>

View file

@ -8,10 +8,7 @@
<a class="is-active" data-tab="matrix">{% trans "Risk Matrix" %}</a> <a class="is-active" data-tab="matrix">{% trans "Risk Matrix" %}</a>
<a data-tab="details">{% trans "Detail View" %}</a> <a data-tab="details">{% trans "Detail View" %}</a>
</div> </div>
<section class="section"> <section class="section">
<!-- Main Container -->
<div class="box"> <div class="box">
<!-- Panel: Matrix View --> <!-- Panel: Matrix View -->
<div class="tab-panel" data-tab="matrix"> <div class="tab-panel" data-tab="matrix">
@ -19,8 +16,8 @@
<thead> <thead>
<tr> <tr>
<th class="has-text-left">{% trans "Impact" %} * {% trans "Likelihood" %}</th> <th class="has-text-left">{% trans "Impact" %} * {% trans "Likelihood" %}</th>
{% for l_val, l_label in likelihoods %} {% for likelihood in likelihoods %}
<th class="{{ l_val|likelihood_class|to_bg }}">{{ l_label }}</th> <th class="{{ likelihood.value|likelihood_class|to_bg }}">{{ likelihood.value }} ({{ likelihood.name }}) <br> {{ likelihood.description }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@ -28,8 +25,8 @@
{% for i_val, i_label in impacts reversed %} {% for i_val, i_label in impacts reversed %}
<tr> <tr>
<th class="has-text-left {{ i_val|impact_class|to_bg }}">{{ i_label }}</th> <th class="has-text-left {{ i_val|impact_class|to_bg }}">{{ i_label }}</th>
{% for l_val, l_label in likelihoods %} {% for likelihood in likelihoods %}
{% with s=i_val|mul:l_val %} {% with s=i_val|mul:likelihood.value %}
<td class="risk-matrix-cell {{ s|score_bg_class }}"> <td class="risk-matrix-cell {{ s|score_bg_class }}">
<div class="is-flex is-justify-content-center is-align-items-center"> <div class="is-flex is-justify-content-center is-align-items-center">
{% if s <= 4 %} {% if s <= 4 %}
@ -49,12 +46,9 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div><!-- Panel: Matrix View End --> </div>
<!-- Panel: Details View --> <!-- Panel: Details View -->
<div class="tab-panel is-hidden" data-tab="details"> <div class="tab-panel is-hidden" data-tab="details">
<!-- Mode Toggle (Gross / Net) -->
<div class="level mb-3"> <div class="level mb-3">
<div class="level-left"> <div class="level-left">
<div class="level-item"><strong>{% trans "Show" %}:</strong></div> <div class="level-item"><strong>{% trans "Show" %}:</strong></div>
@ -69,16 +63,15 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- Mode Toggle End --> </div>
<!-- Gross Risk List --> <!-- Gross Risk List -->
<div class="details-table" data-mode="gross"> <div class="details-table" data-mode="gross">
<table class="table is-bordered is-fullwidth has-text-centered risk-matrix-table"> <table class="table is-bordered is-fullwidth has-text-centered risk-matrix-table">
<thead> <thead>
<tr> <tr>
<th class="has-text-left">{% trans "Impact" %} / {% trans "Likelihood" %}</th> <th class="has-text-left">{% trans "Impact" %} / {% trans "Likelihood" %}</th>
{% for l_val, l_label in likelihoods %} {% for likelihood in likelihoods %}
<th class="{{ l_val|likelihood_class|to_bg }}">{{ l_label }}</th> <th class="{{ likelihood.value|likelihood_class|to_bg }}">{{ likelihood.value }} ({{ likelihood.name }})</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@ -86,41 +79,40 @@
{% for i_val, i_label in impacts reversed %} {% for i_val, i_label in impacts reversed %}
<tr> <tr>
<th class="has-text-left {{ i_val|impact_class|to_bg }}">{{ i_label }}</th> <th class="has-text-left {{ i_val|impact_class|to_bg }}">{{ i_label }}</th>
{% for l_val, l_label in likelihoods %} {% for likelihood in likelihoods %}
{% with row=gross_matrix|dict_get:i_val %} {% with row=gross_matrix|dict_get:i_val %}
{% with cell=row|dict_get:l_val %} {% with cell=row|dict_get:likelihood.value %}
{% with s=i_val|mul:l_val %} {% with s=i_val|mul:likelihood.value %}
<td class="risk-matrix-cell {{ s|score_bg_class }}"> <td class="risk-matrix-cell {{ likelihood|likelihood_class }}">
{% if cell %} {% if cell %}
<ul class="risk-cell-list"> <ul class="risk-cell-list">
{% for risk in cell %} {% for risk in cell %}
<li style="list-style:none;"> <li style="list-style:none;">
<a href="{% url 'risks:show_risk' risk.id %}" class="tag">{{ risk.title }}</a> <a href="{% url 'risks:show_risk' risk.id %}" class="tag">{{ risk.title }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<span class="has-text-grey"></span> <span class="has-text-grey"></span>
{% endif %} {% endif %}
</td> </td>
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div><!-- Gross Risk List End --> </div>
<!-- Net Risk List --> <!-- Net Risk List -->
<div class="details-table is-hidden" data-mode="net"> <div class="details-table is-hidden" data-mode="net">
<table class="table is-bordered is-fullwidth has-text-centered risk-matrix-table"> <table class="table is-bordered is-fullwidth has-text-centered risk-matrix-table">
<thead> <thead>
<tr> <tr>
<th class="has-text-left">{% trans "Impact" %} / {% trans "Likelihood" %}</th> <th class="has-text-left">{% trans "Impact" %} / {% trans "Likelihood" %}</th>
{% for l_val, l_label in likelihoods %} {% for likelihood in likelihoods %}
<th class="{{ l_val|likelihood_class|to_bg }}">{{ l_label }}</th> <th class="{{ likelihood.value|likelihood_class|to_bg }}">{{ likelihood.value }} ({{ likelihood.name }})</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@ -128,11 +120,11 @@
{% for i_val, i_label in impacts reversed %} {% for i_val, i_label in impacts reversed %}
<tr> <tr>
<th class="has-text-left {{ i_val|impact_class|to_bg }}">{{ i_label }}</th> <th class="has-text-left {{ i_val|impact_class|to_bg }}">{{ i_label }}</th>
{% for l_val, l_label in likelihoods %} {% for likelihood in likelihoods %}
{% with row=net_matrix|dict_get:i_val %} {% with row=net_matrix|dict_get:i_val %}
{% with cell=row|dict_get:l_val %} {% with cell=row|dict_get:likelihood.value %}
{% with s=i_val|mul:l_val %} {% with s=i_val|mul:likelihood.value %}
<td class="risk-matrix-cell {{ s|score_bg_class }}"> <td class="risk-matrix-cell {{ likelihood|likelihood_class }}">
{% if cell %} {% if cell %}
<ul class="risk-cell-list"> <ul class="risk-cell-list">
{% for risk in cell %} {% for risk in cell %}
@ -153,49 +145,31 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div><!-- Net Risk List End --> </div>
</div>
</div><!-- Panel: Details View End --> </div>
</section>
</div><!-- Main Container End -->
</section><!-- Section End -->
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Handle ERP-style tabs
const tabs = document.querySelectorAll('.erp-tabs a[data-tab]'); const tabs = document.querySelectorAll('.erp-tabs a[data-tab]');
const panels = document.querySelectorAll('.tab-panel'); const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => { tabs.forEach(tab => {
tab.addEventListener('click', e => { tab.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
// Deactivate all
tabs.forEach(x => x.classList.remove('is-active')); tabs.forEach(x => x.classList.remove('is-active'));
panels.forEach(p => p.classList.add('is-hidden')); panels.forEach(p => p.classList.add('is-hidden'));
// Activate clicked
tab.classList.add('is-active'); tab.classList.add('is-active');
const target = tab.getAttribute('data-tab'); const target = tab.getAttribute('data-tab');
const activePanel = document.querySelector(`.tab-panel[data-tab="${target}"]`); const activePanel = document.querySelector(`.tab-panel[data-tab="${target}"]`);
if (activePanel) activePanel.classList.remove('is-hidden'); if (activePanel) activePanel.classList.remove('is-hidden');
}); });
}); });
// Handle Gross/Net toggle buttons in "Details" tab
const toggles = document.querySelectorAll('.details-toggle'); const toggles = document.querySelectorAll('.details-toggle');
const tables = document.querySelectorAll('.details-table'); const tables = document.querySelectorAll('.details-table');
toggles.forEach(btn => { toggles.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const mode = btn.getAttribute('data-mode'); // "gross" or "net" const mode = btn.getAttribute('data-mode');
// Toggle active state on buttons
toggles.forEach(b => b.classList.toggle('is-active', b === btn)); toggles.forEach(b => b.classList.toggle('is-active', b === btn));
// Show the right table
tables.forEach(t => { tables.forEach(t => {
t.classList.toggle('is-hidden', t.getAttribute('data-mode') !== mode); t.classList.toggle('is-hidden', t.getAttribute('data-mode') !== mode);
}); });
@ -203,6 +177,4 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}