diff --git a/SORT/settings.py b/SORT/settings.py index d7575c2e..367028cf 100644 --- a/SORT/settings.py +++ b/SORT/settings.py @@ -259,12 +259,19 @@ def cast_to_boolean(obj: Any) -> bool: }, } +# Survey content configuration files SURVEY_TEMPLATE_DIR = BASE_DIR / "data/survey_config" SURVEY_TEMPLATES = { "Nurses": "sort_only_config_nurses.json", "Midwives": "sort_only_config_midwives.json", "NMAHPs": "sort_only_config_nmahps.json", } +DEMOGRAPHY_TEMPLATES = { + "Nurses": "demography_only_config_nurses.json", + "Midwives": "demography_only_config_midwives.json", + "NMAHPs": "demography_only_config_nmahps.json", +} +CONSENT_TEMPLATE = "consent_only_config.json" # Crispy enables Bootstrap styling on Django forms # https://django-crispy-forms.readthedocs.io/en/latest/install.html diff --git a/SORT/test/test_case/view.py b/SORT/test/test_case/view.py index f8f38059..bded35ab 100644 --- a/SORT/test/test_case/view.py +++ b/SORT/test/test_case/view.py @@ -42,11 +42,11 @@ def login_superuser(self): ) def get( - self, - view_name: str, - expected_status_code: int = HTTPStatus.OK, - login: bool = True, - **kwargs + self, + view_name: str, + expected_status_code: int = HTTPStatus.OK, + login: bool = True, + **kwargs ): """ Helper method to make a GET request to one of the views in this app. @@ -63,12 +63,12 @@ def get( return response def post( - self, - view_name: str, - expected_status_code: int = HTTPStatus.OK, - login: bool = True, - data: dict = None, - **kwargs + self, + view_name: str, + expected_status_code: int = HTTPStatus.OK, + login: bool = True, + data: dict = None, + **kwargs ): """ Helper method to make a POST request to one of the views in this app. @@ -83,7 +83,8 @@ def post( if login: self.login() response = self.client.post( - django.urls.reverse(view_name, kwargs=kwargs), data=data + path=django.urls.reverse(view_name, kwargs=kwargs), + data=data, ) self.assertEqual(response.status_code, expected_status_code) return response diff --git a/data/survey_config/demography_only_config_midwives.json b/data/survey_config/demography_only_config_midwives.json new file mode 100644 index 00000000..90689285 --- /dev/null +++ b/data/survey_config/demography_only_config_midwives.json @@ -0,0 +1,170 @@ +{ + "sections": [ + { + "title": "Demographic", + "type": "demographic", + "description": "Please fill in your demographic information", + "fields": [ + { + "type": "text", + "label": "What is your age?", + "required": true, + "sublabels": [], + "options": [], + "enforceValueConstraints": false, + "maxNumChar": 500, + "minNumValue": 0, + "maxNumValue": 100, + "textType": "INTEGER_TEXT", + "disabled": false, + "readOnly": true + }, + { + "type": "radio", + "label": "What is your gender?", + "description": "", + "required": true, + "sublabels": [], + "options": [ + "Male", + "Female", + "Non-binary", + "Prefer not to say" + ], + "disabled": false, + "readOnly": true + }, + { + "type": "text", + "label": "How long have you been working in your current organisation? (Number of years)", + "required": true, + "sublabels": [], + "options": [], + "enforceValueConstraints": false, + "maxNumChar": 500, + "minNumValue": 0, + "maxNumValue": 100, + "textType": "INTEGER_TEXT", + "disabled": false, + "readOnly": true + }, + { + "type": "radio", + "label": "What is your current pay band?", + "required": true, + "sublabels": [], + "options": [ + "Band 2", + "Band 3", + "Band 4", + "Band 5", + "Band 6", + "Band 7", + "Band 8a", + "Band 8b", + "Band 8c", + "Band 8d", + "Band 9" + ], + "disabled": false, + "readOnly": true + }, + { + "type": "radio", + "label": "What is your profession?", + "required": true, + "sublabels": [], + "options": [ + "Registered Midwife", + "Community Midwife", + "Hospital Midwife", + "Specialist Midwife", + "Senior Midwife", + "Lead/Manager Midwife", + "Consultant Midwife", + "Research Midwife", + "Maternity Support Worker", + "Midwifery Educator", + "Other" + ], + "disabled": false, + "readOnly": true + }, + { + "description": "", + "disabled": false, + "enforceValueConstraints": false, + "label": "What is your job title?", + "maxNumChar": 500, + "maxNumValue": 100, + "minNumValue": 0, + "options": [ + ], + "required": true, + "sublabels": [ + ], + "textType": "PLAIN_TEXT", + "type": "text", + "readOnly": true + }, + { + "type": "radio", + "label": "What is your highest level of qualification?", + "required": true, + "sublabels": [], + "options": [ + "No qualification", + "Diploma", + "Degree", + "Masters", + "PhD/Doctorate", + "Other" + ], + "disabled": false, + "readOnly": true + }, + { + "description": "", + "disabled": false, + "enforceValueConstraints": true, + "label": "How many years have you been qualified?", + "maxNumChar": 500, + "maxNumValue": 100, + "minNumValue": 0, + "options": [], + "required": true, + "sublabels": [], + "textType": "INTEGER_TEXT", + "type": "text", + "readOnly": true + }, + { + "type": "radio", + "label": "What is your ethnicity?", + "required": true, + "sublabels": [], + "options": [ + "Asian British - Bangladeshi", + "Asian British - Indian", + "Asian British - Pakistani", + "Asian - Chinese", + "Asian - other", + "Black British - African", + "Black British - Caribbean", + "Black British - other", + "Mixed - Black African and White", + "Mixed - Black Asian and White", + "Mixed - Caribbean and White", + "Mixed - other", + "White - British", + "White - Irish", + "White - Romany", + "Other" + ], + "disabled": false, + "readOnly": true + } + ] + } + ] +} diff --git a/data/survey_config/demography_only_config.json b/data/survey_config/demography_only_config_nmahps.json similarity index 87% rename from data/survey_config/demography_only_config.json rename to data/survey_config/demography_only_config_nmahps.json index 5adb88f1..eabc5295 100644 --- a/data/survey_config/demography_only_config.json +++ b/data/survey_config/demography_only_config_nmahps.json @@ -7,7 +7,7 @@ "fields": [ { "type": "text", - "label": "What is your age", + "label": "What is your age?", "required": true, "sublabels": [], "options": [], @@ -21,7 +21,7 @@ }, { "type": "radio", - "label": "Your Gender", + "label": "What is your gender?", "description": "", "required": true, "sublabels": [], @@ -50,15 +50,20 @@ }, { "type": "radio", - "label": "What is your current Band/Grade", + "label": "What is your current pay band?", "required": true, "sublabels": [], "options": [ + "Band 2", + "Band 3", "Band 4", "Band 5", "Band 6", "Band 7", - "Band 8", + "Band 8a", + "Band 8b", + "Band 8c", + "Band 8d", "Band 9" ], "disabled": false, @@ -66,7 +71,7 @@ }, { "type": "radio", - "label": "Please describe your current role", + "label": "What is your profession?", "required": true, "sublabels": [], "options": [ @@ -95,7 +100,7 @@ "description": "", "disabled": false, "enforceValueConstraints": false, - "label": "Please provide your job title", + "label": "What is your job title?", "maxNumChar": 500, "maxNumValue": 100, "minNumValue": 0, @@ -110,14 +115,16 @@ }, { "type": "radio", - "label": "Please indicate your highest qualification", + "label": "What is your highest level of qualification?", "required": true, "sublabels": [], "options": [ + "No qualification", "Diploma", "Degree", "Masters", - "PhD/Doctorate" + "PhD/Doctorate", + "Other" ], "disabled": false, "readOnly": true @@ -126,7 +133,7 @@ "description": "", "disabled": false, "enforceValueConstraints": true, - "label": "How many years have you been qualified", + "label": "How many years have you been qualified?", "maxNumChar": 500, "maxNumValue": 100, "minNumValue": 0, diff --git a/data/survey_config/demography_only_config_nurses.json b/data/survey_config/demography_only_config_nurses.json new file mode 100644 index 00000000..b94d30e3 --- /dev/null +++ b/data/survey_config/demography_only_config_nurses.json @@ -0,0 +1,165 @@ +{ + "sections": [ + { + "title": "Demographic", + "type": "demographic", + "description": "Please fill in your demographic information", + "fields": [ + { + "type": "text", + "label": "What is your age?", + "required": true, + "sublabels": [], + "options": [], + "enforceValueConstraints": false, + "maxNumChar": 500, + "minNumValue": 0, + "maxNumValue": 100, + "textType": "INTEGER_TEXT", + "disabled": false, + "readOnly": true + }, + { + "type": "radio", + "label": "What is your gender?", + "description": "", + "required": true, + "sublabels": [], + "options": [ + "Male", + "Female", + "Non-binary", + "Prefer not to say" + ], + "disabled": false, + "readOnly": true + }, + { + "type": "text", + "label": "How long have you been working in your current organisation? (Number of years)", + "required": true, + "sublabels": [], + "options": [], + "enforceValueConstraints": false, + "maxNumChar": 500, + "minNumValue": 0, + "maxNumValue": 100, + "textType": "INTEGER_TEXT", + "disabled": false, + "readOnly": true + }, + { + "type": "radio", + "label": "What is your current pay band?", + "required": true, + "sublabels": [], + "options": [ + "Band 2", + "Band 3", + "Band 4", + "Band 5", + "Band 6", + "Band 7", + "Band 8a", + "Band 8b", + "Band 8c", + "Band 8d", + "Band 9" + ], + "disabled": false, + "readOnly": true + }, + { + "type": "radio", + "label": "What is your profession?", + "required": true, + "sublabels": [], + "options": [ + "Registered Staff Nurse", + "Senior Nurse", + "Specialist Nurse", + "Advanced Nurse Practitioner", + "Consultant Nurse", + "Other" + ], + "disabled": false, + "readOnly": true + }, + { + "description": "", + "disabled": false, + "enforceValueConstraints": false, + "label": "What is your job title?", + "maxNumChar": 500, + "maxNumValue": 100, + "minNumValue": 0, + "options": [ + ], + "required": true, + "sublabels": [ + ], + "textType": "PLAIN_TEXT", + "type": "text", + "readOnly": true + }, + { + "type": "radio", + "label": "What is your highest level of qualification?", + "required": true, + "sublabels": [], + "options": [ + "No qualification", + "Diploma", + "Degree", + "Masters", + "PhD/Doctorate", + "Other" + ], + "disabled": false, + "readOnly": true + }, + { + "description": "", + "disabled": false, + "enforceValueConstraints": true, + "label": "How many years have you been qualified?", + "maxNumChar": 500, + "maxNumValue": 100, + "minNumValue": 0, + "options": [], + "required": true, + "sublabels": [], + "textType": "INTEGER_TEXT", + "type": "text", + "readOnly": true + }, + { + "type": "radio", + "label": "What is your ethnicity?", + "required": true, + "sublabels": [], + "options": [ + "Asian British - Bangladeshi", + "Asian British - Indian", + "Asian British - Pakistani", + "Asian - Chinese", + "Asian - other", + "Black British - African", + "Black British - Caribbean", + "Black British - other", + "Mixed - Black African and White", + "Mixed - Black Asian and White", + "Mixed - Caribbean and White", + "Mixed - other", + "White - British", + "White - Irish", + "White - Romany", + "Other" + ], + "disabled": false, + "readOnly": true + } + ] + } + ] +} diff --git a/scripts/test.bat b/scripts/test.bat new file mode 100644 index 00000000..9875e4db --- /dev/null +++ b/scripts/test.bat @@ -0,0 +1,3 @@ +flake8 +python manage.py test home/tests --parallel=auto --failfast +python manage.py test survey/tests --parallel=auto --failfast diff --git a/survey/checks/survey_config.py b/survey/checks/survey_config.py index 22706c84..f9f4985c 100644 --- a/survey/checks/survey_config.py +++ b/survey/checks/survey_config.py @@ -1,14 +1,14 @@ +import itertools from pathlib import Path from django.core.checks import Tags, Error -import django.conf +from django.conf import settings import django.contrib.staticfiles.finders -settings = django.conf.settings - -SURVEY_CONFIG_FILE_PATHS = { - "data/survey_config/consent_only_config.json", - "data/survey_config/demography_only_config.json" -} +FILENAMES = itertools.chain( + settings.SURVEY_TEMPLATES.values(), + settings.DEMOGRAPHY_TEMPLATES.values(), + (settings.CONSENT_TEMPLATE,) +) @django.core.checks.register(Tags.staticfiles) @@ -18,8 +18,9 @@ def check_survey_config(*args, **kwargs) -> list[Error]: """ errors = list() - for path in SURVEY_CONFIG_FILE_PATHS: - path = Path(path).absolute() + # Iterate over survey config files + for filename in FILENAMES: + path = Path(settings.SURVEY_TEMPLATE_DIR).joinpath(filename).absolute() if not path.exists(): errors.append( Error(f"File not found: {path}", hint="Make sure the survey config file is present.") diff --git a/survey/forms.py b/survey/forms.py index e264f3bf..8ce65250 100644 --- a/survey/forms.py +++ b/survey/forms.py @@ -4,6 +4,8 @@ from .validators.email_list_validator import EmailListValidator +from .models import Survey, Profession + class InvitationForm(forms.Form): email = forms.CharField( @@ -75,3 +77,20 @@ def add_fields(self, form, index): ) return formset_factory(BlankDynamicForm, BaseTestFormSet, min_num=1, max_num=1) + + +class SurveyCreateForm(forms.ModelForm): + survey_body_path = forms.ChoiceField( + label="Target audience", + choices=Profession, + help_text="Respondent profession", + widget=forms.Select(attrs={'class': 'form-control'}) # Optional: add CSS classes + ) + + class Meta: + model = Survey + fields = ["name", "description", "survey_body_path"] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + } diff --git a/survey/migrations/0020_alter_survey_project_alter_survey_survey_body_path.py b/survey/migrations/0020_alter_survey_project_alter_survey_survey_body_path.py new file mode 100644 index 00000000..f483a685 --- /dev/null +++ b/survey/migrations/0020_alter_survey_project_alter_survey_survey_body_path.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.4 on 2025-09-15 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("home", "0011_rename_created_on_project_created_at"), + ("survey", "0019_survey_is_active"), + ] + + operations = [ + migrations.AlterField( + model_name="survey", + name="survey_body_path", + field=models.TextField( + choices=[ + ("NMAHPS", "NMAHPs"), + ("NURSES", "Nurses"), + ("WIDMIVES", "Midwives"), + ], + default="NMAHPS", + help_text="Respondent profession", + ), + ), + ] diff --git a/survey/models.py b/survey/models.py index da639e11..4b650f4c 100644 --- a/survey/models.py +++ b/survey/models.py @@ -23,6 +23,15 @@ logger = logging.getLogger(__name__) +class Profession(models.TextChoices): + """ + Respondent job category for the target audience that will complete the survey. + """ + NMAHPS = "NMAHPs", "Nurses, Midwives and Allied Health Professionals (NMAHPs)" + NURSES = "Nurses", "Nurses" + WIDMIVES = "Midwives", "Widwives" + + class Survey(models.Model): """ Represents a survey that will be sent out to a participant @@ -31,11 +40,13 @@ class Survey(models.Model): name = models.CharField(max_length=200) description = models.TextField(blank=True, null=True) survey_config = models.JSONField(null=True) - consent_config = models.JSONField(null=True) - demography_config = models.JSONField(null=True) project = models.ForeignKey(Project, on_delete=models.CASCADE, null=True, related_name="survey") created_at = models.DateTimeField(auto_now_add=True) - survey_body_path = models.TextField(blank=True, null=True) + survey_body_path = models.TextField( + blank=False, null=False, default=Profession.NMAHPS, + help_text="Respondent profession", + choices=Profession + ) is_active = models.BooleanField( default=True, help_text="Are responses being collected?", @@ -49,21 +60,46 @@ def __str__(self): def organisation(self): return self.project.organisation - def initialise(self): + @property + def consent_config_path(self) -> Path: + """The location of the consent configuration file.""" + return Path(settings.SURVEY_TEMPLATE_DIR).joinpath(settings.CONSENT_TEMPLATE) + + @property + def demography_config_filename(self) -> str: """ - Load an "empty" survey configuration + The filename of the demographics questions configuration file for this profession. """ + return settings.DEMOGRAPHY_TEMPLATES[self.survey_body_path] - # Consent questions - with open("data/survey_config/consent_only_config.json") as file: - self.consent_config = json.load(file) + @property + def demography_config_path(self) -> Path: + """ + The location of the demographics questions configuration file for this profession + """ + return Path(settings.SURVEY_TEMPLATE_DIR) / self.demography_config_filename + + @property + def consent_config_default(self) -> dict: + """ + Survey consent question configuration + """ + with self.consent_config_path.open() as file: + return json.load(file) - # Demographics questions - with open("data/survey_config/demography_only_config.json") as file: - self.demography_config = json.load(file) + @property + def demography_config_default(self) -> dict: + """ + The default demographics questions configuration + """ + with self.demography_config_path.open() as file: + return json.load(file) - self.survey_body_path = "Nurses" - self.merge_sections() + def initialise(self): + """ + Set up a new survey, populating the question sections. + """ + self.reset() def get_absolute_url(self): return reverse("survey", kwargs={"pk": self.pk}) @@ -278,27 +314,45 @@ def to_excel(self) -> bytes: @property def template_filename(self) -> str: + """ + The filename of the SORT questions config file for this profession e.g. "sort_only_config_midwives.json" + """ return settings.SURVEY_TEMPLATES[self.survey_body_path] @property def template_path(self) -> Path: + """ + The location of the SORT questions configuration for this profession. + """ return settings.SURVEY_TEMPLATE_DIR.joinpath(self.template_filename) @property - def sort_config(self): + def sort_config(self) -> dict: + """ + The SORT section configuration for this profession. + """ with self.template_path.open() as file: return json.load(file) - def merge_sections(self, body_path: str = None): + def reset(self): """ - Merge all the survey question configuration into the survey_config field. + Reset all the questionnaire sections to their default values. """ - merged_sections = ( - self.consent_config["sections"] - + self.sort_config["sections"] - + self.demography_config["sections"] + self.update( + consent_config=self.consent_config_default, + demography_config=self.demography_config_default, ) - self.survey_config = {"sections": merged_sections} + + def update(self, consent_config: dict, demography_config: dict): + """ + Update the survey question configuration into the survey_config field. + + The consent and demographics fields may be overridden by the user, while the SORT questions are hard-coded. + """ + self.survey_config = { + # Merge sections by concatenating all questions + "sections": consent_config["sections"] + self.sort_config["sections"] + demography_config["sections"] + } class SurveyEvidenceSection(models.Model): diff --git a/survey/services/survey.py b/survey/services/survey.py index 8ddcaf32..810078b2 100644 --- a/survey/services/survey.py +++ b/survey/services/survey.py @@ -92,7 +92,7 @@ def update_consent_demography_config( survey.demography_config = demography_config survey.survey_body_path = survey_body_path - survey.merge_sections() + survey.update(consent_config=consent_config, demography_config=demography_config) survey.save() @@ -111,8 +111,8 @@ def duplicate_survey(self, user: User, survey: Survey): self.update_consent_demography_config( user, new_survey, - consent_config=survey.consent_config, - demography_config=survey.demography_config, + consent_config=survey.consent_config_default, + demography_config=survey.demography_config_default, survey_body_path=survey.survey_body_path, ) diff --git a/survey/templates/survey/create.html b/survey/templates/survey/create.html index 0db8e9b8..a69c7f1d 100644 --- a/survey/templates/survey/create.html +++ b/survey/templates/survey/create.html @@ -1,36 +1,39 @@ {% extends "base_manager.html" %} {% block content %} -
Use the form below to create a new survey.
-Survey description:
+Respondent profession: {{ survey.survey_body_path }}
+Description:
{{ survey.description }}
Created on: {{ survey.created_at }}
diff --git a/survey/templates/survey/survey_configure.html b/survey/templates/survey/survey_configure.html index 4b38e43b..f4b9ee7a 100644 --- a/survey/templates/survey/survey_configure.html +++ b/survey/templates/survey/survey_configure.html @@ -35,8 +35,8 @@