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 (
Control,
Incident,
LikelihoodChoice,
Notification,
NotificationPreference,
NotificationRule,
@ -230,3 +231,11 @@ class UserAdmin(BaseUserAdmin):
def responsible_controls_count(self, obj):
return obj.controls_responsible.count()
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 .control import Control
from .incident import Incident
from .likelihood_choice import LikelihoodChoice
from .notification import Notification
from .notification_kind import NotificationKind
from .notification_preference import NotificationPreference
@ -10,4 +11,4 @@ from .risk import Risk
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.utils.translation import gettext_lazy as _
from .likelihood_choice import LikelihoodChoice
from .risk import Risk
# ---------------------------------------------------------------------------
# Residual Risk
# ---------------------------------------------------------------------------
@ -13,7 +15,12 @@ class ResidualRisk(models.Model):
verbose_name_plural = _("Residual Risks")
risk = models.OneToOneField("Risk", on_delete=models.CASCADE, related_name="residual_risk")
likelihood = models.IntegerField(choices=Risk.LIKELIHOOD_CHOICES, default=1)
likelihood = models.ForeignKey(
LikelihoodChoice,
on_delete=models.PROTECT,
verbose_name=_("Likelihood"),
related_name="residual_risks",
)
impact = models.IntegerField(choices=Risk.IMPACT_CHOICES, default=1)
score = models.IntegerField(editable=False)
level = models.CharField(max_length=50, editable=False)
@ -29,7 +36,7 @@ class ResidualRisk(models.Model):
self.status = "review_required"
# Calculate residual risk score and level
self.score = self.likelihood * self.impact
self.score = self.likelihood.value * self.impact
if self.score <= 4:
self.level = "Low"
elif self.score <= 8:

View file

@ -2,6 +2,7 @@ from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField
from .likelihood_choice import LikelihoodChoice
# ---------------------------------------------------------------------------
# Risk
@ -18,12 +19,6 @@ class Risk(models.Model):
("closed", _("Closed")),
("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 = [
(1, _("Very Low (< 1,000 € minor operational 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)
# 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)
# Calculated fields
@ -85,7 +85,7 @@ class Risk(models.Model):
self.status = "review_required"
# Calculate risk score and level
self.score = self.likelihood * self.impact
self.score = self.likelihood.value * self.impact
if self.score <= 4:
self.level = "Low"
elif self.score <= 8:

View file

@ -1,16 +1,50 @@
from django import template
from django.utils.html import format_html
from ..models import Control, Incident, Risk
from ..models import Control, Incident, Risk, LikelihoodChoice
register = template.Library()
_RISK_STATUS_MAP = dict(Risk.STATUS_CHOICES)
_CONTROL_STATUS_MAP = dict(Control.STATUS_CHOICES)
_INCIDENT_STATUS_MAP = dict(Incident.STATUS_CHOICES)
_LIKELIHOOD_LABELS = dict(Risk.LIKELIHOOD_CHOICES)
_IMPACT_LABELS = dict(Risk.IMPACT_CHOICES)
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
def sort_url(request, field, current_sort, current_dir):
query = request.GET.copy()
@ -56,16 +90,6 @@ def _short(label: str) -> str:
return label.split(sep, 1)[0].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
def impact_id_label(val):
try:
@ -115,13 +139,6 @@ LEVEL_MAP = {
"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
def impact_class(val):
try:

View file

@ -13,7 +13,7 @@ from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
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 (
ControlSerializer, RiskSerializer, ResidualRiskSerializer,
UserSerializer, AuditSerializer, IncidentSerializer,
@ -467,16 +467,17 @@ def risk_matrix(request):
"""Show gross/net risk matrix."""
risks = Risk.objects.select_related("owner", "residual_risk").all()
impacts = sorted(Risk.IMPACT_CHOICES, key=lambda x: x[0])
likelihoods = sorted(Risk.LIKELIHOOD_CHOICES, key=lambda x: x[0])
likelihoods = LikelihoodChoice.objects.all().order_by('value')
gross_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts}
net_matrix = {i: {l: [] for l, _ in likelihoods} for i, _ in impacts}
# Erstelle die Matrizen mit den Werten der LikelihoodChoice-Objekte
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:
gross_matrix[r.impact][r.likelihood].append(r)
gross_matrix[r.impact][r.likelihood.value].append(r)
rr = getattr(r, "residual_risk", None)
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", {
"impacts": impacts,

View file

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

View file

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