From 2f99250568705f01a7549b66d0be6808cd5a440e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 00:22:25 +0200 Subject: [PATCH] refactor(survey,benchmark): extract survey + benchmark modules into dojo// [Phase 1,3,4,5] --- dojo/benchmark/__init__.py | 1 + dojo/benchmark/admin.py | 15 ++ dojo/benchmark/models.py | 100 +++++++ dojo/benchmark/signals.py | 2 +- dojo/benchmark/ui/__init__.py | 0 dojo/benchmark/ui/forms.py | 37 +++ dojo/benchmark/{ => ui}/urls.py | 2 +- dojo/benchmark/{ => ui}/views.py | 6 +- dojo/filters.py | 66 +---- dojo/forms.py | 447 +------------------------------ dojo/models.py | 307 ++------------------- dojo/survey/__init__.py | 1 + dojo/survey/admin.py | 5 + dojo/survey/models.py | 181 +++++++++++++ dojo/survey/ui/__init__.py | 0 dojo/survey/ui/filters.py | 63 +++++ dojo/survey/ui/forms.py | 417 ++++++++++++++++++++++++++++ dojo/survey/{ => ui}/urls.py | 9 +- dojo/survey/{ => ui}/views.py | 34 +-- dojo/urls.py | 4 +- unittests/test_survey_forms.py | 2 +- 21 files changed, 876 insertions(+), 823 deletions(-) create mode 100644 dojo/benchmark/admin.py create mode 100644 dojo/benchmark/models.py create mode 100644 dojo/benchmark/ui/__init__.py create mode 100644 dojo/benchmark/ui/forms.py rename dojo/benchmark/{ => ui}/urls.py (96%) rename dojo/benchmark/{ => ui}/views.py (98%) create mode 100644 dojo/survey/admin.py create mode 100644 dojo/survey/models.py create mode 100644 dojo/survey/ui/__init__.py create mode 100644 dojo/survey/ui/filters.py create mode 100644 dojo/survey/ui/forms.py rename dojo/survey/{ => ui}/urls.py (94%) rename dojo/survey/{ => ui}/views.py (99%) diff --git a/dojo/benchmark/__init__.py b/dojo/benchmark/__init__.py index e69de29bb2d..08cfc4447d9 100644 --- a/dojo/benchmark/__init__.py +++ b/dojo/benchmark/__init__.py @@ -0,0 +1 @@ +import dojo.benchmark.admin # noqa: F401 diff --git a/dojo/benchmark/admin.py b/dojo/benchmark/admin.py new file mode 100644 index 00000000000..288569dc768 --- /dev/null +++ b/dojo/benchmark/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from dojo.benchmark.models import ( + Benchmark_Category, + Benchmark_Product, + Benchmark_Product_Summary, + Benchmark_Requirement, + Benchmark_Type, +) + +admin.site.register(Benchmark_Type) +admin.site.register(Benchmark_Requirement) +admin.site.register(Benchmark_Category) +admin.site.register(Benchmark_Product) +admin.site.register(Benchmark_Product_Summary) diff --git a/dojo/benchmark/models.py b/dojo/benchmark/models.py new file mode 100644 index 00000000000..184e9dc9b2d --- /dev/null +++ b/dojo/benchmark/models.py @@ -0,0 +1,100 @@ +from django.db import models +from django.utils.translation import gettext as _ + + +class Benchmark_Type(models.Model): + name = models.CharField(max_length=300) + version = models.CharField(max_length=15) + source = (("PCI", "PCI"), + ("OWASP ASVS", "OWASP ASVS"), + ("OWASP Mobile ASVS", "OWASP Mobile ASVS")) + benchmark_source = models.CharField(max_length=20, blank=False, + null=True, choices=source, + default="OWASP ASVS") + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + enabled = models.BooleanField(default=True) + + def __str__(self): + return self.name + " " + self.version + + +class Benchmark_Category(models.Model): + type = models.ForeignKey("dojo.Benchmark_Type", verbose_name=_("Benchmark Type"), on_delete=models.CASCADE) + name = models.CharField(max_length=300) + objective = models.TextField() + references = models.TextField(blank=True, null=True) + enabled = models.BooleanField(default=True) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + ": " + self.type.name + + +class Benchmark_Requirement(models.Model): + category = models.ForeignKey("dojo.Benchmark_Category", on_delete=models.CASCADE) + objective_number = models.CharField(max_length=15, null=True, blank=True) + objective = models.TextField() + references = models.TextField(blank=True, null=True) + level_1 = models.BooleanField(default=False) + level_2 = models.BooleanField(default=False) + level_3 = models.BooleanField(default=False) + enabled = models.BooleanField(default=True) + cwe_mapping = models.ManyToManyField("dojo.CWE", blank=True) + testing_guide = models.ManyToManyField("dojo.Testing_Guide", blank=True) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return str(self.objective_number) + ": " + self.category.name + + +class Benchmark_Product(models.Model): + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + control = models.ForeignKey("dojo.Benchmark_Requirement", on_delete=models.CASCADE) + pass_fail = models.BooleanField(default=False, verbose_name=_("Pass"), + help_text=_("Does the product meet the requirement?")) + enabled = models.BooleanField(default=True, + help_text=_("Applicable for this specific product.")) + notes = models.ManyToManyField("dojo.Notes", blank=True, editable=False) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("product", "control")] + + def __str__(self): + return self.product.name + ": " + self.control.objective_number + ": " + self.control.category.name + + +class Benchmark_Product_Summary(models.Model): + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + benchmark_type = models.ForeignKey("dojo.Benchmark_Type", on_delete=models.CASCADE) + asvs_level = (("Level 1", "Level 1"), + ("Level 2", "Level 2"), + ("Level 3", "Level 3")) + desired_level = models.CharField(max_length=15, + null=False, choices=asvs_level, + default="Level 1") + current_level = models.CharField(max_length=15, blank=True, + null=True, choices=asvs_level, + default="None") + asvs_level_1_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) + asvs_level_1_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 1 Score")) + asvs_level_2_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) + asvs_level_2_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 2 Score")) + asvs_level_3_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) + asvs_level_3_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 3 Score")) + publish = models.BooleanField(default=False, help_text=_("Publish score to Product.")) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("product", "benchmark_type")] + + def __str__(self): + return self.product.name + ": " + self.benchmark_type.name diff --git a/dojo/benchmark/signals.py b/dojo/benchmark/signals.py index 6f87fa320cd..f6d997698a7 100644 --- a/dojo/benchmark/signals.py +++ b/dojo/benchmark/signals.py @@ -3,7 +3,7 @@ from django.db.models.signals import pre_delete from django.dispatch import receiver -from dojo.models import Benchmark_Product +from dojo.benchmark.models import Benchmark_Product from dojo.notes.helper import delete_related_notes logger = logging.getLogger(__name__) diff --git a/dojo/benchmark/ui/__init__.py b/dojo/benchmark/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/benchmark/ui/forms.py b/dojo/benchmark/ui/forms.py new file mode 100644 index 00000000000..c4416af53f9 --- /dev/null +++ b/dojo/benchmark/ui/forms.py @@ -0,0 +1,37 @@ +from django import forms + +from dojo.benchmark.models import ( + Benchmark_Product, + Benchmark_Product_Summary, + Benchmark_Requirement, +) + + +class Benchmark_Product_SummaryForm(forms.ModelForm): + + class Meta: + model = Benchmark_Product_Summary + exclude = ["product", "current_level", "benchmark_type", "asvs_level_1_benchmark", "asvs_level_1_score", "asvs_level_2_benchmark", "asvs_level_2_score", "asvs_level_3_benchmark", "asvs_level_3_score"] + + +class DeleteBenchmarkForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Benchmark_Product_Summary + fields = ["id"] + + +class BenchmarkForm(forms.ModelForm): + + class Meta: + model = Benchmark_Product + exclude = ["product", "control"] + + +class Benchmark_RequirementForm(forms.ModelForm): + + class Meta: + model = Benchmark_Requirement + exclude = [""] diff --git a/dojo/benchmark/urls.py b/dojo/benchmark/ui/urls.py similarity index 96% rename from dojo/benchmark/urls.py rename to dojo/benchmark/ui/urls.py index 849e83c603c..3581ce165ab 100644 --- a/dojo/benchmark/urls.py +++ b/dojo/benchmark/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.benchmark.ui import views urlpatterns = [ re_path( diff --git a/dojo/benchmark/views.py b/dojo/benchmark/ui/views.py similarity index 98% rename from dojo/benchmark/views.py rename to dojo/benchmark/ui/views.py index b1dc065692e..40bfde471d7 100644 --- a/dojo/benchmark/views.py +++ b/dojo/benchmark/ui/views.py @@ -8,15 +8,15 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from dojo.forms import Benchmark_Product_SummaryForm, DeleteBenchmarkForm -from dojo.models import ( +from dojo.benchmark.models import ( Benchmark_Category, Benchmark_Product, Benchmark_Product_Summary, Benchmark_Requirement, Benchmark_Type, - Product, ) +from dojo.benchmark.ui.forms import Benchmark_Product_SummaryForm, DeleteBenchmarkForm +from dojo.models import Product from dojo.templatetags.display_tags import asvs_level from dojo.utils import ( Product_Tab, diff --git a/dojo/filters.py b/dojo/filters.py index 8162da4e0c5..b6ff6815f6f 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -1,19 +1,16 @@ import collections import decimal import logging -import warnings from datetime import datetime, timedelta import six import tagulous from django.apps import apps from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q from django.utils.timezone import now, tzinfo from django.utils.translation import gettext_lazy as _ from django_filters import ( - BooleanFilter, CharFilter, DateFilter, FilterSet, @@ -25,7 +22,6 @@ ) from django_filters import rest_framework as filters from django_filters.filters import ChoiceFilter -from polymorphic.base import ManagerInheritanceWarning # from tagulous.forms import TagWidget # import tagulous @@ -46,21 +42,17 @@ from dojo.models import ( SEVERITY_CHOICES, App_Analysis, - ChoiceQuestion, Development_Environment, DojoMeta, Endpoint, Endpoint_Status, Engagement, - Engagement_Survey, Finding, Note_Type, Product, Product_Type, - Question, Risk_Acceptance, Test, - TextQuestion, Vulnerability_Id, ) from dojo.product_type.queries import get_authorized_product_types @@ -1413,64 +1405,8 @@ class Meta: exclude = [] include = ("name", "is_single", "description") -# ============================== -# Defect Dojo Engaegment Surveys -# ============================== - - -class QuestionnaireFilter(FilterSet): - name = CharFilter(lookup_expr="icontains") - description = CharFilter(lookup_expr="icontains") - active = BooleanFilter() - - class Meta: - model = Engagement_Survey - exclude = ["questions"] - - survey_set = FilterSet - - -class QuestionTypeFilter(ChoiceFilter): - def any(self, qs, name): - return qs.all() - - def text_question(self, qs, name): - return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(TextQuestion)) - - def choice_question(self, qs, name): - return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(ChoiceQuestion)) - - options = { - None: (_("Any"), any), - 1: (_("Text Question"), text_question), - 2: (_("Choice Question"), choice_question), - } - - def __init__(self, *args, **kwargs): - kwargs["choices"] = [ - (key, value[0]) for key, value in six.iteritems(self.options)] - super().__init__(*args, **kwargs) - - def filter(self, qs, value): - try: - value = int(value) - except (ValueError, TypeError): - value = None - return self.options[value][1](self, qs, self.options[value][0]) - - # ApiUserFilter lives in dojo/user/api/filters.py — import from there directly. - -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class QuestionFilter(FilterSet): - text = CharFilter(lookup_expr="icontains") - type = QuestionTypeFilter() - - class Meta: - model = Question - exclude = ["polymorphic_ctype", "created", "modified", "order"] - - question_set = FilterSet +# QuestionnaireFilter, QuestionTypeFilter, QuestionFilter live in dojo/survey/ui/filters.py from dojo.auditlog.filters import LogEntryFilter, PgHistoryFilter # noqa: E402, F401 -- backward compat diff --git a/dojo/forms.py b/dojo/forms.py index e07dfb9517c..999c9bf16d8 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -1,13 +1,8 @@ -import json import logging import re -import warnings from datetime import date, datetime from pathlib import Path -from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout from crum import get_current_user from dateutil.relativedelta import relativedelta from django import forms @@ -15,14 +10,12 @@ from django.contrib.auth.models import Permission from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError -from django.db.models import Count from django.forms import modelformset_factory from django.forms.widgets import Select, Widget from django.utils import timezone from django.utils.dates import MONTHS from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from polymorphic.base import ManagerInheritanceWarning from tagulous.forms import TagField from dojo.endpoint.utils import validate_endpoints_to_add @@ -54,41 +47,28 @@ from dojo.models import ( SEVERITY_CHOICES, Announcement, - Answered_Survey, App_Analysis, - Benchmark_Product, - Benchmark_Product_Summary, - Benchmark_Requirement, Check_List, - Choice, - ChoiceAnswer, - ChoiceQuestion, Development_Environment, Dojo_User, DojoMeta, Endpoint, - Engagement_Survey, FileUpload, Finding, Finding_Group, - General_Survey, Note_Type, Notes, Objects_Product, Product_API_Scan_Configuration, Product_Type, - Question, Regulation, Risk_Acceptance, SLA_Configuration, Test_Type, - TextAnswer, - TextQuestion, User, ) from dojo.product_type.queries import get_authorized_product_types from dojo.tools.factory import get_choices_sorted, requires_file, requires_tool_type -from dojo.user.queries import get_authorized_users from dojo.user.utils import get_configuration_permissions_fields from dojo.utils import ( get_password_requirements_string, @@ -110,14 +90,6 @@ ("out_of_scope", "Out of Scope")) -class MultipleSelectWithPop(forms.SelectMultiple): - def render(self, name, *args, **kwargs): - html = super().render(name, *args, **kwargs) - popup_plus = '
' + html + '
' - - return mark_safe(popup_plus) - - class MonthYearWidget(Widget): """ @@ -953,20 +925,12 @@ class CustomReportOptionsForm(forms.Form): report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) -class Benchmark_Product_SummaryForm(forms.ModelForm): - - class Meta: - model = Benchmark_Product_Summary - exclude = ["product", "current_level", "benchmark_type", "asvs_level_1_benchmark", "asvs_level_1_score", "asvs_level_2_benchmark", "asvs_level_2_score", "asvs_level_3_benchmark", "asvs_level_3_score"] - - -class DeleteBenchmarkForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Benchmark_Product_Summary - fields = ["id"] +from dojo.benchmark.ui.forms import ( # noqa: E402, F401 -- backward compat + Benchmark_Product_SummaryForm, + Benchmark_RequirementForm, + BenchmarkForm, + DeleteBenchmarkForm, +) class RegulationForm(forms.ModelForm): @@ -1066,20 +1030,6 @@ def clean(self): return self.cleaned_data -class BenchmarkForm(forms.ModelForm): - - class Meta: - model = Benchmark_Product - exclude = ["product", "control"] - - -class Benchmark_RequirementForm(forms.ModelForm): - - class Meta: - model = Benchmark_Requirement - exclude = [""] - - from dojo.notifications.ui.forms import ( # noqa: E402, F401 -- backward compat DeleteNotificationsWebhookForm, NotificationsForm, @@ -1124,391 +1074,6 @@ def __init__(self, *args, **kwargs): self.fields["style"].disabled = True -# ============================== -# Defect Dojo Engaegment Surveys -# ============================== - -# List of validator_name:func_name -# Show in admin a multichoice list of validator names -# pass this to form using field_name='validator_name' ? -class QuestionForm(forms.Form): - - """Base class for a Question""" - - def __init__(self, *args, **kwargs): - self.helper = FormHelper() - self.helper.form_method = "post" - - # If true crispy-forms will render a
..
tags - self.helper.form_tag = kwargs.pop("form_tag", True) - - self.engagement_survey = kwargs.get("engagement_survey") - - self.answered_survey = kwargs.get("answered_survey") - if not self.answered_survey: - del kwargs["engagement_survey"] - else: - del kwargs["answered_survey"] - - self.helper.form_class = kwargs.get("form_class", "") - - self.question = kwargs.pop("question", None) - - if not self.question: - msg = "Need a question to render" - raise ValueError(msg) - - super().__init__(*args, **kwargs) - - -class TextQuestionForm(QuestionForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # work out initial data - - initial_answer = TextAnswer.objects.filter( - answered_survey=self.answered_survey, - question=self.question, - ) - - initial_answer = initial_answer[0].answer if initial_answer.exists() else "" - - self.fields["answer"] = forms.CharField( - label=self.question.text, - widget=forms.Textarea(attrs={"rows": 3, "cols": 10}), - required=not self.question.optional, - initial=initial_answer, - ) - - def save(self): - if not self.is_valid(): - msg = "form is not valid" - raise forms.ValidationError(msg) - - answer = self.cleaned_data.get("answer") - - if not answer: - if self.fields["answer"].required: - msg = "Required" - raise forms.ValidationError(msg) - return - - text_answer, created = TextAnswer.objects.get_or_create( - answered_survey=self.answered_survey, - question=self.question, - ) - - if created: - text_answer.answered_survey = self.answered_survey - text_answer.answer = answer - text_answer.save() - - -class ChoiceQuestionForm(QuestionForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - choices = [(c.id, c.label) for c in self.question.choices.all()] - - # initial values - - initial_choices = [] - choice_answer = ChoiceAnswer.objects.filter( - answered_survey=self.answered_survey, - question=self.question, - ).annotate(a=Count("answer")).filter(a__gt=0) - - # we have ChoiceAnswer instance - if choice_answer: - choice_answer = choice_answer[0] - initial_choices = list(choice_answer.answer.all().values_list("id", flat=True)) - if self.question.multichoice is False: - initial_choices = initial_choices[0] - - # default classes - widget = forms.RadioSelect - field_type = forms.ChoiceField - inline_type = InlineRadios - - if self.question.multichoice: - field_type = forms.MultipleChoiceField - widget = forms.CheckboxSelectMultiple - inline_type = InlineCheckboxes - - field = field_type( - label=self.question.text, - required=not self.question.optional, - choices=choices, - initial=initial_choices, - widget=widget, - ) - - self.fields["answer"] = field - - # Render choice buttons inline - self.helper.layout = Layout( - inline_type("answer"), - ) - - def clean_answer(self): - real_answer = self.cleaned_data.get("answer") - - # for single choice questions, the selected answer is a single string - if not isinstance(real_answer, list): - real_answer = [real_answer] - return real_answer - - def save(self): - if not self.is_valid(): - msg = "Form is not valid" - raise forms.ValidationError(msg) - - real_answer = self.cleaned_data.get("answer") - - if not real_answer: - if self.fields["answer"].required: - msg = "Required" - raise forms.ValidationError(msg) - return - - choices = Choice.objects.filter(id__in=real_answer) - - # find ChoiceAnswer and filter in answer ! - choice_answer = ChoiceAnswer.objects.filter( - answered_survey=self.answered_survey, - question=self.question, - ) - - # we have ChoiceAnswer instance - if choice_answer: - choice_answer = choice_answer[0] - - if not choice_answer: - # create a ChoiceAnswer - choice_answer = ChoiceAnswer.objects.create( - answered_survey=self.answered_survey, - question=self.question, - ) - - # re save out the choices - choice_answer.answered_survey = self.answered_survey - choice_answer.answer.set(choices) - choice_answer.save() - - -class Add_Questionnaire_Form(forms.ModelForm): - survey = forms.ModelChoiceField( - queryset=Engagement_Survey.objects.all(), - required=True, - widget=forms.widgets.Select(), - help_text="Select the Questionnaire to add.") - - class Meta: - model = Answered_Survey - exclude = ("responder", - "completed", - "engagement", - "answered_on", - "assignee") - - -class AddGeneralQuestionnaireForm(forms.ModelForm): - survey = forms.ModelChoiceField( - queryset=Engagement_Survey.objects.all(), - required=True, - widget=forms.widgets.Select(), - help_text="Select the Questionnaire to add.") - expiration = forms.DateField(widget=forms.TextInput( - attrs={"class": "datepicker", "autocomplete": "off"})) - - class Meta: - model = General_Survey - exclude = ("num_responses", "generated") - - # date can only be today or in the past, not the future - def clean_expiration(self): - expiration = self.cleaned_data.get("expiration", None) - if expiration: - today = datetime.today().date() - if expiration < today: - msg = "The expiration cannot be in the past" - raise forms.ValidationError(msg) - if expiration == today: - msg = "The expiration cannot be today" - raise forms.ValidationError(msg) - return timezone.make_aware( - datetime.combine(expiration, datetime.min.time()), - ) - msg = "An expiration for the survey must be supplied" - raise forms.ValidationError(msg) - - -class Delete_Questionnaire_Form(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Answered_Survey - fields = ["id"] - - -class DeleteGeneralQuestionnaireForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = General_Survey - fields = ["id"] - - -class Delete_Eng_Survey_Form(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Engagement_Survey - fields = ["id"] - - -class CreateQuestionnaireForm(forms.ModelForm): - class Meta: - model = Engagement_Survey - exclude = ["questions"] - - -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class EditQuestionnaireQuestionsForm(forms.ModelForm): - questions = forms.ModelMultipleChoiceField( - Question.polymorphic.all(), - required=True, - help_text="Select questions to include on this questionnaire. Field can be used to search available questions.", - widget=MultipleSelectWithPop(attrs={"size": "11"})) - - class Meta: - model = Engagement_Survey - exclude = ["name", "description", "active"] - - -class CreateQuestionForm(forms.Form): - type = forms.ChoiceField( - choices=(("---", "-----"), ("text", "Text"), ("choice", "Choice"))) - order = forms.IntegerField( - min_value=1, - widget=forms.TextInput(attrs={"data-type": "both"}), - help_text="The order the question will appear on the questionnaire") - optional = forms.BooleanField(help_text="If selected, user doesn't have to answer this question", - initial=False, - required=False, - widget=forms.CheckboxInput(attrs={"data-type": "both"})) - text = forms.CharField(widget=forms.Textarea(attrs={"data-type": "text"}), - label="Question Text", - help_text="The actual question.") - - -class CreateTextQuestionForm(forms.Form): - class Meta: - model = TextQuestion - exclude = ["order", "optional"] - - -class MultiWidgetBasic(forms.widgets.MultiWidget): - def __init__(self, attrs=None): - widgets = [forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"})] - super().__init__(widgets, attrs) - - def decompress(self, value): - if value: - return json.loads(value) - return [None, None, None, None, None, None] - - def format_output(self, rendered_widgets): - return "
".join(rendered_widgets) - - -class MultiExampleField(forms.fields.MultiValueField): - widget = MultiWidgetBasic - - def __init__(self, *args, **kwargs): - list_fields = [forms.fields.CharField(required=True), - forms.fields.CharField(required=True), - forms.fields.CharField(required=False), - forms.fields.CharField(required=False), - forms.fields.CharField(required=False), - forms.fields.CharField(required=False)] - super().__init__(list_fields, *args, **kwargs) - - def compress(self, values): - return json.dumps(values) - - -class CreateChoiceQuestionForm(forms.Form): - multichoice = forms.BooleanField(required=False, - initial=False, - widget=forms.CheckboxInput(attrs={"data-type": "choice"}), - help_text="Can more than one choice can be selected?") - - answer_choices = MultiExampleField(required=False, widget=MultiWidgetBasic(attrs={"data-type": "choice"})) - - class Meta: - model = ChoiceQuestion - exclude = ["order", "optional", "choices"] - - -class EditQuestionForm(forms.ModelForm): - class Meta: - model = Question - exclude = [] - - -class EditTextQuestionForm(EditQuestionForm): - class Meta: - model = TextQuestion - exclude = [] - - -class EditChoiceQuestionForm(EditQuestionForm): - choices = forms.ModelMultipleChoiceField( - Choice.objects.all(), - required=True, - help_text="Select choices to include on this question. Field can be used to search available choices.", - widget=MultipleSelectWithPop(attrs={"size": "11"})) - - class Meta: - model = ChoiceQuestion - exclude = [] - - -class AddChoicesForm(forms.ModelForm): - class Meta: - model = Choice - exclude = [] - - -class AssignUserForm(forms.ModelForm): - assignee = forms.CharField(required=False, - widget=forms.widgets.HiddenInput()) - - def __init__(self, *args, **kwargs): - assignee = None - if "assignee" in kwargs: - assignee = kwargs.pop("asignees") - super().__init__(*args, **kwargs) - if assignee is None: - self.fields["assignee"] = forms.ModelChoiceField(queryset=get_authorized_users("view"), empty_label="Not Assigned", required=False) - else: - self.fields["assignee"].initial = assignee - - class Meta: - model = Answered_Survey - exclude = ["engagement", "survey", "responder", "completed", "answered_on"] - - class ConfigurationPermissionsForm(forms.Form): def __init__(self, *args, **kwargs): diff --git a/dojo/models.py b/dojo/models.py index 7e6052ffe0c..651bebad557 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,6 +1,5 @@ import copy import logging -import warnings from datetime import timedelta from pathlib import Path from uuid import uuid4 @@ -20,10 +19,6 @@ from django.utils.deconstruct import deconstructible from django.utils.timezone import now from django.utils.translation import gettext as _ -from django_extensions.db.models import TimeStampedModel -from polymorphic.base import ManagerInheritanceWarning -from polymorphic.managers import PolymorphicManager -from polymorphic.models import PolymorphicModel from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager # noqa: F401 -- backward compat re-export @@ -552,7 +547,7 @@ class Meta: from dojo.finding.models import ( # noqa: E402 -- re-export; class-body FKs below reference these - CWE, + CWE, # noqa: F401 -- re-export BurpRawRequestResponse, # noqa: F401 -- re-export Finding, Finding_Group, # noqa: F401 -- re-export @@ -905,278 +900,26 @@ def __str__(self): return self.testing_guide_category.name + ": " + self.name -class Benchmark_Type(models.Model): - name = models.CharField(max_length=300) - version = models.CharField(max_length=15) - source = (("PCI", "PCI"), - ("OWASP ASVS", "OWASP ASVS"), - ("OWASP Mobile ASVS", "OWASP Mobile ASVS")) - benchmark_source = models.CharField(max_length=20, blank=False, - null=True, choices=source, - default="OWASP ASVS") - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - enabled = models.BooleanField(default=True) - - def __str__(self): - return self.name + " " + self.version - - -class Benchmark_Category(models.Model): - type = models.ForeignKey(Benchmark_Type, verbose_name=_("Benchmark Type"), on_delete=models.CASCADE) - name = models.CharField(max_length=300) - objective = models.TextField() - references = models.TextField(blank=True, null=True) - enabled = models.BooleanField(default=True) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name + ": " + self.type.name - - -class Benchmark_Requirement(models.Model): - category = models.ForeignKey(Benchmark_Category, on_delete=models.CASCADE) - objective_number = models.CharField(max_length=15, null=True, blank=True) - objective = models.TextField() - references = models.TextField(blank=True, null=True) - level_1 = models.BooleanField(default=False) - level_2 = models.BooleanField(default=False) - level_3 = models.BooleanField(default=False) - enabled = models.BooleanField(default=True) - cwe_mapping = models.ManyToManyField(CWE, blank=True) - testing_guide = models.ManyToManyField(Testing_Guide, blank=True) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - def __str__(self): - return str(self.objective_number) + ": " + self.category.name - - -class Benchmark_Product(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - control = models.ForeignKey(Benchmark_Requirement, on_delete=models.CASCADE) - pass_fail = models.BooleanField(default=False, verbose_name=_("Pass"), - help_text=_("Does the product meet the requirement?")) - enabled = models.BooleanField(default=True, - help_text=_("Applicable for this specific product.")) - notes = models.ManyToManyField(Notes, blank=True, editable=False) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = [("product", "control")] - - def __str__(self): - return self.product.name + ": " + self.control.objective_number + ": " + self.control.category.name - - -class Benchmark_Product_Summary(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - benchmark_type = models.ForeignKey(Benchmark_Type, on_delete=models.CASCADE) - asvs_level = (("Level 1", "Level 1"), - ("Level 2", "Level 2"), - ("Level 3", "Level 3")) - desired_level = models.CharField(max_length=15, - null=False, choices=asvs_level, - default="Level 1") - current_level = models.CharField(max_length=15, blank=True, - null=True, choices=asvs_level, - default="None") - asvs_level_1_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) - asvs_level_1_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 1 Score")) - asvs_level_2_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) - asvs_level_2_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 2 Score")) - asvs_level_3_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) - asvs_level_3_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 3 Score")) - publish = models.BooleanField(default=False, help_text=_("Publish score to Product.")) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = [("product", "benchmark_type")] - - def __str__(self): - return self.product.name + ": " + self.benchmark_type.name - - -# ========================== -# Defect Dojo Engaegment Surveys -# ============================== -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class Question(PolymorphicModel, TimeStampedModel): - - """Represents a question.""" - - class Meta: - ordering = ["order"] - - order = models.PositiveIntegerField(default=1, - help_text=_("The render order")) - - optional = models.BooleanField( - default=False, - help_text=_("If selected, user doesn't have to answer this question")) - - text = models.TextField(blank=False, help_text=_("The question text"), default="") - objects = models.Manager() - polymorphic = PolymorphicManager() - - def __str__(self): - return self.text - - -class TextQuestion(Question): - - """Question with a text answer""" - - objects = PolymorphicManager() - - def get_form(self): - """Returns the form for this model""" - from .forms import TextQuestionForm # noqa: PLC0415 - return TextQuestionForm - - -class Choice(TimeStampedModel): - - """Model to store the choices for multi choice questions""" - - order = models.PositiveIntegerField(default=1) - - label = models.TextField(default="") - - class Meta: - ordering = ["order"] - - def __str__(self): - return self.label - - -class ChoiceQuestion(Question): - - """ - Question with answers that are chosen from a list of choices defined - by the user. - """ - - multichoice = models.BooleanField(default=False, - help_text=_("Select one or more")) - choices = models.ManyToManyField(Choice) - objects = PolymorphicManager() - - def get_form(self): - """Returns the form for this model""" - from .forms import ChoiceQuestionForm # noqa: PLC0415 - return ChoiceQuestionForm - - -# meant to be a abstract survey, identified by name for purpose -class Engagement_Survey(models.Model): - name = models.CharField(max_length=200, null=False, blank=False, - editable=True, default="") - description = models.TextField(editable=True, default="") - questions = models.ManyToManyField(Question) - active = models.BooleanField(default=True) - - class Meta: - verbose_name = _("Engagement Survey") - verbose_name_plural = "Engagement Surveys" - ordering = ("-active", "name") - - def __str__(self): - return self.name - - -# meant to be an answered survey tied to an engagement - -class Answered_Survey(models.Model): - # tie this to a specific engagement - engagement = models.ForeignKey(Engagement, related_name="engagement+", - null=True, blank=False, editable=True, - on_delete=models.CASCADE) - # what surveys have been answered - survey = models.ForeignKey(Engagement_Survey, on_delete=models.CASCADE) - assignee = models.ForeignKey(Dojo_User, related_name="assignee", - null=True, blank=True, editable=True, - default=None, on_delete=models.RESTRICT) - # who answered it - responder = models.ForeignKey(Dojo_User, related_name="responder", - null=True, blank=True, editable=True, - default=None, on_delete=models.RESTRICT) - completed = models.BooleanField(default=False) - answered_on = models.DateField(null=True) - - class Meta: - verbose_name = _("Answered Engagement Survey") - verbose_name_plural = _("Answered Engagement Surveys") - - def __str__(self): - return self.survey.name - - -def default_expiration(): - return timezone.now() + timedelta(days=7) - - -class General_Survey(models.Model): - survey = models.ForeignKey(Engagement_Survey, on_delete=models.CASCADE) - num_responses = models.IntegerField(default=0) - generated = models.DateTimeField(auto_now_add=True, null=True) - expiration = models.DateTimeField(default=default_expiration) - - class Meta: - verbose_name = _("General Engagement Survey") - verbose_name_plural = _("General Engagement Surveys") - - def __str__(self): - return self.survey.name - - def clean(self): - if self.expiration and timezone.is_naive(self.expiration): - self.expiration = timezone.make_aware(self.expiration) - - -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class Answer(PolymorphicModel, TimeStampedModel): - - """Base Answer model""" - - question = models.ForeignKey(Question, on_delete=models.CASCADE) - - answered_survey = models.ForeignKey(Answered_Survey, - null=False, - blank=False, - on_delete=models.CASCADE) - objects = models.Manager() - polymorphic = PolymorphicManager() - - -class TextAnswer(Answer): - answer = models.TextField( - blank=False, - help_text=_("The answer text"), - default="") - objects = PolymorphicManager() - - def __str__(self): - return self.answer - - -class ChoiceAnswer(Answer): - answer = models.ManyToManyField( - Choice, - help_text=_("The selected choices as the answer")) - objects = PolymorphicManager() - - def __str__(self): - if len(self.answer.all()): - return str(self.answer.all()[0]) - return "No Response" - +from dojo.benchmark.models import ( # noqa: E402, I001 -- re-export; backward compat + Benchmark_Category, # noqa: F401 + Benchmark_Product, # noqa: F401 + Benchmark_Product_Summary, # noqa: F401 + Benchmark_Requirement, # noqa: F401 + Benchmark_Type, # noqa: F401 +) +from dojo.survey.models import ( # noqa: E402 -- re-export; backward compat + Answer, # noqa: F401 + Answered_Survey, # noqa: F401 + Choice, # noqa: F401 + ChoiceAnswer, # noqa: F401 + ChoiceQuestion, # noqa: F401 + Engagement_Survey, # noqa: F401 + General_Survey, # noqa: F401 + Question, # noqa: F401 + TextAnswer, # noqa: F401 + TextQuestion, # noqa: F401 + default_expiration, # noqa: F401 +) # Audit logging registration is now handled in auditlog.py and configured in apps.py # This allows for conditional registration of either django-auditlog or django-pghistory @@ -1198,13 +941,6 @@ def __str__(self): tagulous.admin.register(App_Analysis.tags) tagulous.admin.register(Objects_Product.tags) -# Benchmarks -admin.site.register(Benchmark_Type) -admin.site.register(Benchmark_Requirement) -admin.site.register(Benchmark_Category) -admin.site.register(Benchmark_Product) -admin.site.register(Benchmark_Product_Summary) - # Testing admin.site.register(Testing_Guide_Category) admin.site.register(Testing_Guide) @@ -1255,4 +991,3 @@ def __str__(self): admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) -admin.site.register(General_Survey) diff --git a/dojo/survey/__init__.py b/dojo/survey/__init__.py index e69de29bb2d..dcf96374631 100644 --- a/dojo/survey/__init__.py +++ b/dojo/survey/__init__.py @@ -0,0 +1 @@ +import dojo.survey.admin # noqa: F401 diff --git a/dojo/survey/admin.py b/dojo/survey/admin.py new file mode 100644 index 00000000000..15b76ad2c8a --- /dev/null +++ b/dojo/survey/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.survey.models import General_Survey + +admin.site.register(General_Survey) diff --git a/dojo/survey/models.py b/dojo/survey/models.py new file mode 100644 index 00000000000..6e8b98a9cf7 --- /dev/null +++ b/dojo/survey/models.py @@ -0,0 +1,181 @@ +import warnings +from datetime import timedelta + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext as _ +from django_extensions.db.models import TimeStampedModel +from polymorphic.base import ManagerInheritanceWarning +from polymorphic.managers import PolymorphicManager +from polymorphic.models import PolymorphicModel + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class Question(PolymorphicModel, TimeStampedModel): + + """Represents a question.""" + + class Meta: + ordering = ["order"] + + order = models.PositiveIntegerField(default=1, + help_text=_("The render order")) + + optional = models.BooleanField( + default=False, + help_text=_("If selected, user doesn't have to answer this question")) + + text = models.TextField(blank=False, help_text=_("The question text"), default="") + objects = models.Manager() + polymorphic = PolymorphicManager() + + def __str__(self): + return self.text + + +class TextQuestion(Question): + + """Question with a text answer""" + + objects = PolymorphicManager() + + def get_form(self): + """Returns the form for this model""" + from dojo.survey.ui.forms import TextQuestionForm # noqa: PLC0415 -- lazy import, avoids circular dependency + return TextQuestionForm + + +class Choice(TimeStampedModel): + + """Model to store the choices for multi choice questions""" + + order = models.PositiveIntegerField(default=1) + + label = models.TextField(default="") + + class Meta: + ordering = ["order"] + + def __str__(self): + return self.label + + +class ChoiceQuestion(Question): + + """ + Question with answers that are chosen from a list of choices defined + by the user. + """ + + multichoice = models.BooleanField(default=False, + help_text=_("Select one or more")) + choices = models.ManyToManyField("dojo.Choice") + objects = PolymorphicManager() + + def get_form(self): + """Returns the form for this model""" + from dojo.survey.ui.forms import ChoiceQuestionForm # noqa: PLC0415 -- lazy import, avoids circular dependency + return ChoiceQuestionForm + + +# meant to be a abstract survey, identified by name for purpose +class Engagement_Survey(models.Model): + name = models.CharField(max_length=200, null=False, blank=False, + editable=True, default="") + description = models.TextField(editable=True, default="") + questions = models.ManyToManyField("dojo.Question") + active = models.BooleanField(default=True) + + class Meta: + verbose_name = _("Engagement Survey") + verbose_name_plural = "Engagement Surveys" + ordering = ("-active", "name") + + def __str__(self): + return self.name + + +# meant to be an answered survey tied to an engagement + +class Answered_Survey(models.Model): + # tie this to a specific engagement + engagement = models.ForeignKey("dojo.Engagement", related_name="engagement+", + null=True, blank=False, editable=True, + on_delete=models.CASCADE) + # what surveys have been answered + survey = models.ForeignKey("dojo.Engagement_Survey", on_delete=models.CASCADE) + assignee = models.ForeignKey("dojo.Dojo_User", related_name="assignee", + null=True, blank=True, editable=True, + default=None, on_delete=models.RESTRICT) + # who answered it + responder = models.ForeignKey("dojo.Dojo_User", related_name="responder", + null=True, blank=True, editable=True, + default=None, on_delete=models.RESTRICT) + completed = models.BooleanField(default=False) + answered_on = models.DateField(null=True) + + class Meta: + verbose_name = _("Answered Engagement Survey") + verbose_name_plural = _("Answered Engagement Surveys") + + def __str__(self): + return self.survey.name + + +def default_expiration(): + return timezone.now() + timedelta(days=7) + + +class General_Survey(models.Model): + survey = models.ForeignKey("dojo.Engagement_Survey", on_delete=models.CASCADE) + num_responses = models.IntegerField(default=0) + generated = models.DateTimeField(auto_now_add=True, null=True) + expiration = models.DateTimeField(default=default_expiration) + + class Meta: + verbose_name = _("General Engagement Survey") + verbose_name_plural = _("General Engagement Surveys") + + def __str__(self): + return self.survey.name + + def clean(self): + if self.expiration and timezone.is_naive(self.expiration): + self.expiration = timezone.make_aware(self.expiration) + + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class Answer(PolymorphicModel, TimeStampedModel): + + """Base Answer model""" + + question = models.ForeignKey("dojo.Question", on_delete=models.CASCADE) + + answered_survey = models.ForeignKey("dojo.Answered_Survey", + null=False, + blank=False, + on_delete=models.CASCADE) + objects = models.Manager() + polymorphic = PolymorphicManager() + + +class TextAnswer(Answer): + answer = models.TextField( + blank=False, + help_text=_("The answer text"), + default="") + objects = PolymorphicManager() + + def __str__(self): + return self.answer + + +class ChoiceAnswer(Answer): + answer = models.ManyToManyField( + "dojo.Choice", + help_text=_("The selected choices as the answer")) + objects = PolymorphicManager() + + def __str__(self): + if len(self.answer.all()): + return str(self.answer.all()[0]) + return "No Response" diff --git a/dojo/survey/ui/__init__.py b/dojo/survey/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/survey/ui/filters.py b/dojo/survey/ui/filters.py new file mode 100644 index 00000000000..c1cac668f04 --- /dev/null +++ b/dojo/survey/ui/filters.py @@ -0,0 +1,63 @@ +import warnings + +import six +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ +from django_filters import BooleanFilter, CharFilter, FilterSet +from django_filters.filters import ChoiceFilter +from polymorphic.base import ManagerInheritanceWarning + +from dojo.survey.models import ChoiceQuestion, Engagement_Survey, Question, TextQuestion + + +class QuestionnaireFilter(FilterSet): + name = CharFilter(lookup_expr="icontains") + description = CharFilter(lookup_expr="icontains") + active = BooleanFilter() + + class Meta: + model = Engagement_Survey + exclude = ["questions"] + + survey_set = FilterSet + + +class QuestionTypeFilter(ChoiceFilter): + def any(self, qs, name): + return qs.all() + + def text_question(self, qs, name): + return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(TextQuestion)) + + def choice_question(self, qs, name): + return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(ChoiceQuestion)) + + options = { + None: (_("Any"), any), # noqa: A003 -- shadows builtin; matches original dojo/filters.py pattern + 1: (_("Text Question"), text_question), + 2: (_("Choice Question"), choice_question), + } + + def __init__(self, *args, **kwargs): + kwargs["choices"] = [ + (key, value[0]) for key, value in six.iteritems(self.options)] + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + try: + value = int(value) + except (ValueError, TypeError): + value = None + return self.options[value][1](self, qs, self.options[value][0]) + + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class QuestionFilter(FilterSet): + text = CharFilter(lookup_expr="icontains") + type = QuestionTypeFilter() + + class Meta: + model = Question + exclude = ["polymorphic_ctype", "created", "modified", "order"] + + question_set = FilterSet diff --git a/dojo/survey/ui/forms.py b/dojo/survey/ui/forms.py new file mode 100644 index 00000000000..72d1898f4b0 --- /dev/null +++ b/dojo/survey/ui/forms.py @@ -0,0 +1,417 @@ +import json +import warnings +from datetime import datetime + +from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout +from django import forms +from django.db.models import Count +from django.utils import timezone +from polymorphic.base import ManagerInheritanceWarning + +from dojo.survey.models import ( + Answered_Survey, + Choice, + ChoiceAnswer, + ChoiceQuestion, + Engagement_Survey, + General_Survey, + Question, + TextAnswer, + TextQuestion, +) +from dojo.user.queries import get_authorized_users + + +class MultipleSelectWithPop(forms.SelectMultiple): + def render(self, name, *args, **kwargs): + from django.utils.safestring import mark_safe # noqa: PLC0415 -- lazy import, avoids circular dependency + html = super().render(name, *args, **kwargs) + popup_plus = '
' + html + '
' + return mark_safe(popup_plus) + + +# ============================== +# Defect Dojo Engaegment Surveys +# ============================== + +# List of validator_name:func_name +# Show in admin a multichoice list of validator names +# pass this to form using field_name='validator_name' ? +class QuestionForm(forms.Form): + + """Base class for a Question""" + + def __init__(self, *args, **kwargs): + self.helper = FormHelper() + self.helper.form_method = "post" + + # If true crispy-forms will render a
..
tags + self.helper.form_tag = kwargs.pop("form_tag", True) + + self.engagement_survey = kwargs.get("engagement_survey") + + self.answered_survey = kwargs.get("answered_survey") + if not self.answered_survey: + del kwargs["engagement_survey"] + else: + del kwargs["answered_survey"] + + self.helper.form_class = kwargs.get("form_class", "") + + self.question = kwargs.pop("question", None) + + if not self.question: + msg = "Need a question to render" + raise ValueError(msg) + + super().__init__(*args, **kwargs) + + +class TextQuestionForm(QuestionForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # work out initial data + + initial_answer = TextAnswer.objects.filter( + answered_survey=self.answered_survey, + question=self.question, + ) + + initial_answer = initial_answer[0].answer if initial_answer.exists() else "" + + self.fields["answer"] = forms.CharField( + label=self.question.text, + widget=forms.Textarea(attrs={"rows": 3, "cols": 10}), + required=not self.question.optional, + initial=initial_answer, + ) + + def save(self): + if not self.is_valid(): + msg = "form is not valid" + raise forms.ValidationError(msg) + + answer = self.cleaned_data.get("answer") + + if not answer: + if self.fields["answer"].required: + msg = "Required" + raise forms.ValidationError(msg) + return + + text_answer, created = TextAnswer.objects.get_or_create( + answered_survey=self.answered_survey, + question=self.question, + ) + + if created: + text_answer.answered_survey = self.answered_survey + text_answer.answer = answer + text_answer.save() + + +class ChoiceQuestionForm(QuestionForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + choices = [(c.id, c.label) for c in self.question.choices.all()] + + # initial values + + initial_choices = [] + choice_answer = ChoiceAnswer.objects.filter( + answered_survey=self.answered_survey, + question=self.question, + ).annotate(a=Count("answer")).filter(a__gt=0) + + # we have ChoiceAnswer instance + if choice_answer: + choice_answer = choice_answer[0] + initial_choices = list(choice_answer.answer.all().values_list("id", flat=True)) + if self.question.multichoice is False: + initial_choices = initial_choices[0] + + # default classes + widget = forms.RadioSelect + field_type = forms.ChoiceField + inline_type = InlineRadios + + if self.question.multichoice: + field_type = forms.MultipleChoiceField + widget = forms.CheckboxSelectMultiple + inline_type = InlineCheckboxes + + field = field_type( + label=self.question.text, + required=not self.question.optional, + choices=choices, + initial=initial_choices, + widget=widget, + ) + + self.fields["answer"] = field + + # Render choice buttons inline + self.helper.layout = Layout( + inline_type("answer"), + ) + + def clean_answer(self): + real_answer = self.cleaned_data.get("answer") + + # for single choice questions, the selected answer is a single string + if not isinstance(real_answer, list): + real_answer = [real_answer] + return real_answer + + def save(self): + if not self.is_valid(): + msg = "Form is not valid" + raise forms.ValidationError(msg) + + real_answer = self.cleaned_data.get("answer") + + if not real_answer: + if self.fields["answer"].required: + msg = "Required" + raise forms.ValidationError(msg) + return + + choices = Choice.objects.filter(id__in=real_answer) + + # find ChoiceAnswer and filter in answer ! + choice_answer = ChoiceAnswer.objects.filter( + answered_survey=self.answered_survey, + question=self.question, + ) + + # we have ChoiceAnswer instance + if choice_answer: + choice_answer = choice_answer[0] + + if not choice_answer: + # create a ChoiceAnswer + choice_answer = ChoiceAnswer.objects.create( + answered_survey=self.answered_survey, + question=self.question, + ) + + # re save out the choices + choice_answer.answered_survey = self.answered_survey + choice_answer.answer.set(choices) + choice_answer.save() + + +class Add_Questionnaire_Form(forms.ModelForm): + survey = forms.ModelChoiceField( + queryset=Engagement_Survey.objects.all(), + required=True, + widget=forms.widgets.Select(), + help_text="Select the Questionnaire to add.") + + class Meta: + model = Answered_Survey + exclude = ("responder", + "completed", + "engagement", + "answered_on", + "assignee") + + +class AddGeneralQuestionnaireForm(forms.ModelForm): + survey = forms.ModelChoiceField( + queryset=Engagement_Survey.objects.all(), + required=True, + widget=forms.widgets.Select(), + help_text="Select the Questionnaire to add.") + expiration = forms.DateField(widget=forms.TextInput( + attrs={"class": "datepicker", "autocomplete": "off"})) + + class Meta: + model = General_Survey + exclude = ("num_responses", "generated") + + # date can only be today or in the past, not the future + def clean_expiration(self): + expiration = self.cleaned_data.get("expiration", None) + if expiration: + today = datetime.today().date() + if expiration < today: + msg = "The expiration cannot be in the past" + raise forms.ValidationError(msg) + if expiration == today: + msg = "The expiration cannot be today" + raise forms.ValidationError(msg) + return timezone.make_aware( + datetime.combine(expiration, datetime.min.time()), + ) + msg = "An expiration for the survey must be supplied" + raise forms.ValidationError(msg) + + +class Delete_Questionnaire_Form(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Answered_Survey + fields = ["id"] + + +class DeleteGeneralQuestionnaireForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = General_Survey + fields = ["id"] + + +class Delete_Eng_Survey_Form(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Engagement_Survey + fields = ["id"] + + +class CreateQuestionnaireForm(forms.ModelForm): + class Meta: + model = Engagement_Survey + exclude = ["questions"] + + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class EditQuestionnaireQuestionsForm(forms.ModelForm): + questions = forms.ModelMultipleChoiceField( + Question.polymorphic.all(), + required=True, + help_text="Select questions to include on this questionnaire. Field can be used to search available questions.", + widget=MultipleSelectWithPop(attrs={"size": "11"})) + + class Meta: + model = Engagement_Survey + exclude = ["name", "description", "active"] + + +class CreateQuestionForm(forms.Form): + type = forms.ChoiceField( + choices=(("---", "-----"), ("text", "Text"), ("choice", "Choice"))) + order = forms.IntegerField( + min_value=1, + widget=forms.TextInput(attrs={"data-type": "both"}), + help_text="The order the question will appear on the questionnaire") + optional = forms.BooleanField(help_text="If selected, user doesn't have to answer this question", + initial=False, + required=False, + widget=forms.CheckboxInput(attrs={"data-type": "both"})) + text = forms.CharField(widget=forms.Textarea(attrs={"data-type": "text"}), + label="Question Text", + help_text="The actual question.") + + +class CreateTextQuestionForm(forms.Form): + class Meta: + model = TextQuestion + exclude = ["order", "optional"] + + +class MultiWidgetBasic(forms.widgets.MultiWidget): + def __init__(self, attrs=None): + widgets = [forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"})] + super().__init__(widgets, attrs) + + def decompress(self, value): + if value: + return json.loads(value) + return [None, None, None, None, None, None] + + def format_output(self, rendered_widgets): + return "
".join(rendered_widgets) + + +class MultiExampleField(forms.fields.MultiValueField): + widget = MultiWidgetBasic + + def __init__(self, *args, **kwargs): + list_fields = [forms.fields.CharField(required=True), + forms.fields.CharField(required=True), + forms.fields.CharField(required=False), + forms.fields.CharField(required=False), + forms.fields.CharField(required=False), + forms.fields.CharField(required=False)] + super().__init__(list_fields, *args, **kwargs) + + def compress(self, values): + return json.dumps(values) + + +class CreateChoiceQuestionForm(forms.Form): + multichoice = forms.BooleanField(required=False, + initial=False, + widget=forms.CheckboxInput(attrs={"data-type": "choice"}), + help_text="Can more than one choice can be selected?") + + answer_choices = MultiExampleField(required=False, widget=MultiWidgetBasic(attrs={"data-type": "choice"})) + + class Meta: + model = ChoiceQuestion + exclude = ["order", "optional", "choices"] + + +class EditQuestionForm(forms.ModelForm): + class Meta: + model = Question + exclude = [] + + +class EditTextQuestionForm(EditQuestionForm): + class Meta: + model = TextQuestion + exclude = [] + + +class EditChoiceQuestionForm(EditQuestionForm): + choices = forms.ModelMultipleChoiceField( + Choice.objects.all(), + required=True, + help_text="Select choices to include on this question. Field can be used to search available choices.", + widget=MultipleSelectWithPop(attrs={"size": "11"})) + + class Meta: + model = ChoiceQuestion + exclude = [] + + +class AddChoicesForm(forms.ModelForm): + class Meta: + model = Choice + exclude = [] + + +class AssignUserForm(forms.ModelForm): + assignee = forms.CharField(required=False, + widget=forms.widgets.HiddenInput()) + + def __init__(self, *args, **kwargs): + assignee = None + if "assignee" in kwargs: + assignee = kwargs.pop("asignees") + super().__init__(*args, **kwargs) + if assignee is None: + self.fields["assignee"] = forms.ModelChoiceField(queryset=get_authorized_users("view"), empty_label="Not Assigned", required=False) + else: + self.fields["assignee"].initial = assignee + + class Meta: + model = Answered_Survey + exclude = ["engagement", "survey", "responder", "completed", "answered_on"] diff --git a/dojo/survey/urls.py b/dojo/survey/ui/urls.py similarity index 94% rename from dojo/survey/urls.py rename to dojo/survey/ui/urls.py index a592719aaf2..43865e055a1 100644 --- a/dojo/survey/urls.py +++ b/dojo/survey/ui/urls.py @@ -3,16 +3,9 @@ @author: jay7958 """ -from django.apps import apps -from django.contrib import admin from django.urls import re_path -from dojo.survey import views - -if not apps.ready: - apps.get_models() - -admin.autodiscover() +from dojo.survey.ui import views urlpatterns = [ re_path(r"^questionnaire$", diff --git a/dojo/survey/views.py b/dojo/survey/ui/views.py similarity index 99% rename from dojo/survey/views.py rename to dojo/survey/ui/views.py index b47e6bc3502..866184829c5 100644 --- a/dojo/survey/views.py +++ b/dojo/survey/ui/views.py @@ -18,11 +18,28 @@ user_has_permission, user_has_permission_or_403, ) -from dojo.filters import QuestionFilter, QuestionnaireFilter from dojo.forms import ( + AddEngagementForm, + ExistingEngagementForm, +) +from dojo.models import ( + Engagement, + System_Settings, +) +from dojo.survey.models import ( + Answer, + Answered_Survey, + Choice, + ChoiceQuestion, + Engagement_Survey, + General_Survey, + Question, + TextQuestion, +) +from dojo.survey.ui.filters import QuestionFilter, QuestionnaireFilter +from dojo.survey.ui.forms import ( Add_Questionnaire_Form, AddChoicesForm, - AddEngagementForm, AddGeneralQuestionnaireForm, AssignUserForm, CreateChoiceQuestionForm, @@ -35,19 +52,6 @@ EditChoiceQuestionForm, EditQuestionnaireQuestionsForm, EditTextQuestionForm, - ExistingEngagementForm, -) -from dojo.models import ( - Answer, - Answered_Survey, - Choice, - ChoiceQuestion, - Engagement, - Engagement_Survey, - General_Survey, - Question, - System_Settings, - TextQuestion, ) from dojo.utils import add_breadcrumb, get_page_items, get_setting diff --git a/dojo/urls.py b/dojo/urls.py index 706fe9225f3..28d2427cebd 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -39,7 +39,7 @@ from dojo.asset.api.urls import add_asset_urls from dojo.asset.urls import urlpatterns as asset_urls from dojo.banner.urls import urlpatterns as banner_urls -from dojo.benchmark.urls import urlpatterns as benchmark_urls +from dojo.benchmark.ui.urls import urlpatterns as benchmark_urls from dojo.components.urls import urlpatterns as component_urls from dojo.development_environment.urls import urlpatterns as dev_env_urls from dojo.endpoint.api.urls import add_endpoint_urls, register_endpoint_meta_import @@ -68,7 +68,7 @@ from dojo.reports.urls import urlpatterns as reports_urls from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls -from dojo.survey.urls import urlpatterns as survey_urls +from dojo.survey.ui.urls import urlpatterns as survey_urls from dojo.system_settings.api.urls import add_system_settings_urls from dojo.system_settings.ui.urls import urlpatterns as system_settings_urls from dojo.test.api.urls import add_test_urls diff --git a/unittests/test_survey_forms.py b/unittests/test_survey_forms.py index 9526c44424a..1945f2d2245 100644 --- a/unittests/test_survey_forms.py +++ b/unittests/test_survey_forms.py @@ -1,6 +1,6 @@ import json -from dojo.forms import MultiExampleField, MultiWidgetBasic +from dojo.survey.ui.forms import MultiExampleField, MultiWidgetBasic from unittests.dojo_test_case import DojoTestCase