import datetime import json from django.conf import settings from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from multiselectfield import MultiSelectField # --------------------------------------------------------------------------- # SafeJSONEncoder # --------------------------------------------------------------------------- class SafeJSONEncoder(DjangoJSONEncoder): """JSON encoder that can handle datetime.date properly.""" def default(self, obj): if isinstance(obj, datetime.date): return obj.isoformat() return super().default(obj) # --------------------------------------------------------------------------- # User # --------------------------------------------------------------------------- class User(AbstractUser): """Custom user model to support both local and SSO users.""" is_sso_user = models.BooleanField(default=False) @property def risks_owned(self): """All risks where the user is the risk owner.""" return self.owned_risks.all() @property def controls_responsible(self): """All controls where the user is responsible.""" return self.responsible_controls.all() # --------------------------------------------------------------------------- # Risk # --------------------------------------------------------------------------- class Risk(models.Model): class Meta: verbose_name = _("Risk") verbose_name_plural = _("Risks") STATUS_CHOICES = [ ("open", _("Open")), ("in_progress", _("In Progress")), ("closed", _("Closed")), ("review_required", _("Review required")), ] LIKELIHOOD_CHOICES = [ (1, _("Very low – occurs less than once every 5 years")), (2, _("Low – once every 1–5 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,000–5,000 € – local impact)")), (3, _("High (5,000–15,000 € – team-level impact)")), (4, _("Severe (50,000–100,000 € – regional impact)")), (5, _("Critical (> 100,000 € – existential threat)")), ] CIA_CHOICES = [ ("1", _("Confidentiality")), ("2", _("Integrity")), ("3", _("Availability")), ] # Basic information title = models.CharField(_("Title"), max_length=255) description = models.TextField(_("Description"), max_length=225, blank=True, null=True) asset = models.CharField(_("Asset"), max_length=255, blank=True, null=True) process = models.CharField(_("Process"), max_length=255, blank=True, null=True) category = models.CharField(_("Category"), max_length=255, blank=True, null=True) created_at = models.DateTimeField(_("Created at"), auto_now_add=True) updated_at = models.DateTimeField(_("Updated at"), auto_now=True) effects = models.TextField(_("Effects"), blank=True, null=True) status = models.CharField( _("Status"), max_length=20, choices=STATUS_CHOICES, default="open", db_index=True, ) # CIA Protection Goals cia = MultiSelectField(choices=CIA_CHOICES, max_length=100, blank=True, null=True) # Risk evaluation before controls likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES, default=1) impact = models.IntegerField(choices=IMPACT_CHOICES, default=1) # Calculated fields score = models.IntegerField(editable=False) level = models.CharField(max_length=50, editable=False) # Ownership owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="owned_risks" ) # Reminder / follow-up date follow_up = models.DateField(blank=True, null=True) def save(self, *args, **kwargs): # Mark for review if likelihood/impact changed if self.pk: old = Risk.objects.get(pk=self.pk) if old.likelihood != self.likelihood or old.impact != self.impact: self.review_required = True self.status = "review_required" # Calculate risk score and level self.score = self.likelihood * self.impact if self.score <= 4: self.level = "Low" elif self.score <= 8: self.level = "Medium" elif self.score <= 12: self.level = "High" else: self.level = "Critical" super().save(*args, **kwargs) def __str__(self): return f"{self.title} (Score: {self.score}, Level: {self.level})" # --------------------------------------------------------------------------- # Residual Risk # --------------------------------------------------------------------------- class ResidualRisk(models.Model): """Residual risk after implementing controls.""" class Meta: verbose_name = _("Residual Risk") verbose_name_plural = _("Residual Risks") risk = models.OneToOneField(Risk, on_delete=models.CASCADE, related_name="residual_risk") likelihood = models.IntegerField(choices=Risk.LIKELIHOOD_CHOICES, default=1) impact = models.IntegerField(choices=Risk.IMPACT_CHOICES, default=1) score = models.IntegerField(editable=False) level = models.CharField(max_length=50, editable=False) review_required = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): if self.pk: old = ResidualRisk.objects.get(pk=self.pk) if old.likelihood != self.likelihood or old.impact != self.impact: self.review_required = True self.status = "review_required" # Calculate residual risk score and level self.score = self.likelihood * self.impact if self.score <= 4: self.level = "Low" elif self.score <= 8: self.level = "Medium" elif self.score <= 12: self.level = "High" else: self.level = "Critical" super().save(*args, **kwargs) def __str__(self): return f"Residual Risk for {self.risk.title} (Score: {self.score}, Level: {self.level})" # --------------------------------------------------------------------------- # Control # --------------------------------------------------------------------------- class Control(models.Model): """Security control/measure linked to a risk.""" class Meta: verbose_name = _("Control") verbose_name_plural = _("Controls") STATUS_CHOICES = [ ("planned", _("Planned")), ("in_progress", _("In progress")), ("completed", _("Completed")), ("verified", _("Verified")), ("rejected", _("Rejected")), ] title = models.CharField(_("Title"), max_length=255) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="planned") due_date = models.DateField(blank=True, null=True) responsible = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="responsible_controls" ) description = models.TextField(blank=True, null=True) wiki_link = models.URLField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # Relation to risk risks = models.ManyToManyField(Risk, related_name="controls", blank=True) def __str__(self): return f"{self.title} ({self.get_status_display()})" # --------------------------------------------------------------------------- # AuditLog # --------------------------------------------------------------------------- class AuditLog(models.Model): """Generic audit log entry for tracking changes.""" class Meta: verbose_name = _("Auditlog") verbose_name_plural = _("Auditlogs") ACTION_CHOICES = [ ("create", "Created"), ("update", "Updated"), ("delete", "Deleted"), ] user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="audit_logs" ) action = models.CharField(max_length=10, choices=ACTION_CHOICES) model = models.CharField(max_length=100) object_id = models.CharField(max_length=50) changes = models.JSONField(null=True, blank=True, encoder=SafeJSONEncoder) timestamp = models.DateTimeField(auto_now_add=True) def __str__(self): return f"[{self.timestamp}] {self.user} {self.action} {self.model}({self.object_id})" # --------------------------------------------------------------------------- # Incident # --------------------------------------------------------------------------- class Incident(models.Model): """Incidents and related risks.""" class Meta: verbose_name = _("Incident") verbose_name_plural = _("Incidents") STATUS_CHOICES = [ ("open", _("Opened")), ("in_progress", _("In Progress")), ("closed", _("Closed")), ] title = models.CharField(_("Title"), max_length=255) description = models.TextField(_("Description"), blank=True, null=True) date_reported = models.DateField(_("Date reported"), blank=True, null=True) reported_by = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_("Reported by"), null=True, blank=True, on_delete=models.SET_NULL, related_name="incidents" ) status = models.CharField(max_length=12, choices=STATUS_CHOICES) related_risks = models.ManyToManyField(Risk, blank=True, related_name="incidents") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # --------------------------------------------------------------------------- # NotificationKind # --------------------------------------------------------------------------- class NotificationKind(models.TextChoices): """Event types for notifications.""" RISK_CREATED = "risk.created", _("Risk created") RISK_UPDATED = "risk.updated", _("Risk updated") RISK_DELETED = "risk.deleted", _("Risk deleted") RISK_REVIEW_REQUIRED = "risk.review_required", _("Risk review required") RISK_REVIEW_COMPLETED = "risk.review_completed", _("Risk review completed") CONTROL_CREATED = "control.created", _("Control created") CONTROL_UPDATED = "control.updated", _("Control updated") CONTROL_DELETED = "control.deleted", _("Control deleted") RESIDUAL_CREATED = "residual.created", _("Residual created") RESIDUAL_UPDATED = "residual.updated", _("Residual updated") RESIDUAL_DELETED = "residual.deleted", _("Residual deleted") RESIDUAL_REVIEW_REQUIRED = "residual.review_required", _("Residual review required") RESIDUAL_REVIEW_COMPLETED = "residual.review_completed", _("Residual review completed") INCIDENT_CREATED = "incident.created", _("Incident created") INCIDENT_UPDATED = "incident.updated", _("Incident updated") INCIDENT_DELETED = "incident.deleted", _("Incident deleted") USER_CREATED = "user.created", _("User created") USER_DELETED = "user.deleted", _("User deleted") # --------------------------------------------------------------------------- # Notification # --------------------------------------------------------------------------- class Notification(models.Model): class Meta: verbose_name = _("Notification") verbose_name_plural = _("Notifications") user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications" ) message = models.TextField() kind = models.CharField(max_length=40, choices=NotificationKind.choices, default="") created_at = models.DateTimeField(auto_now_add=True) read = models.BooleanField(default=False) # Read in WebApp sent = models.BooleanField(default=False) # Sent via Mail (optional) # Optional relation to any object content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) object_id = models.PositiveIntegerField(null=True, blank=True) related_object = GenericForeignKey("content_type", "object_id") target_url = models.CharField(max_length=500, blank=True, null=True) 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): """User-specific notification preferences.""" user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notification_preference", verbose_name=_("User"), ) # Risks risk_created = models.BooleanField(default=True) risk_updated = models.BooleanField(default=True) risk_deleted = models.BooleanField(default=True) # Controls control_created = models.BooleanField(default=True) control_updated = models.BooleanField(default=True) control_deleted = models.BooleanField(default=True) # Residual risks residual_created = models.BooleanField(default=True) residual_updated = models.BooleanField(default=True) residual_deleted = models.BooleanField(default=True) # Reviews review_required = models.BooleanField(default=True) review_completed = models.BooleanField(default=True) # Users user_created = models.BooleanField(default=False) user_deleted = models.BooleanField(default=False) # Incidents incident_created = models.BooleanField(default=True) incident_updated = models.BooleanField(default=True) incident_deleted = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return f"Prefs({self.user})" def should_notify(self, event_code: str) -> bool: """Return True if user wants notifications for this event code.""" return bool(getattr(self, event_code, False)) # --------------------------------------------------------------------------- # NotificationRule # --------------------------------------------------------------------------- class NotificationRule(models.Model): """Global rules: Which events trigger in-app and/or email notifications.""" class Meta: verbose_name = _("Notification rule") verbose_name_plural = _("Notification rules") kind = models.CharField( _("Event"), max_length=40, choices=NotificationKind.choices, unique=True, ) enabled_in_app = models.BooleanField(_("Show in app"), default=True) enabled_email = models.BooleanField(_("Send via email"), default=False) # Recipient groups to_owner = models.BooleanField( _("Send to owner/responsible/reporter (if available)"), default=True, ) to_staff = models.BooleanField(_("Send to all staff"), default=False) extra_recipients = models.TextField( _("Extra recipients (emails, comma or newline separated)"), blank=True, ) def __str__(self): return self.get_kind_display() or self.kind