From 65dc2231bbe66491e9b48532dce1b131cf7c895c Mon Sep 17 00:00:00 2001 From: Kevin Heyer Date: Mon, 22 Sep 2025 08:35:11 +0200 Subject: [PATCH] refactor risks models --- db.sqlite3 | Bin 278528 -> 278528 bytes risks/migrations/0001_initial.py | 150 +++++- risks/migrations/0002_residualrisk.py | 25 - risks/migrations/0003_risk_cia.py | 19 - ...ty_remove_risk_confidentiality_and_more.py | 44 -- risks/migrations/0005_incidents.py | 27 -- .../0006_rename_incidents_incident.py | 17 - risks/migrations/0007_notification.py | 26 - .../0008_rename_send_notification_sent.py | 18 - .../0009_risk_created_at_risk_updatet_at.py | 25 - ...0010_alter_residualrisk_impact_and_more.py | 39 -- risks/migrations/0011_risk_description.py | 18 - ...0012_alter_residualrisk_impact_and_more.py | 33 -- ..._created_at_control_updatet_at_and_more.py | 52 -- .../0014_remove_control_risk_control_risks.py | 21 - .../migrations/0015_alter_auditlog_changes.py | 20 - ..._updatet_at_control_updated_at_and_more.py | 32 -- .../migrations/0017_alter_incident_status.py | 24 - ..._options_alter_control_options_and_more.py | 108 ----- .../migrations/0019_alter_incident_options.py | 16 - ...r_residualrisk_impact_alter_risk_impact.py | 40 -- ...0021_risk_status_notificationpreference.py | 71 --- .../0022_alter_notification_options.py | 19 - risks/migrations/0023_notificationrule.py | 86 ---- risks/migrations/0024_risk_effects.py | 17 - risks/migrations/0025_alter_control_risks.py | 19 - risks/migrations/0026_alter_control_risks.py | 19 - ...content_type_notification_kind_and_more.py | 57 --- .../0028_notification_target_url.py | 17 - risks/models.py | 443 ------------------ risks/models/__init__.py | 13 + risks/models/auditlog.py | 48 ++ risks/models/control.py | 41 ++ risks/models/incident.py | 35 ++ risks/models/notification.py | 51 ++ risks/models/notification_kind.py | 32 ++ risks/models/notification_preference.py | 54 +++ risks/models/notification_rule.py | 36 ++ risks/models/residual_risk.py | 45 ++ risks/models/risk.py | 100 ++++ risks/models/safe_json_encoder.py | 12 + risks/models/user.py | 19 + 42 files changed, 625 insertions(+), 1363 deletions(-) delete mode 100644 risks/migrations/0002_residualrisk.py delete mode 100644 risks/migrations/0003_risk_cia.py delete mode 100644 risks/migrations/0004_remove_risk_availability_remove_risk_confidentiality_and_more.py delete mode 100644 risks/migrations/0005_incidents.py delete mode 100644 risks/migrations/0006_rename_incidents_incident.py delete mode 100644 risks/migrations/0007_notification.py delete mode 100644 risks/migrations/0008_rename_send_notification_sent.py delete mode 100644 risks/migrations/0009_risk_created_at_risk_updatet_at.py delete mode 100644 risks/migrations/0010_alter_residualrisk_impact_and_more.py delete mode 100644 risks/migrations/0011_risk_description.py delete mode 100644 risks/migrations/0012_alter_residualrisk_impact_and_more.py delete mode 100644 risks/migrations/0013_control_created_at_control_updatet_at_and_more.py delete mode 100644 risks/migrations/0014_remove_control_risk_control_risks.py delete mode 100644 risks/migrations/0015_alter_auditlog_changes.py delete mode 100644 risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py delete mode 100644 risks/migrations/0017_alter_incident_status.py delete mode 100644 risks/migrations/0018_alter_auditlog_options_alter_control_options_and_more.py delete mode 100644 risks/migrations/0019_alter_incident_options.py delete mode 100644 risks/migrations/0020_alter_residualrisk_impact_alter_risk_impact.py delete mode 100644 risks/migrations/0021_risk_status_notificationpreference.py delete mode 100644 risks/migrations/0022_alter_notification_options.py delete mode 100644 risks/migrations/0023_notificationrule.py delete mode 100644 risks/migrations/0024_risk_effects.py delete mode 100644 risks/migrations/0025_alter_control_risks.py delete mode 100644 risks/migrations/0026_alter_control_risks.py delete mode 100644 risks/migrations/0027_notification_content_type_notification_kind_and_more.py delete mode 100644 risks/migrations/0028_notification_target_url.py delete mode 100644 risks/models.py create mode 100644 risks/models/__init__.py create mode 100644 risks/models/auditlog.py create mode 100644 risks/models/control.py create mode 100644 risks/models/incident.py create mode 100644 risks/models/notification.py create mode 100644 risks/models/notification_kind.py create mode 100644 risks/models/notification_preference.py create mode 100644 risks/models/notification_rule.py create mode 100644 risks/models/residual_risk.py create mode 100644 risks/models/risk.py create mode 100644 risks/models/safe_json_encoder.py create mode 100644 risks/models/user.py diff --git a/db.sqlite3 b/db.sqlite3 index 7d06d7d8bb12cb52a4181e1102b8e08ac802e136..16fc25284c30781f6b420dc8fc50c8823af5acd1 100644 GIT binary patch delta 323 zcmZo@5Nv1=oFL7pG*QNxQE6ks!(1UFBLxFfD-&ZY14}(aOEV*5i{{|G?ZJ7B>lO-f zxiPSEDlqch=H13KkyC*?d$XYeALn)jNhUQ$76t}J)y;(hii{dfO6;t@&B2aE{%@^Q4$Id6yYPc?VcU6d8J#CHokqWR(Sn zXQt>Do93J6X8Pp?R(PdVq?@GYxMYUpR;8r-R+tzW7@6uC8t59BAzW%?U}|h)vVDRK zQ>`%bBz}+W0tQSr{ECEJz&<_Ko@o(tD}UN%!GaKeWjSU?M%>1;gQTbHNHIyZFZO5J KzSy5R_5%PyKwF6b delta 321 zcmZo@5Nv1=oFL68H&Mo!QEp?x!(1UlGX(=vD-#1N6H`4)Gc!vAljh*O?ZJ7B>lO-f zXEU&JDlqch=H13Kk;{!!VY8tEALn)jNhUQ$jV2{lR^R4e$DGpC%mP!R62rpGimK$q z!h!+=la#WYqC}IVB!h~?oay&unKt`Xn!1)*g*yd?hE?VQ1vC7NON{a&!;8Hgopsaw z%LC1lBEo|LeRFhkTrCQ`((;WAj7)V6EOm`66$~t`3=ORejrEKy%nVG785kHaqI!&D>8?7=^2yMO_c4Zk8Gm#|LHwP#wyoW|d}S+F34Us;aXkrB5&tRU&>I#Nth P?Th`HwlDT)j{N`t!x>wi diff --git a/risks/migrations/0001_initial.py b/risks/migrations/0001_initial.py index c97f381..c90fb0a 100644 --- a/risks/migrations/0001_initial.py +++ b/risks/migrations/0001_initial.py @@ -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.validators import django.db.models.deletion import django.utils.timezone +import multiselectfield.db.fields +import risks.models.auditlog from django.conf import settings from django.db import migrations, models @@ -14,9 +16,26 @@ class Migration(migrations.Migration): dependencies = [ ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), ] 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( name='User', fields=[ @@ -44,36 +63,145 @@ class Migration(migrations.Migration): ('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( name='Risk', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255)), - ('asset', models.CharField(blank=True, max_length=255, null=True)), - ('process', models.CharField(blank=True, max_length=255, null=True)), - ('category', models.CharField(blank=True, max_length=255, null=True)), + ('title', models.CharField(max_length=255, verbose_name='Title')), + ('description', models.TextField(blank=True, max_length=225, null=True, verbose_name='Description')), + ('asset', models.CharField(blank=True, max_length=255, null=True, verbose_name='Asset')), + ('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 1–5 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,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)')], default=1)), + ('impact', models.IntegerField(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)')], default=1)), ('score', models.IntegerField(editable=False)), ('level', models.CharField(editable=False, max_length=50)), ('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)), ], + 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 1–5 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,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)')], 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( name='Control', fields=[ ('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)), ('due_date', models.DateField(blank=True, null=True)), ('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)), ('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', + }, ), ] diff --git a/risks/migrations/0002_residualrisk.py b/risks/migrations/0002_residualrisk.py deleted file mode 100644 index 6ca198c..0000000 --- a/risks/migrations/0002_residualrisk.py +++ /dev/null @@ -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 1–5 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,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)')], 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')), - ], - ), - ] diff --git a/risks/migrations/0003_risk_cia.py b/risks/migrations/0003_risk_cia.py deleted file mode 100644 index e587b61..0000000 --- a/risks/migrations/0003_risk_cia.py +++ /dev/null @@ -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), - ), - ] diff --git a/risks/migrations/0004_remove_risk_availability_remove_risk_confidentiality_and_more.py b/risks/migrations/0004_remove_risk_availability_remove_risk_confidentiality_and_more.py deleted file mode 100644 index f9527cc..0000000 --- a/risks/migrations/0004_remove_risk_availability_remove_risk_confidentiality_and_more.py +++ /dev/null @@ -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)), - ], - ), - ] diff --git a/risks/migrations/0005_incidents.py b/risks/migrations/0005_incidents.py deleted file mode 100644 index b83b306..0000000 --- a/risks/migrations/0005_incidents.py +++ /dev/null @@ -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)), - ], - ), - ] diff --git a/risks/migrations/0006_rename_incidents_incident.py b/risks/migrations/0006_rename_incidents_incident.py deleted file mode 100644 index d230401..0000000 --- a/risks/migrations/0006_rename_incidents_incident.py +++ /dev/null @@ -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', - ), - ] diff --git a/risks/migrations/0007_notification.py b/risks/migrations/0007_notification.py deleted file mode 100644 index 908f2d3..0000000 --- a/risks/migrations/0007_notification.py +++ /dev/null @@ -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)), - ], - ), - ] diff --git a/risks/migrations/0008_rename_send_notification_sent.py b/risks/migrations/0008_rename_send_notification_sent.py deleted file mode 100644 index 3d94b9e..0000000 --- a/risks/migrations/0008_rename_send_notification_sent.py +++ /dev/null @@ -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', - ), - ] diff --git a/risks/migrations/0009_risk_created_at_risk_updatet_at.py b/risks/migrations/0009_risk_created_at_risk_updatet_at.py deleted file mode 100644 index c132c26..0000000 --- a/risks/migrations/0009_risk_created_at_risk_updatet_at.py +++ /dev/null @@ -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), - ), - ] diff --git a/risks/migrations/0010_alter_residualrisk_impact_and_more.py b/risks/migrations/0010_alter_residualrisk_impact_and_more.py deleted file mode 100644 index 90ffa7a..0000000 --- a/risks/migrations/0010_alter_residualrisk_impact_and_more.py +++ /dev/null @@ -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,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)')], 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 1–5 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,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)')], 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 1–5 years'), ('3', 'Likely – once per year or more'), ('4', 'Very likely – multiple times per year/monthly')], default=1), - ), - ] diff --git a/risks/migrations/0011_risk_description.py b/risks/migrations/0011_risk_description.py deleted file mode 100644 index ef8025d..0000000 --- a/risks/migrations/0011_risk_description.py +++ /dev/null @@ -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), - ), - ] diff --git a/risks/migrations/0012_alter_residualrisk_impact_and_more.py b/risks/migrations/0012_alter_residualrisk_impact_and_more.py deleted file mode 100644 index 76b918c..0000000 --- a/risks/migrations/0012_alter_residualrisk_impact_and_more.py +++ /dev/null @@ -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,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)')], 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 1–5 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,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)')], 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 1–5 years'), (3, 'Likely – once per year or more'), (4, 'Very likely – multiple times per year/monthly')], default=1), - ), - ] diff --git a/risks/migrations/0013_control_created_at_control_updatet_at_and_more.py b/risks/migrations/0013_control_created_at_control_updatet_at_and_more.py deleted file mode 100644 index d1043ea..0000000 --- a/risks/migrations/0013_control_created_at_control_updatet_at_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/risks/migrations/0014_remove_control_risk_control_risks.py b/risks/migrations/0014_remove_control_risk_control_risks.py deleted file mode 100644 index bf2a0b9..0000000 --- a/risks/migrations/0014_remove_control_risk_control_risks.py +++ /dev/null @@ -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"), - ), - ] diff --git a/risks/migrations/0015_alter_auditlog_changes.py b/risks/migrations/0015_alter_auditlog_changes.py deleted file mode 100644 index 96b4bc9..0000000 --- a/risks/migrations/0015_alter_auditlog_changes.py +++ /dev/null @@ -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 - ), - ), - ] diff --git a/risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py b/risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py deleted file mode 100644 index 9c7a9eb..0000000 --- a/risks/migrations/0016_rename_updatet_at_control_updated_at_and_more.py +++ /dev/null @@ -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", - ), - ] diff --git a/risks/migrations/0017_alter_incident_status.py b/risks/migrations/0017_alter_incident_status.py deleted file mode 100644 index a1a3c8c..0000000 --- a/risks/migrations/0017_alter_incident_status.py +++ /dev/null @@ -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, - ), - ), - ] diff --git a/risks/migrations/0018_alter_auditlog_options_alter_control_options_and_more.py b/risks/migrations/0018_alter_auditlog_options_alter_control_options_and_more.py deleted file mode 100644 index 1b34164..0000000 --- a/risks/migrations/0018_alter_auditlog_options_alter_control_options_and_more.py +++ /dev/null @@ -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"), - ), - ] diff --git a/risks/migrations/0019_alter_incident_options.py b/risks/migrations/0019_alter_incident_options.py deleted file mode 100644 index 818f699..0000000 --- a/risks/migrations/0019_alter_incident_options.py +++ /dev/null @@ -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"}, - ), - ] diff --git a/risks/migrations/0020_alter_residualrisk_impact_alter_risk_impact.py b/risks/migrations/0020_alter_residualrisk_impact_alter_risk_impact.py deleted file mode 100644 index 6fab0b5..0000000 --- a/risks/migrations/0020_alter_residualrisk_impact_alter_risk_impact.py +++ /dev/null @@ -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,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)"), - ], - default=1, - ), - ), - migrations.AlterField( - model_name="risk", - name="impact", - field=models.IntegerField( - 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)"), - ], - default=1, - ), - ), - ] diff --git a/risks/migrations/0021_risk_status_notificationpreference.py b/risks/migrations/0021_risk_status_notificationpreference.py deleted file mode 100644 index c1059b8..0000000 --- a/risks/migrations/0021_risk_status_notificationpreference.py +++ /dev/null @@ -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", - ), - ), - ], - ), - ] diff --git a/risks/migrations/0022_alter_notification_options.py b/risks/migrations/0022_alter_notification_options.py deleted file mode 100644 index 1ca8cd6..0000000 --- a/risks/migrations/0022_alter_notification_options.py +++ /dev/null @@ -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", - }, - ), - ] diff --git a/risks/migrations/0023_notificationrule.py b/risks/migrations/0023_notificationrule.py deleted file mode 100644 index 24ec4b7..0000000 --- a/risks/migrations/0023_notificationrule.py +++ /dev/null @@ -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", - }, - ), - ] diff --git a/risks/migrations/0024_risk_effects.py b/risks/migrations/0024_risk_effects.py deleted file mode 100644 index b6edbaf..0000000 --- a/risks/migrations/0024_risk_effects.py +++ /dev/null @@ -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"), - ), - ] diff --git a/risks/migrations/0025_alter_control_risks.py b/risks/migrations/0025_alter_control_risks.py deleted file mode 100644 index 0562cab..0000000 --- a/risks/migrations/0025_alter_control_risks.py +++ /dev/null @@ -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" - ), - ), - ] diff --git a/risks/migrations/0026_alter_control_risks.py b/risks/migrations/0026_alter_control_risks.py deleted file mode 100644 index 743ce29..0000000 --- a/risks/migrations/0026_alter_control_risks.py +++ /dev/null @@ -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" - ), - ), - ] diff --git a/risks/migrations/0027_notification_content_type_notification_kind_and_more.py b/risks/migrations/0027_notification_content_type_notification_kind_and_more.py deleted file mode 100644 index cab9a2a..0000000 --- a/risks/migrations/0027_notification_content_type_notification_kind_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/risks/migrations/0028_notification_target_url.py b/risks/migrations/0028_notification_target_url.py deleted file mode 100644 index 11c738c..0000000 --- a/risks/migrations/0028_notification_target_url.py +++ /dev/null @@ -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), - ), - ] diff --git a/risks/models.py b/risks/models.py deleted file mode 100644 index 92c7788..0000000 --- a/risks/models.py +++ /dev/null @@ -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 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 diff --git a/risks/models/__init__.py b/risks/models/__init__.py new file mode 100644 index 0000000..5c124ff --- /dev/null +++ b/risks/models/__init__.py @@ -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"] \ No newline at end of file diff --git a/risks/models/auditlog.py b/risks/models/auditlog.py new file mode 100644 index 0000000..eb07f50 --- /dev/null +++ b/risks/models/auditlog.py @@ -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})" diff --git a/risks/models/control.py b/risks/models/control.py new file mode 100644 index 0000000..8cc6343 --- /dev/null +++ b/risks/models/control.py @@ -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()})" \ No newline at end of file diff --git a/risks/models/incident.py b/risks/models/incident.py new file mode 100644 index 0000000..c905dac --- /dev/null +++ b/risks/models/incident.py @@ -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) + diff --git a/risks/models/notification.py b/risks/models/notification.py new file mode 100644 index 0000000..df43c77 --- /dev/null +++ b/risks/models/notification.py @@ -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 \ No newline at end of file diff --git a/risks/models/notification_kind.py b/risks/models/notification_kind.py new file mode 100644 index 0000000..edf40c2 --- /dev/null +++ b/risks/models/notification_kind.py @@ -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") + + diff --git a/risks/models/notification_preference.py b/risks/models/notification_preference.py new file mode 100644 index 0000000..bb10751 --- /dev/null +++ b/risks/models/notification_preference.py @@ -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)) diff --git a/risks/models/notification_rule.py b/risks/models/notification_rule.py new file mode 100644 index 0000000..9a5c05e --- /dev/null +++ b/risks/models/notification_rule.py @@ -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 \ No newline at end of file diff --git a/risks/models/residual_risk.py b/risks/models/residual_risk.py new file mode 100644 index 0000000..15c2140 --- /dev/null +++ b/risks/models/residual_risk.py @@ -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})" \ No newline at end of file diff --git a/risks/models/risk.py b/risks/models/risk.py new file mode 100644 index 0000000..dfc0ccd --- /dev/null +++ b/risks/models/risk.py @@ -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 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})" \ No newline at end of file diff --git a/risks/models/safe_json_encoder.py b/risks/models/safe_json_encoder.py new file mode 100644 index 0000000..09c5af6 --- /dev/null +++ b/risks/models/safe_json_encoder.py @@ -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) \ No newline at end of file diff --git a/risks/models/user.py b/risks/models/user.py new file mode 100644 index 0000000..3f42c65 --- /dev/null +++ b/risks/models/user.py @@ -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() \ No newline at end of file