refactor risks models

This commit is contained in:
Kevin Heyer 2025-09-22 08:35:11 +02:00
parent bfd1c081e9
commit 65dc2231bb
42 changed files with 625 additions and 1363 deletions

Binary file not shown.

View file

@ -1,9 +1,11 @@
# Generated by Django 5.2.6 on 2025-09-05 20:00 # Generated by Django 5.2.6 on 2025-09-22 06:32
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import multiselectfield.db.fields
import risks.models.auditlog
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -14,9 +16,26 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
] ]
operations = [ operations = [
migrations.CreateModel(
name='NotificationRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', 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')], max_length=40, unique=True, verbose_name='Event')),
('enabled_in_app', models.BooleanField(default=True, verbose_name='Show in app')),
('enabled_email', models.BooleanField(default=False, verbose_name='Send via email')),
('to_owner', models.BooleanField(default=True, verbose_name='Send to owner/responsible/reporter (if available)')),
('to_staff', models.BooleanField(default=False, verbose_name='Send to all staff')),
('extra_recipients', models.TextField(blank=True, verbose_name='Extra recipients (emails, comma or newline separated)')),
],
options={
'verbose_name': 'Notification rule',
'verbose_name_plural': 'Notification rules',
},
),
migrations.CreateModel( migrations.CreateModel(
name='User', name='User',
fields=[ fields=[
@ -44,36 +63,145 @@ class Migration(migrations.Migration):
('objects', django.contrib.auth.models.UserManager()), ('objects', django.contrib.auth.models.UserManager()),
], ],
), ),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('create', 'Created'), ('update', 'Updated'), ('delete', 'Deleted')], max_length=10)),
('model', models.CharField(max_length=100)),
('object_id', models.CharField(max_length=50)),
('changes', models.JSONField(blank=True, encoder=risks.models.auditlog.SafeJSONEncoder, null=True)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Auditlog',
'verbose_name_plural': 'Auditlogs',
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField()),
('kind', 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)),
('created_at', models.DateTimeField(auto_now_add=True)),
('read', models.BooleanField(default=False)),
('sent', models.BooleanField(default=False)),
('object_id', models.PositiveIntegerField(blank=True, null=True)),
('target_url', models.CharField(blank=True, max_length=500, null=True)),
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
},
),
migrations.CreateModel(
name='NotificationPreference',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('risk_created', models.BooleanField(default=True)),
('risk_updated', models.BooleanField(default=True)),
('risk_deleted', models.BooleanField(default=True)),
('control_created', models.BooleanField(default=True)),
('control_updated', models.BooleanField(default=True)),
('control_deleted', models.BooleanField(default=True)),
('residual_created', models.BooleanField(default=True)),
('residual_updated', models.BooleanField(default=True)),
('residual_deleted', models.BooleanField(default=True)),
('review_required', models.BooleanField(default=True)),
('review_completed', models.BooleanField(default=True)),
('user_created', models.BooleanField(default=False)),
('user_deleted', models.BooleanField(default=False)),
('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)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preference', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
),
migrations.CreateModel( migrations.CreateModel(
name='Risk', name='Risk',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)), ('title', models.CharField(max_length=255, verbose_name='Title')),
('asset', models.CharField(blank=True, max_length=255, null=True)), ('description', models.TextField(blank=True, max_length=225, null=True, verbose_name='Description')),
('process', models.CharField(blank=True, max_length=255, null=True)), ('asset', models.CharField(blank=True, max_length=255, null=True, verbose_name='Asset')),
('category', models.CharField(blank=True, max_length=255, null=True)), ('process', models.CharField(blank=True, max_length=255, null=True, verbose_name='Process')),
('category', models.CharField(blank=True, max_length=255, null=True, verbose_name='Category')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('effects', models.TextField(blank=True, null=True, verbose_name='Effects')),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('closed', 'Closed'), ('review_required', 'Review required')], db_index=True, default='open', max_length=20, verbose_name='Status')),
('cia', multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('1', 'Confidentiality'), ('2', 'Integrity'), ('3', 'Availability')], max_length=100, null=True)),
('likelihood', models.IntegerField(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')], default=1)), ('likelihood', models.IntegerField(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')], default=1)),
('impact', models.IntegerField(choices=[(1, 'Low (< 1,000 € minor operational impact)'), (2, 'Medium (1,0005,000 € local impact)'), (3, 'High (5,00015,000 € team-level impact)'), (4, 'Severe (50,000100,000 € regional impact)'), (5, 'Critical (> 100,000 € existential threat)')], default=1)), ('impact', models.IntegerField(choices=[(1, 'Very Low (< 1,000 € minor operational impact)'), (2, 'Low (1,0005,000 € local impact)'), (3, 'High (5,00015,000 € team-level impact)'), (4, 'Severe (50,000100,000 € regional impact)'), (5, 'Critical (> 100,000 € existential threat)')], default=1)),
('score', models.IntegerField(editable=False)), ('score', models.IntegerField(editable=False)),
('level', models.CharField(editable=False, max_length=50)), ('level', models.CharField(editable=False, max_length=50)),
('follow_up', models.DateField(blank=True, null=True)), ('follow_up', models.DateField(blank=True, null=True)),
('confidentiality', models.BooleanField(default=False)),
('integrity', models.BooleanField(default=False)),
('availability', models.BooleanField(default=False)),
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_risks', to=settings.AUTH_USER_MODEL)), ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_risks', to=settings.AUTH_USER_MODEL)),
], ],
options={
'verbose_name': 'Risk',
'verbose_name_plural': 'Risks',
},
),
migrations.CreateModel(
name='ResidualRisk',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('likelihood', models.IntegerField(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')], default=1)),
('impact', models.IntegerField(choices=[(1, 'Very Low (< 1,000 € minor operational impact)'), (2, 'Low (1,0005,000 € local impact)'), (3, 'High (5,00015,000 € team-level impact)'), (4, 'Severe (50,000100,000 € regional impact)'), (5, 'Critical (> 100,000 € existential threat)')], default=1)),
('score', models.IntegerField(editable=False)),
('level', models.CharField(editable=False, max_length=50)),
('review_required', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('risk', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='residual_risk', to='risks.risk')),
],
options={
'verbose_name': 'Residual Risk',
'verbose_name_plural': 'Residual Risks',
},
),
migrations.CreateModel(
name='Incident',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Title')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('date_reported', models.DateField(blank=True, null=True, verbose_name='Date reported')),
('status', models.CharField(choices=[('open', 'Opened'), ('in_progress', 'In Progress'), ('closed', 'Closed')], max_length=12)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('reported_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incidents', to=settings.AUTH_USER_MODEL, verbose_name='Reported by')),
('related_risks', models.ManyToManyField(blank=True, related_name='incidents', to='risks.risk')),
],
options={
'verbose_name': 'Incident',
'verbose_name_plural': 'Incidents',
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Control', name='Control',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)), ('title', models.CharField(max_length=255, verbose_name='Title')),
('status', models.CharField(choices=[('planned', 'Planned'), ('in_progress', 'In progress'), ('completed', 'Completed'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='planned', max_length=20)), ('status', models.CharField(choices=[('planned', 'Planned'), ('in_progress', 'In progress'), ('completed', 'Completed'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='planned', max_length=20)),
('due_date', models.DateField(blank=True, null=True)), ('due_date', models.DateField(blank=True, null=True)),
('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)),
('updated_at', models.DateTimeField(auto_now=True)),
('responsible', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responsible_controls', to=settings.AUTH_USER_MODEL)), ('responsible', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responsible_controls', to=settings.AUTH_USER_MODEL)),
('risk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='controls', to='risks.risk')), ('risks', models.ManyToManyField(blank=True, related_name='controls', to='risks.risk')),
], ],
options={
'verbose_name': 'Control',
'verbose_name_plural': 'Controls',
},
), ),
] ]

View file

@ -1,25 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-06 10:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ResidualRisk',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('likelihood', models.IntegerField(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')], default=1)),
('impact', models.IntegerField(choices=[(1, 'Low (< 1,000 € minor operational impact)'), (2, 'Medium (1,0005,000 € local impact)'), (3, 'High (5,00015,000 € team-level impact)'), (4, 'Severe (50,000100,000 € regional impact)'), (5, 'Critical (> 100,000 € existential threat)')], default=1)),
('score', models.IntegerField(editable=False)),
('level', models.CharField(editable=False, max_length=50)),
('risk', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='residual_risk', to='risks.risk')),
],
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-06 11:39
import multiselectfield.db.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('risks', '0002_residualrisk'),
]
operations = [
migrations.AddField(
model_name='risk',
name='cia',
field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[(1, 'Confidentiality'), (2, 'Integrity'), (3, 'Availability')], max_length=100, null=True),
),
]

View file

@ -1,44 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-07 09:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0003_risk_cia'),
]
operations = [
migrations.RemoveField(
model_name='risk',
name='availability',
),
migrations.RemoveField(
model_name='risk',
name='confidentiality',
),
migrations.RemoveField(
model_name='risk',
name='integrity',
),
migrations.AddField(
model_name='residualrisk',
name='review_required',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('create', 'Created'), ('update', 'Updated'), ('delete', 'Deleted')], max_length=10)),
('model', models.CharField(max_length=100)),
('object_id', models.CharField(max_length=50)),
('changes', models.JSONField(blank=True, null=True)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -1,27 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-07 10:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0004_remove_risk_availability_remove_risk_confidentiality_and_more'),
]
operations = [
migrations.CreateModel(
name='Incidents',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True, null=True)),
('date_reported', models.DateField(blank=True, null=True)),
('status', models.CharField(choices=[('open', 'Opened'), ('in_progress', 'In Progress'), ('close', 'Closed')], max_length=12)),
('related_risks', models.ManyToManyField(blank=True, related_name='incidents', to='risks.risk')),
('reported_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incidents', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-07 16:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('risks', '0005_incidents'),
]
operations = [
migrations.RenameModel(
old_name='Incidents',
new_name='Incident',
),
]

View file

@ -1,26 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-07 18:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0006_rename_incidents_incident'),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('read', models.BooleanField(default=False)),
('send', models.BooleanField(default=False)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-07 19:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('risks', '0007_notification'),
]
operations = [
migrations.RenameField(
model_name='notification',
old_name='send',
new_name='sent',
),
]

View file

@ -1,25 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-08 09:15
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0008_rename_send_notification_sent'),
]
operations = [
migrations.AddField(
model_name='risk',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='risk',
name='updatet_at',
field=models.DateTimeField(auto_now=True),
),
]

View file

@ -1,39 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-08 09:44
import multiselectfield.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0009_risk_created_at_risk_updatet_at'),
]
operations = [
migrations.AlterField(
model_name='residualrisk',
name='impact',
field=models.IntegerField(choices=[('1', 'Low (< 1,000 € minor operational impact)'), ('2', 'Medium (1,0005,000 € local impact)'), ('3', 'High (5,00015,000 € team-level impact)'), ('4', 'Severe (50,000100,000 € regional impact)'), ('5', 'Critical (> 100,000 € existential threat)')], default=1),
),
migrations.AlterField(
model_name='residualrisk',
name='likelihood',
field=models.IntegerField(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')], default=1),
),
migrations.AlterField(
model_name='risk',
name='cia',
field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('1', 'Confidentiality'), ('2', 'Integrity'), ('3', 'Availability')], max_length=100, null=True),
),
migrations.AlterField(
model_name='risk',
name='impact',
field=models.IntegerField(choices=[('1', 'Low (< 1,000 € minor operational impact)'), ('2', 'Medium (1,0005,000 € local impact)'), ('3', 'High (5,00015,000 € team-level impact)'), ('4', 'Severe (50,000100,000 € regional impact)'), ('5', 'Critical (> 100,000 € existential threat)')], default=1),
),
migrations.AlterField(
model_name='risk',
name='likelihood',
field=models.IntegerField(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')], default=1),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-08 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0010_alter_residualrisk_impact_and_more'),
]
operations = [
migrations.AddField(
model_name='risk',
name='description',
field=models.TextField(blank=True, max_length=225, null=True),
),
]

View file

@ -1,33 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-08 09:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('risks', '0011_risk_description'),
]
operations = [
migrations.AlterField(
model_name='residualrisk',
name='impact',
field=models.IntegerField(choices=[(1, 'Low (< 1,000 € minor operational impact)'), (2, 'Medium (1,0005,000 € local impact)'), (3, 'High (5,00015,000 € team-level impact)'), (4, 'Severe (50,000100,000 € regional impact)'), (5, 'Critical (> 100,000 € existential threat)')], default=1),
),
migrations.AlterField(
model_name='residualrisk',
name='likelihood',
field=models.IntegerField(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')], default=1),
),
migrations.AlterField(
model_name='risk',
name='impact',
field=models.IntegerField(choices=[(1, 'Low (< 1,000 € minor operational impact)'), (2, 'Medium (1,0005,000 € local impact)'), (3, 'High (5,00015,000 € team-level impact)'), (4, 'Severe (50,000100,000 € regional impact)'), (5, 'Critical (> 100,000 € existential threat)')], default=1),
),
migrations.AlterField(
model_name='risk',
name='likelihood',
field=models.IntegerField(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')], default=1),
),
]

View file

@ -1,52 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 07:00
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0012_alter_residualrisk_impact_and_more"),
]
operations = [
migrations.AddField(
model_name="control",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="control",
name="updatet_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="incident",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="incident",
name="updatet_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="residualrisk",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="residualrisk",
name="updatet_at",
field=models.DateTimeField(auto_now=True),
),
]

View file

@ -1,21 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 07:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0013_control_created_at_control_updatet_at_and_more"),
]
operations = [
migrations.RemoveField(
model_name="control",
name="risk",
),
migrations.AddField(
model_name="control",
name="risks",
field=models.ManyToManyField(related_name="controls", to="risks.risk"),
),
]

View file

@ -1,20 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 08:37
import risks.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0014_remove_control_risk_control_risks"),
]
operations = [
migrations.AlterField(
model_name="auditlog",
name="changes",
field=models.JSONField(
blank=True, encoder=risks.models.SafeJSONEncoder, null=True
),
),
]

View file

@ -1,32 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 09:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("risks", "0015_alter_auditlog_changes"),
]
operations = [
migrations.RenameField(
model_name="control",
old_name="updatet_at",
new_name="updated_at",
),
migrations.RenameField(
model_name="incident",
old_name="updatet_at",
new_name="updated_at",
),
migrations.RenameField(
model_name="residualrisk",
old_name="updatet_at",
new_name="updated_at",
),
migrations.RenameField(
model_name="risk",
old_name="updatet_at",
new_name="updated_at",
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 09:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0016_rename_updatet_at_control_updated_at_and_more"),
]
operations = [
migrations.AlterField(
model_name="incident",
name="status",
field=models.CharField(
choices=[
("open", "Opened"),
("in_progress", "In Progress"),
("closed", "Closed"),
],
max_length=12,
),
),
]

View file

@ -1,108 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 11:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0017_alter_incident_status"),
]
operations = [
migrations.AlterModelOptions(
name="auditlog",
options={"verbose_name": "Auditlog", "verbose_name_plural": "Auditlogs"},
),
migrations.AlterModelOptions(
name="control",
options={"verbose_name": "Control", "verbose_name_plural": "Controls"},
),
migrations.AlterModelOptions(
name="residualrisk",
options={
"verbose_name": "Residual Risk",
"verbose_name_plural": "Residual Risks",
},
),
migrations.AlterModelOptions(
name="risk",
options={"verbose_name": "Risk", "verbose_name_plural": "Risks"},
),
migrations.AlterField(
model_name="control",
name="title",
field=models.CharField(max_length=255, verbose_name="Title"),
),
migrations.AlterField(
model_name="incident",
name="date_reported",
field=models.DateField(blank=True, null=True, verbose_name="Date reported"),
),
migrations.AlterField(
model_name="incident",
name="description",
field=models.TextField(blank=True, null=True, verbose_name="Description"),
),
migrations.AlterField(
model_name="incident",
name="reported_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="incidents",
to=settings.AUTH_USER_MODEL,
verbose_name="Reported by",
),
),
migrations.AlterField(
model_name="incident",
name="title",
field=models.CharField(max_length=255, verbose_name="Title"),
),
migrations.AlterField(
model_name="risk",
name="asset",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="Asset"
),
),
migrations.AlterField(
model_name="risk",
name="category",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="Category"
),
),
migrations.AlterField(
model_name="risk",
name="created_at",
field=models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
),
migrations.AlterField(
model_name="risk",
name="description",
field=models.TextField(
blank=True, max_length=225, null=True, verbose_name="Description"
),
),
migrations.AlterField(
model_name="risk",
name="process",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="Process"
),
),
migrations.AlterField(
model_name="risk",
name="title",
field=models.CharField(max_length=255, verbose_name="Title"),
),
migrations.AlterField(
model_name="risk",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Updated at"),
),
]

View file

@ -1,16 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 11:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("risks", "0018_alter_auditlog_options_alter_control_options_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="incident",
options={"verbose_name": "Incident", "verbose_name_plural": "Incidents"},
),
]

View file

@ -1,40 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-09 12:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0019_alter_incident_options"),
]
operations = [
migrations.AlterField(
model_name="residualrisk",
name="impact",
field=models.IntegerField(
choices=[
(1, "Very Low (< 1,000 € minor operational impact)"),
(2, "Low (1,0005,000 € local impact)"),
(3, "High (5,00015,000 € team-level impact)"),
(4, "Severe (50,000100,000 € regional impact)"),
(5, "Critical (> 100,000 € existential threat)"),
],
default=1,
),
),
migrations.AlterField(
model_name="risk",
name="impact",
field=models.IntegerField(
choices=[
(1, "Very Low (< 1,000 € minor operational impact)"),
(2, "Low (1,0005,000 € local impact)"),
(3, "High (5,00015,000 € team-level impact)"),
(4, "Severe (50,000100,000 € regional impact)"),
(5, "Critical (> 100,000 € existential threat)"),
],
default=1,
),
),
]

View file

@ -1,71 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-10 09:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0020_alter_residualrisk_impact_alter_risk_impact"),
]
operations = [
migrations.AddField(
model_name="risk",
name="status",
field=models.CharField(
choices=[
("open", "Open"),
("in_progress", "In Progress"),
("closed", "Closed"),
("review_required", "Review required"),
],
db_index=True,
default="open",
max_length=20,
verbose_name="Status",
),
),
migrations.CreateModel(
name="NotificationPreference",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("risk_created", models.BooleanField(default=True)),
("risk_updated", models.BooleanField(default=True)),
("risk_deleted", models.BooleanField(default=True)),
("control_created", models.BooleanField(default=True)),
("control_updated", models.BooleanField(default=True)),
("control_deleted", models.BooleanField(default=True)),
("residual_created", models.BooleanField(default=True)),
("residual_updated", models.BooleanField(default=True)),
("residual_deleted", models.BooleanField(default=True)),
("review_required", models.BooleanField(default=True)),
("review_completed", models.BooleanField(default=True)),
("user_created", models.BooleanField(default=False)),
("user_deleted", models.BooleanField(default=False)),
("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)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preference",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-10 10:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("risks", "0021_risk_status_notificationpreference"),
]
operations = [
migrations.AlterModelOptions(
name="notification",
options={
"verbose_name": "Notification",
"verbose_name_plural": "Notifications",
},
),
]

View file

@ -1,86 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-10 12:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0022_alter_notification_options"),
]
operations = [
migrations.CreateModel(
name="NotificationRule",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kind",
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"),
],
max_length=40,
unique=True,
verbose_name="Event",
),
),
(
"enabled_in_app",
models.BooleanField(default=True, verbose_name="Show in app"),
),
(
"enabled_email",
models.BooleanField(default=False, verbose_name="Send via email"),
),
(
"to_owner",
models.BooleanField(
default=True,
verbose_name="Send to owner/responsible/reporter (if available)",
),
),
(
"to_staff",
models.BooleanField(
default=False, verbose_name="Send to all staff"
),
),
(
"extra_recipients",
models.TextField(
blank=True,
verbose_name="Extra recipients (emails, comma or newline separated)",
),
),
],
options={
"verbose_name": "Notification rule",
"verbose_name_plural": "Notification rules",
},
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-10 12:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0023_notificationrule"),
]
operations = [
migrations.AddField(
model_name="risk",
name="effects",
field=models.TextField(blank=True, null=True, verbose_name="Effects"),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-10 12:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0024_risk_effects"),
]
operations = [
migrations.AlterField(
model_name="control",
name="risks",
field=models.ManyToManyField(
blank=True, null=True, related_name="controls", to="risks.risk"
),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-10 12:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0025_alter_control_risks"),
]
operations = [
migrations.AlterField(
model_name="control",
name="risks",
field=models.ManyToManyField(
blank=True, related_name="controls", to="risks.risk"
),
),
]

View file

@ -1,57 +0,0 @@
# 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),
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-16 12:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("risks", "0027_notification_content_type_notification_kind_and_more"),
]
operations = [
migrations.AddField(
model_name="notification",
name="target_url",
field=models.CharField(blank=True, max_length=500, null=True),
),
]

View file

@ -1,443 +0,0 @@
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 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)")),
(3, _("High (5,00015,000 € team-level impact)")),
(4, _("Severe (50,000100,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

13
risks/models/__init__.py Normal file
View file

@ -0,0 +1,13 @@
from .auditlog import AuditLog
from .control import Control
from .incident import Incident
from .notification import Notification
from .notification_kind import NotificationKind
from .notification_preference import NotificationPreference
from .notification_rule import NotificationRule
from .residual_risk import ResidualRisk
from .risk import Risk
from .user import User
__all__ = ["AuditLog", "Control", "Incident", "Notification", "NotificationKind", "NotificationPreference", "NotificationRule", "ResidualRisk", "Risk", "User"]

48
risks/models/auditlog.py Normal file
View file

@ -0,0 +1,48 @@
from django.db import models
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.translation import gettext_lazy as _
from .safe_json_encoder import SafeJSONEncoder
import datetime
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# 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})"

41
risks/models/control.py Normal file
View file

@ -0,0 +1,41 @@
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
# ---------------------------------------------------------------------------
# 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()})"

35
risks/models/incident.py Normal file
View file

@ -0,0 +1,35 @@
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
# ---------------------------------------------------------------------------
# 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)

View file

@ -0,0 +1,51 @@
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from .notification_kind import NotificationKind
# ---------------------------------------------------------------------------
# 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

View file

@ -0,0 +1,32 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
# ---------------------------------------------------------------------------
# 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")

View file

@ -0,0 +1,54 @@
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
# ---------------------------------------------------------------------------
# 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))

View file

@ -0,0 +1,36 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from .notification_kind import NotificationKind
# ---------------------------------------------------------------------------
# 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

View file

@ -0,0 +1,45 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from .risk import Risk
# ---------------------------------------------------------------------------
# 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})"

100
risks/models/risk.py Normal file
View file

@ -0,0 +1,100 @@
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField
# ---------------------------------------------------------------------------
# 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 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)")),
(3, _("High (5,00015,000 € team-level impact)")),
(4, _("Severe (50,000100,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})"

View file

@ -0,0 +1,12 @@
from django.core.serializers.json import DjangoJSONEncoder
import datetime
# ---------------------------------------------------------------------------
# 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)

19
risks/models/user.py Normal file
View file

@ -0,0 +1,19 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
# ---------------------------------------------------------------------------
# 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()