diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 750868c..792cbff 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -13,7 +13,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 0f0b311..f675f17 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index e106314..9761704 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__ db.sqlite3 media +attachments/ # Backup files # *.bak diff --git a/README.md b/README.md index c1fed4c..80e746e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Oxomium is an opensource project build to help company to manage the cybersecurity compliance of organisations. -It provides help to CISO or other security people to follow conformity to a Policy. +It provides help to CISO or other security people to follow conformity to a Framework. More information on [Oxomium Website](https://www.oxomium.org). diff --git a/conformity/admin.py b/conformity/admin.py index 8196b68..329feee 100644 --- a/conformity/admin.py +++ b/conformity/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from import_export import resources from import_export.admin import ImportExportModelAdmin -from .models import Organization, Policy, Measure, Conformity, Audit, Finding, Action, Control, ControlPoint +from .models import Organization, Framework, Requirement, Conformity, Audit, Finding, Action, Control, ControlPoint, Attachment class OrganizationResources(resources.ModelResource): @@ -17,22 +17,22 @@ class OrganizationAdmin(ImportExportModelAdmin): ressource_class = Organization -class PolicyResources(resources.ModelResource): +class FrameworkResources(resources.ModelResource): class Meta: - model = Policy + model = Framework -class PolicyAdmin(ImportExportModelAdmin): - ressource_class = Policy +class FrameworkAdmin(ImportExportModelAdmin): + ressource_class = Framework -class MeasureResources(resources.ModelResource): +class RequirementResources(resources.ModelResource): class Meta: - model = Measure + model = Requirement -class MeasureAdmin(ImportExportModelAdmin): - ressource_class = Measure +class RequirementAdmin(ImportExportModelAdmin): + ressource_class = Requirement class ConformityResources(resources.ModelResource): @@ -55,8 +55,8 @@ class ActionAdmin(ImportExportModelAdmin): # Registration -admin.site.register(Policy, PolicyAdmin) -admin.site.register(Measure, MeasureAdmin) +admin.site.register(Framework, FrameworkAdmin) +admin.site.register(Requirement, RequirementAdmin) admin.site.register(Conformity, ConformityAdmin) admin.site.register(Action, ActionAdmin) admin.site.register(Audit) @@ -64,3 +64,4 @@ class ActionAdmin(ImportExportModelAdmin): admin.site.register(Organization, OrganizationAdmin) admin.site.register(Control) admin.site.register(ControlPoint) +admin.site.register(Attachment) diff --git a/conformity/filterset.py b/conformity/filterset.py index e7d6673..35834f7 100644 --- a/conformity/filterset.py +++ b/conformity/filterset.py @@ -1,5 +1,5 @@ from django_filters import FilterSet, CharFilter -from .models import Action, ControlPoint +from .models import Action, Control, ControlPoint, Attachment class ActionFilter(FilterSet): @@ -10,6 +10,13 @@ class Meta: class ControlFilter(FilterSet): + class Meta: + model = Control + fields = ['level', 'organization', 'conformity__id', 'frequency'] + + +class ControlPointFilter(FilterSet): class Meta: model = ControlPoint - fields = ['control__level', 'control__organization', 'control__conformity__id', 'control__control__id', 'control__frequency', 'control__level'] + fields = [ 'control__id', 'control__frequency', 'status' ] + diff --git a/conformity/forms.py b/conformity/forms.py index 837e5df..fff6681 100644 --- a/conformity/forms.py +++ b/conformity/forms.py @@ -2,7 +2,7 @@ Forms for front-end editing of Models instance """ -from django.forms import ModelForm +from django.forms import ModelForm, FileField, ClearableFileInput from django.contrib.auth.mixins import LoginRequiredMixin from django.http import request from django.utils import timezone @@ -22,15 +22,18 @@ def __init__(self, *args, **kwargs): class OrganizationForm(LoginRequiredMixin, ModelForm): + attachments = FileField(required=False, widget=ClearableFileInput()) class Meta: model = Organization - fields = '__all__' + fields = ['name', 'administrative_id', 'description', 'applicable_frameworks'] class AuditForm(LoginRequiredMixin, ModelForm): + attachments = FileField(required=False, widget=ClearableFileInput()) class Meta: model = Audit fields = '__all__' + exclude = ['attachment'] class FindingForm(LoginRequiredMixin, ModelForm): @@ -77,15 +80,29 @@ class Meta: class ControlPointForm(LoginRequiredMixin, ModelForm): + attachments = FileField(required=False, widget=ClearableFileInput()) class Meta: model = ControlPoint - fields = ['control_date', 'control_user', 'status', 'comment'] + fields = ['control_date', 'control_user', 'status', 'comment', 'attachments'] def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) super(ControlPointForm, self).__init__(*args, **kwargs) - self.initial['control_date'] = timezone.now() - self.fields['control_date'].disabled = True - if self.get_initial_for_field(self.fields['status'], 'status') != ControlPoint.Status.TOBEEVALUATED.value: + # Set some value for all situation + self.fields['control_date'].disabled = True + self.fields['control_user'].disabled = True + + # Set some value if the ControlPoint has to be evaluated + if self.get_initial_for_field(self.fields['status'], 'status') == ControlPoint.Status.TOBEEVALUATED.value: + self.initial['control_date'] = timezone.now() + self.initial['control_user'] = self.user + self.fields['status'].widget.choices = [ + (ControlPoint.Status.COMPLIANT, ControlPoint.Status.COMPLIANT.label), + (ControlPoint.Status.NONCOMPLIANT, ControlPoint.Status.NONCOMPLIANT.label), + ] + # Switch to display mode if ControlPoint is not to be evaluated + else: + del self.fields['attachments'] for field in self.fields: self.fields[field].disabled = True diff --git a/conformity/middleware.py b/conformity/middleware.py new file mode 100644 index 0000000..17fd755 --- /dev/null +++ b/conformity/middleware.py @@ -0,0 +1,51 @@ +from django.contrib.auth.signals import user_logged_in +from django.dispatch import receiver +from datetime import datetime +from dateutil.relativedelta import relativedelta +from .models import ControlPoint + + +class SanityCheckMiddleware: + last_checked = datetime.today() # Class variable to store the last check date + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + self.run_daily_checks() + return response + + def run_daily_checks(self): + """Performs the daily integrity checks.""" + today = datetime.today().date() + + if SanityCheckMiddleware.last_checked != today: + self.check_control_points(today) + SanityCheckMiddleware.last_checked = today + + @staticmethod + def check_control_points(today): + """Checks and updates the status of ControlPoint.""" + yesterday=today - relativedelta(days=1) + + """Update SCHD to TOBE when period start""" + s=today.replace(day=1) + e=today.replace(day=1) + relativedelta(months=1) + scheduled_controls = ControlPoint.objects.filter(period_start_date__lte=today, + period_end_date__gte=today, + status="SCHD") + scheduled_controls.update(status='TOBE') + + """Update expired TOBE to MISS """ + missed_controls = ControlPoint.objects.filter(period_start_date__lt=today, + period_end_date__lt=today, + status="TOBE") + missed_controls.update(status='MISS') + + +# Connect the user login signal +@receiver(user_logged_in) +def update_on_login(sender, user, request, **kwargs): + middleware = SanityCheckMiddleware(None) + middleware.run_daily_checks() \ No newline at end of file diff --git a/conformity/migrations/0001_squashed_0014_mesure_is_parent.py b/conformity/migrations/0001_squashed_0014_mesure_is_parent.py index f7c8759..ea1c41b 100644 --- a/conformity/migrations/0001_squashed_0014_mesure_is_parent.py +++ b/conformity/migrations/0001_squashed_0014_mesure_is_parent.py @@ -47,7 +47,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=256)), ('administrative_id', models.CharField(blank=True, max_length=256)), ('description', models.CharField(blank=True, max_length=256)), - ('applicable_policies', models.ManyToManyField(blank=True, to='conformity.policy')), + ('applicable_frameworks', models.ManyToManyField(blank=True, to='conformity.policy')), ], ), migrations.CreateModel( diff --git a/conformity/migrations/0001_squashed_0029_alter_action_control_comment_and_more.py b/conformity/migrations/0001_squashed_0029_alter_action_control_comment_and_more.py index 670c280..efe0eed 100644 --- a/conformity/migrations/0001_squashed_0029_alter_action_control_comment_and_more.py +++ b/conformity/migrations/0001_squashed_0029_alter_action_control_comment_and_more.py @@ -40,7 +40,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=256, unique=True)), ('administrative_id', models.CharField(blank=True, max_length=256)), ('description', models.TextField(blank=True, max_length=4096)), - ('applicable_policies', models.ManyToManyField(blank=True, to='conformity.policy')), + ('applicable_frameworks', models.ManyToManyField(blank=True, to='conformity.policy')), ], options={ 'ordering': ['name'], diff --git a/conformity/migrations/0041_rename_policy_framework_alter_framework_options_and_more.py b/conformity/migrations/0041_rename_policy_framework_alter_framework_options_and_more.py new file mode 100644 index 0000000..815c005 --- /dev/null +++ b/conformity/migrations/0041_rename_policy_framework_alter_framework_options_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.9 on 2024-08-25 02:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0040_alter_control_level'), + ] + + operations = [ + migrations.RenameModel( + old_name='Policy', + new_name='Framework', + ), + migrations.AlterModelOptions( + name='framework', + options={'ordering': ['name'], 'verbose_name': 'Framework', 'verbose_name_plural': 'Frameworks'}, + ), + migrations.RenameField( + model_name='measure', + old_name='policy', + new_name='framework', + ), + migrations.RenameField( + model_name='audit', + old_name='audited_policies', + new_name='audited_frameworks', + ), + ] diff --git a/conformity/migrations/0042_rename_measure_requirement_alter_conformity_options_and_more.py b/conformity/migrations/0042_rename_measure_requirement_alter_conformity_options_and_more.py new file mode 100644 index 0000000..072e587 --- /dev/null +++ b/conformity/migrations/0042_rename_measure_requirement_alter_conformity_options_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.9 on 2024-08-25 04:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0041_rename_policy_framework_alter_framework_options_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name='Measure', + new_name='Requirement', + ), + migrations.AlterModelOptions( + name='conformity', + options={'ordering': ['organization', 'requirement'], 'verbose_name': 'Conformity', 'verbose_name_plural': 'Conformities'}, + ), + migrations.RenameField( + model_name='conformity', + old_name='measure', + new_name='requirement', + ), + migrations.AlterUniqueTogether( + name='conformity', + unique_together={('organization', 'requirement')}, + ), + ] diff --git a/conformity/migrations/0043_attachment.py b/conformity/migrations/0043_attachment.py new file mode 100644 index 0000000..de2f86d --- /dev/null +++ b/conformity/migrations/0043_attachment.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.9 on 2024-10-12 02:57 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0042_rename_measure_requirement_alter_conformity_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='attachments/')), + ('comment', models.TextField(blank=True, max_length=4096)), + ('mime_type', models.CharField(blank=True, max_length=255)), + ('create_date', models.DateField(default=django.utils.timezone.now)), + ], + ), + ] diff --git a/conformity/migrations/0044_action_attachment_audit_attachment_and_more.py b/conformity/migrations/0044_action_attachment_audit_attachment_and_more.py new file mode 100644 index 0000000..7049b28 --- /dev/null +++ b/conformity/migrations/0044_action_attachment_audit_attachment_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.9 on 2024-10-12 03:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0043_attachment'), + ] + + operations = [ + migrations.AddField( + model_name='action', + name='attachment', + field=models.ManyToManyField(blank=True, related_name='actions', to='conformity.attachment'), + ), + migrations.AddField( + model_name='audit', + name='attachment', + field=models.ManyToManyField(blank=True, related_name='audits', to='conformity.attachment'), + ), + migrations.AddField( + model_name='controlpoint', + name='attachment', + field=models.ManyToManyField(blank=True, related_name='ControlPoint', to='conformity.attachment'), + ), + migrations.AddField( + model_name='finding', + name='attachment', + field=models.ManyToManyField(blank=True, related_name='findings', to='conformity.attachment'), + ), + migrations.AddField( + model_name='framework', + name='attachment', + field=models.ManyToManyField(blank=True, related_name='frameworks', to='conformity.attachment'), + ), + migrations.AddField( + model_name='organization', + name='attachment', + field=models.ManyToManyField(blank=True, related_name='organizations', to='conformity.attachment'), + ), + ] diff --git a/conformity/migrations/0045_alter_attachment_create_date_and_more.py b/conformity/migrations/0045_alter_attachment_create_date_and_more.py new file mode 100644 index 0000000..877532b --- /dev/null +++ b/conformity/migrations/0045_alter_attachment_create_date_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.9 on 2024-10-12 04:28 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0044_action_attachment_audit_attachment_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='create_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='attachment', + name='mime_type', + field=models.CharField(max_length=255), + ), + ] diff --git a/conformity/migrations/0046_alter_attachment_mime_type.py b/conformity/migrations/0046_alter_attachment_mime_type.py new file mode 100644 index 0000000..85d534c --- /dev/null +++ b/conformity/migrations/0046_alter_attachment_mime_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-10-12 04:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0045_alter_attachment_create_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='mime_type', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/conformity/migrations/0047_alter_attachment_create_date.py b/conformity/migrations/0047_alter_attachment_create_date.py new file mode 100644 index 0000000..bc34acf --- /dev/null +++ b/conformity/migrations/0047_alter_attachment_create_date.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-10-12 04:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0046_alter_attachment_mime_type'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='create_date', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/conformity/migrations/0048_remove_finding_attachment.py b/conformity/migrations/0048_remove_finding_attachment.py new file mode 100644 index 0000000..6b230f5 --- /dev/null +++ b/conformity/migrations/0048_remove_finding_attachment.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.9 on 2024-10-13 01:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0047_alter_attachment_create_date'), + ] + + operations = [ + migrations.RemoveField( + model_name='finding', + name='attachment', + ), + ] diff --git a/conformity/migrations/0049_remove_action_attachment.py b/conformity/migrations/0049_remove_action_attachment.py new file mode 100644 index 0000000..3f72411 --- /dev/null +++ b/conformity/migrations/0049_remove_action_attachment.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.9 on 2024-10-13 01:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('conformity', '0048_remove_finding_attachment'), + ] + + operations = [ + migrations.RemoveField( + model_name='action', + name='attachment', + ), + ] diff --git a/conformity/models.py b/conformity/models.py index 5db9825..8adca41 100644 --- a/conformity/models.py +++ b/conformity/models.py @@ -1,11 +1,12 @@ """ Conformity module manage all the manual declarative aspect of conformity management. -It's Organized around Organization, Policy, Measure and Conformity classes. +It's Organized around Organization, Framework, Requirement and Conformity classes. """ from calendar import monthrange from statistics import mean from datetime import date, timedelta from django.db import models +from django.db.models import Q from django.db.models.signals import m2m_changed, pre_save, post_save, post_init from django.core.validators import MaxValueValidator, MinValueValidator from django.dispatch import receiver @@ -15,23 +16,24 @@ from django.utils.translation import gettext_lazy as _ from django.urls import reverse from auditlog.context import set_actor +import magic User = get_user_model() -class PolicyManager(models.Manager): +class FrameworkManager(models.Manager): def get_by_natural_key(self, name): return self.get(name=name) -class Policy(models.Model): +class Framework(models.Model): """ - Policy class represent the conformity policy you will apply on Organization. - A Policy is simply a collections of Measure with publication parameter. + Framework class represent the conformity framework you will apply on Organization. + A Framework is simply a collections of Requirement with publication parameter. """ class Type(models.TextChoices): - """ List of the Type of policy """ + """ List of the Type of framework """ INTERNATIONAL = 'INT', _('International Standard') NATIONAL = 'NAT', _('National Standard') TECHNICAL = 'TECH', _('Technical Standard') @@ -39,7 +41,7 @@ class Type(models.TextChoices): POLICY = 'POL', _('Internal Policy') OTHER = 'OTHER', _('Other') - objects = PolicyManager() + objects = FrameworkManager() name = models.CharField(max_length=256, unique=True) version = models.IntegerField(default=0) publish_by = models.CharField(max_length=256) @@ -48,48 +50,50 @@ class Type(models.TextChoices): choices=Type.choices, default=Type.OTHER, ) + attachment = models.ManyToManyField('Attachment', blank=True, related_name='frameworks') class Meta: ordering = ['name'] - verbose_name = 'Policy' - verbose_name_plural = 'Policies' + verbose_name = 'Framework' + verbose_name_plural = 'Frameworks' def __str__(self): return str(self.name) def natural_key(self): - return self.name + return (self.name) def get_type(self): - """return the readable version of the Policy Type""" + """return the readable version of the Framework Type""" return self.Type(self.type).label - def get_measures(self): - """return all Measure related to the Policy""" - return Measure.objects.filter(policy=self.id) + def get_requirements(self): + """return all Requirement related to the Framework""" + return Requirement.objects.filter(framework=self.id) - def get_measures_number(self): - """return the number of leaf Measure related to the Policy""" - return Measure.objects.filter(policy=self.id).filter(measure__is_parent=False).count() + def get_requirements_number(self): + """return the number of leaf Requirement related to the Framework""" + return Requirement.objects.filter(framework=self.id).filter(requirement__is_parent=False).count() - def get_root_measure(self): - """return the root Measure of the Policy""" - return Measure.objects.filter(policy=self.id).filter(level=0).order_by('order') + def get_root_requirement(self): + """return the root Requirement of the Framework""" + return Requirement.objects.filter(framework=self.id).filter(level=0).order_by('order') - def get_first_measures(self): - """return the Measure of the first hierarchical level of the Policy""" - return Measure.objects.filter(policy=self.id).filter(level=1).order_by('order') + def get_first_requirements(self): + """return the Requirement of the first hierarchical level of the Framework""" + return Requirement.objects.filter(framework=self.id).filter(level=1).order_by('order') class Organization(models.Model): """ - Organization class is a representation of a company, a division of company, a administration... - The Organization may answer to one or several Policy. + Organization class is a representation of a company, a division of company, an administration... + The Organization may answer to one or several Framework. """ name = models.CharField(max_length=256, unique=True) administrative_id = models.CharField(max_length=256, blank=True) description = models.TextField(max_length=4096, blank=True) - applicable_policies = models.ManyToManyField(Policy, blank=True) + applicable_frameworks = models.ManyToManyField(Framework, blank=True) + attachment = models.ManyToManyField('Attachment', blank=True, related_name='organizations') class Meta: ordering = ['name'] @@ -98,50 +102,50 @@ def __str__(self): return str(self.name) def natural_key(self): - return self.name + return (self.name) @staticmethod def get_absolute_url(): """return the absolute URL for Forms, could probably do better""" return reverse('conformity:organization_index') - def get_policies(self): - """return all Policy applicable to the Organization""" - return self.applicable_policies.all() + def get_frameworks(self): + """return all Framework applicable to the Organization""" + return self.applicable_frameworks.all() def remove_conformity(self, pid): """Cascade deletion of conformity""" with set_actor('system'): - measure_set = Measure.objects.filter(policy=pid) - for measure in measure_set: - Conformity.objects.filter(measure=measure.id).filter(organization=self.id).delete() + requirement_set = Requirement.objects.filter(framework=pid) + for requirement in requirement_set: + Conformity.objects.filter(requirement=requirement.id).filter(organization=self.id).delete() def add_conformity(self, pid): """Automatic creation of conformity""" with set_actor('system'): - measure_set = Measure.objects.filter(policy=pid) - for measure in measure_set: - conformity = Conformity(organization=self, measure=measure) + requirement_set = Requirement.objects.filter(framework=pid) + for requirement in requirement_set: + conformity = Conformity(organization=self, requirement=requirement) conformity.save() -class MeasureManager(models.Manager): +class RequirementManager(models.Manager): def get_by_natural_key(self, name): return self.get(name=name) -class Measure(models.Model): +class Requirement(models.Model): """ - A Measure is a precise requirement. - Measure can be hierarchical in order to form a collection of Measure, aka Policy. - A Measure is not representing the conformity level, see Conformity class. + A Requirement is a precise requirement. + Requirement can be hierarchical in order to form a collection of Requirement, aka Framework. + A Requirement is not representing the conformity level, see Conformity class. """ - objects = MeasureManager() + objects = RequirementManager() code = models.CharField(max_length=5, blank=True) name = models.CharField(max_length=50, blank=True, unique=True) level = models.IntegerField(default=0) order = models.IntegerField(default=1) - policy = models.ForeignKey(Policy, on_delete=models.CASCADE) + framework = models.ForeignKey(Framework, on_delete=models.CASCADE) parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True) title = models.CharField(max_length=256, blank=True) description = models.TextField(blank=True) @@ -151,57 +155,57 @@ class Meta: ordering = ['name'] def __str__(self): - return str(self.name + ": " + self.title) + return str(self.name) + ": " + str(self.title) def natural_key(self): - return self.name + return (self.name) - natural_key.dependencies = ['conformity.policy'] + natural_key.dependencies = ['conformity.framework'] def get_children(self): - """Return all children of the measure""" - return Measure.objects.filter(parent=self.id).order_by('order') + """Return all children of the requirement""" + return Requirement.objects.filter(parent=self.id).order_by('order') class Conformity(models.Model): """ - Conformity represent the conformity of an Organization to a Measure. - Value are automatically update for parent measure conformity + Conformity represent the conformity of an Organization to a Requirement. + Value are automatically update for parent requirement conformity """ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, null=True) - measure = models.ForeignKey(Measure, on_delete=models.CASCADE, null=True) + requirement = models.ForeignKey(Requirement, on_delete=models.CASCADE, null=True) applicable = models.BooleanField(default=True) status = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], null=True, blank=True) responsible = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) comment = models.TextField(max_length=4096, blank=True) class Meta: - ordering = ['organization', 'measure'] + ordering = ['organization', 'requirement'] verbose_name = 'Conformity' verbose_name_plural = 'Conformities' - unique_together = (('organization', 'measure'),) + unique_together = (('organization', 'requirement'),) def __str__(self): - return "[" + str(self.organization) + "] " + str(self.measure) + return "[" + str(self.organization) + "] " + str(self.requirement) def natural_key(self): - return self.organization, self.measure + return self.organization, self.requirement - natural_key.dependencies = ['conformity.policy', 'conformity.measure', 'conformity.organization'] + natural_key.dependencies = ['conformity.framework', 'conformity.requirement', 'conformity.organization'] def get_absolute_url(self): """Return the absolute URL of the class for Form, probably not the best way to do it""" - return reverse('conformity:conformity_orgpol_index', - kwargs={'org': self.organization.id, 'pol': self.measure.policy.id}) + return reverse('conformity:conformity_detail_index', + kwargs={'org': self.organization.id, 'pol': self.requirement.framework.id}) def get_children(self): - """Return all children Conformity based on Measure hierarchy""" + """Return all children Conformity based on Requirement hierarchy""" return Conformity.objects.filter(organization=self.organization) \ - .filter(measure__parent=self.measure.id).order_by('measure__order') + .filter(requirement__parent=self.requirement.id).order_by('requirement__order') def get_parent(self): - """Return the parent Conformity based on Measure hierarchy""" - p = Conformity.objects.filter(organization=self.organization).filter(measure=self.measure.parent) + """Return the parent Conformity based on Requirement hierarchy""" + p = Conformity.objects.filter(organization=self.organization).filter(requirement=self.requirement.parent) if len(p) == 1: return p[0] else: @@ -247,10 +251,9 @@ def update(self): # Callback functions - -@receiver(pre_save, sender=Measure) +@receiver(pre_save, sender=Requirement) def post_init_callback(instance, **kwargs): - """This function keep hierarchy of the Measure working on each Measure instantiation""" + """This function keep hierarchy of the Requirement working on each Requirement instantiation""" if instance.parent: instance.name = instance.parent.name + "-" + instance.code instance.level = instance.parent.level + 1 @@ -259,8 +262,8 @@ def post_init_callback(instance, **kwargs): instance.name = instance.code -@receiver(m2m_changed, sender=Organization.applicable_policies.through) -def change_policy(instance, action, pk_set, *args, **kwargs): +@receiver(m2m_changed, sender=Organization.applicable_frameworks.through) +def change_framework(instance, action, pk_set, *args, **kwargs): if action == "post_add": for pk in pk_set: instance.add_conformity(pk) @@ -288,7 +291,7 @@ class Type(models.TextChoices): description = models.TextField(max_length=4096, blank=True) conclusion = models.TextField(max_length=4096, blank=True) auditor = models.CharField(max_length=256) - audited_policies = models.ManyToManyField(Policy, blank=True) + audited_frameworks = models.ManyToManyField(Framework, blank=True) start_date = models.DateField(null=True, blank=True) end_date = models.DateField(null=True, blank=True) report_date = models.DateField(null=True, blank=True) @@ -297,30 +300,32 @@ class Type(models.TextChoices): choices=Type.choices, default=Type.OTHER, ) + attachment = models.ManyToManyField('Attachment', blank=True, related_name='audits') class Meta: ordering = ['report_date'] def __str__(self): + if self.report_date: - date = self.report_date.strftime('%b %Y') + display_date = self.report_date.strftime('%b %Y') elif self.start_date: - date = self.start_date.strftime('%b %Y') + display_date = self.start_date.strftime('%b %Y') elif self.end_date: - date = self.end_date.strftime('%b %Y') + display_date = self.end_date.strftime('%b %Y') else: - date = "xx-xxxx" + display_date = "xx-xxxx" - return "[" + str(self.organization) + "] " + str(self.auditor) + " (" + date + ")" + return "[" + str(self.organization) + "] " + str(self.auditor) + " (" + display_date + ")" @staticmethod def get_absolute_url(): """return the absolute URL for Forms, could probably do better""" return reverse('conformity:audit_index') - def get_policies(self): - """return all Policy within the Audit scope""" - return self.audited_policies.all() + def get_frameworks(self): + """return all Framework within the Audit scope""" + return self.audited_frameworks.all() def get_type(self): """return the readable version of the Audit Type""" @@ -391,7 +396,7 @@ def get_severity(self): return self.Severity(self.severity).label def get_absolute_url(self): - """"return somewhere else when a edit has work """ + """"return somewhere else when an edit has work """ return reverse('conformity:audit_detail', kwargs={'pk': self.audit_id}) def get_action(self): @@ -401,7 +406,7 @@ def get_action(self): class Control(models.Model): """ - Control class represent the periodic control needed to verify the security and the effectiveness of the security measure. + Control class represent the periodic control needed to verify the security and the effectiveness of the security requirement. """ class Frequency(models.IntegerChoices): @@ -432,7 +437,7 @@ class Level(models.IntegerChoices): ) def __str__(self): - return "[" + str(self.organization) + "] " + self.title + return "[" + str(self.organization) + "] " + str(self.title) @staticmethod def get_absolute_url(): @@ -441,6 +446,8 @@ def get_absolute_url(): @staticmethod def post_init_callback(instance, **kwargs): + ControlPoint.objects.filter(control=instance.id).filter(Q(status='SCHD') | Q(status='TOBE')).delete() + num_cp = instance.frequency today = date.today() start_date = date(today.year, 1, 1) @@ -449,15 +456,21 @@ def post_init_callback(instance, **kwargs): for _ in range(num_cp): period_start_date = date(start_date.year, start_date.month, 1) period_end_date = date(end_date.year, end_date.month, monthrange(end_date.year, end_date.month)[1]) - ControlPoint.objects.create( - control=instance, - period_start_date=period_start_date, - period_end_date=period_end_date, - ) + if not ControlPoint.objects.filter(control=instance.id).filter(period_start_date=period_start_date).filter(period_end_date=period_end_date) : + ControlPoint.objects.create( + control=instance, + period_start_date=period_start_date, + period_end_date=period_end_date, + ) start_date = period_end_date + timedelta(days=1) end_date = start_date + delta - timedelta(days=1) + def get_controlpoint(self): + """Return all control point based on this control""" + return ControlPoint.objects.filter(control=self.id).order_by('period_start_date') + + class ControlPoint(models.Model): """ A control point is a specific point of verification of a periodic Control. @@ -478,6 +491,7 @@ class Status(models.TextChoices): period_end_date = models.DateField() status = models.CharField(choices=Status.choices, max_length=4, default=Status.SCHEDULED) comment = models.TextField(max_length=4096, blank=True) + attachment = models.ManyToManyField('Attachment', blank=True, related_name='ControlPoint') @staticmethod def get_absolute_url(): @@ -486,13 +500,14 @@ def get_absolute_url(): @staticmethod def pre_save(sender, instance, *args, **kwargs): - today = date.today() - if instance.period_end_date < today: - instance.status = ControlPoint.Status.MISSED - elif instance.period_start_date <= today <= instance.period_end_date: - instance.status = ControlPoint.Status.TOBEEVALUATED - else: - instance.status = ControlPoint.Status.SCHEDULED + if instance.status != ControlPoint.Status.COMPLIANT and instance.status != ControlPoint.Status.NONCOMPLIANT: + today = date.today() + if instance.period_end_date < today: + instance.status = ControlPoint.Status.MISSED + elif instance.period_start_date <= today <= instance.period_end_date: + instance.status = ControlPoint.Status.TOBEEVALUATED + else: + instance.status = ControlPoint.Status.SCHEDULED def __str__(self): @@ -580,6 +595,25 @@ def save(self, *args, **kwargs): return super(Action, self).save(*args, **kwargs) +class Attachment(models.Model): + file = models.FileField(upload_to='attachments/') + comment = models.TextField(max_length=4096, blank=True) + mime_type = models.CharField(max_length=255, blank=True) + create_date = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.file.name.split("/")[1] + + @staticmethod + def pre_save(sender, instance, *args, **kwargs): + # Read file and set mime_type + file_content = instance.file.read() + instance.file.seek(0) + mime = magic.Magic(mime=True) + instance.mime_type = mime.from_buffer(file_content) + + # TODO filter on mime type + # # Signal # @@ -587,3 +621,4 @@ def save(self, *args, **kwargs): post_save.connect(Control.post_init_callback, sender=Control) pre_save.connect(ControlPoint.pre_save, sender=ControlPoint) +pre_save.connect(Attachment.pre_save, sender=Attachment) diff --git a/conformity/templates/conformity/action_form.html b/conformity/templates/conformity/action_form.html index b239670..4af7da1 100644 --- a/conformity/templates/conformity/action_form.html +++ b/conformity/templates/conformity/action_form.html @@ -2,7 +2,7 @@ {% load django_bootstrap5 %} {% block header %} -
Attachment | +Creation date | +References | +
---|---|---|
+ {{ attachment | truncatechars:40 }} + | ++ {{ attachment.create_date }} + | ++ {% for org in attachment.organizations.all %} + {{ org }} + {% endfor %} + {% for framework in attachment.frameworks.all %} + {{ framework }} + {% endfor %} + {% for cp in attachment.ControlPoint.all %} + {{ cp }} + {% endfor %} + {% for audit in attachment.audits.all %} + {{ audit }} + {% endfor %} + | +
{{ audit.get_type }} realized by {{ audit.auditor }} from {{ audit.start_date }} to {{ audit.end_date }}.
-The following policies were within the audit scope :
+The following frameworks were within the audit scope :