From f24b57c907999e3ed632167a3fa99d440cdb182c Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 09:09:51 +0100 Subject: [PATCH 1/9] feat: validate survey response answers against JSON Schema Add jsonschema-based validation to SurveyResponse.clean() that verifies the structure of submitted answers matches the survey configuration, catching wrong section/field counts and invalid option values at save time. Co-Authored-By: Claude Sonnet 4.6 --- requirements.txt | 1 + survey/models.py | 38 +++++++++++++++++++ survey/schema.py | 62 ++++++++++++++++++++++++++++++ survey/tests/test_schema.py | 75 +++++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 survey/schema.py create mode 100644 survey/tests/test_schema.py diff --git a/requirements.txt b/requirements.txt index b1e825cd..607e04e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ django-allauth==65.14.1 psycopg[binary]==3.2.* python-dotenv==1.0.1 gunicorn==23.* +jsonschema==4.* strenum==0.4.15 xlsxwriter==3.2.5 sqlparse==0.5.4 diff --git a/survey/models.py b/survey/models.py index bff6ace2..d35d096a 100644 --- a/survey/models.py +++ b/survey/models.py @@ -13,6 +13,7 @@ import xlsxwriter from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.http import HttpRequest from django.urls import reverse @@ -184,6 +185,35 @@ def sections(self) -> tuple[dict]: # survey_config field return tuple(self.survey_config["sections"]) + @property + def response_schema(self) -> dict: + """ + Generate a JSON Schema that validates the structure of response answers. + """ + from survey.schema import field_schema + + section_schemas = [] + for section in self.sections: + fields = section.get("fields", []) + field_schemas = [field_schema(f) for f in fields] + section_schemas.append( + { + "type": "array", + "prefixItems": field_schemas, + "items": False, + "minItems": len(fields), + "maxItems": len(fields), + } + ) + + return { + "type": "array", + "prefixItems": section_schemas, + "items": False, + "minItems": len(section_schemas), + "maxItems": len(section_schemas), + } + @classmethod def _generate_random_field_value(cls, field_config): field_type = field_config["type"] @@ -475,6 +505,14 @@ def clean(self): if not self.survey.is_active: raise ValueError("Cannot submit response to an inactive survey") + # Validate response structure against survey config + import jsonschema + + try: + jsonschema.validate(self.answers, self.survey.response_schema) + except jsonschema.ValidationError as exc: + raise ValidationError(exc.message) from exc + @property def answers_values(self) -> Generator[str, None, None]: """ diff --git a/survey/schema.py b/survey/schema.py new file mode 100644 index 00000000..b8aa7710 --- /dev/null +++ b/survey/schema.py @@ -0,0 +1,62 @@ +""" +Generate JSON Schema from survey configuration for validating response answers. +""" + + +def field_schema(field_config: dict) -> dict: + """Return a JSON Schema for one field's answer value.""" + field_type = field_config["type"] + + if field_type == "likert": + return _likert_schema(field_config) + elif field_type in ("radio", "select"): + return _radio_schema(field_config) + elif field_type == "checkbox": + return _checkbox_schema(field_config) + elif field_type in ("text", "textarea"): + return _text_schema(field_config) + else: + return {} + + +def _likert_schema(field_config: dict) -> dict: + """Likert answer: array of strings from options, one per sublabel.""" + num_sublabels = len(field_config.get("sublabels", [])) + options = field_config.get("options", []) + schema = { + "type": "array", + "items": {"type": "string", "enum": options}, + "minItems": num_sublabels, + "maxItems": num_sublabels, + } + return schema + + +def _radio_schema(field_config: dict) -> dict: + """Radio/select answer: single string from options.""" + options = field_config.get("options", []) + schema = {"type": "string"} + if options: + schema["enum"] = options + if field_config.get("required"): + schema["minLength"] = 1 + return schema + + +def _checkbox_schema(field_config: dict) -> dict: + """Checkbox answer: array of strings from options.""" + options = field_config.get("options", []) + schema = {"type": "array", "items": {"type": "string"}} + if options: + schema["items"]["enum"] = options + if field_config.get("required"): + schema["minItems"] = 1 + return schema + + +def _text_schema(field_config: dict) -> dict: + """Text/textarea answer: string.""" + schema = {"type": "string"} + if field_config.get("required"): + schema["minLength"] = 1 + return schema diff --git a/survey/tests/test_schema.py b/survey/tests/test_schema.py new file mode 100644 index 00000000..2f94165b --- /dev/null +++ b/survey/tests/test_schema.py @@ -0,0 +1,75 @@ +import copy + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from SORT.test.model_factory import SurveyFactory +from survey.models import SurveyResponse + + +class TestResponseSchema(TestCase): + def setUp(self): + self.survey = SurveyFactory() + self.survey.initialise() + self.survey.save() + + def test_schema_generated(self): + schema = self.survey.response_schema + self.assertEqual(schema["type"], "array") + self.assertEqual( + schema["minItems"], len(self.survey.sections) + ) + + def test_valid_mock_response_passes(self): + answers = self.survey._generate_mock_response() + response = SurveyResponse(survey=self.survey, answers=answers) + response.clean() + + def test_wrong_number_of_sections(self): + answers = self.survey._generate_mock_response() + answers.pop() # Remove last section + response = SurveyResponse(survey=self.survey, answers=answers) + with self.assertRaises(ValidationError): + response.clean() + + def test_wrong_number_of_fields(self): + answers = self.survey._generate_mock_response() + answers[0].pop() # Remove last field from first section + response = SurveyResponse(survey=self.survey, answers=answers) + with self.assertRaises(ValidationError): + response.clean() + + def test_extra_section_rejected(self): + answers = self.survey._generate_mock_response() + answers.append(["extra"]) + response = SurveyResponse(survey=self.survey, answers=answers) + with self.assertRaises(ValidationError): + response.clean() + + def test_invalid_likert_value(self): + answers = self.survey._generate_mock_response() + # Find a likert field (first SORT section, first field is likert) + sort_section_index = 1 # After consent + answers[sort_section_index][0] = copy.deepcopy( + answers[sort_section_index][0] + ) + answers[sort_section_index][0][0] = "99" # Invalid value + response = SurveyResponse(survey=self.survey, answers=answers) + with self.assertRaises(ValidationError): + response.clean() + + def test_wrong_likert_sublabel_count(self): + answers = self.survey._generate_mock_response() + sort_section_index = 1 + answers[sort_section_index][0] = ["0"] # Only 1 instead of 22 + response = SurveyResponse(survey=self.survey, answers=answers) + with self.assertRaises(ValidationError): + response.clean() + + def test_required_checkbox_empty(self): + answers = self.survey._generate_mock_response() + # Consent section (index 0) has required checkboxes + answers[0][0] = [] # Empty required checkbox + response = SurveyResponse(survey=self.survey, answers=answers) + with self.assertRaises(ValidationError): + response.clean() From 73856ea1952d6d4aa5c6d416fe790173c70f6129 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 09:20:54 +0100 Subject: [PATCH 2/9] refactor: extract Survey.validate() and add validate_responses management command Moves validation logic into Survey.validate(), adds a management command to bulk-validate all existing responses, and fixes exception type in the command. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 72 +++++++++++++++++++ SORT/test/test_case/view.py | 22 +++--- .../management/commands/validate_responses.py | 23 ++++++ survey/models.py | 22 ++++-- survey/schema.py | 1 + 5 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 Makefile create mode 100644 survey/management/commands/validate_responses.py diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..331e66d1 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +# Custom Django Development Makefile +# This Makefile contains common Django management commands for easier development + +# Variables +PYTHON = python +MANAGE = $(PYTHON) manage.py +PROJECT_NAME = SORT + +# Help command to list all available commands +help: + @echo "Available commands:" + @echo " make runserver - Start Django development server" + @echo " make migrations - Create new database migrations" + @echo " make migrate - Apply database migrations" + @echo " make check - Run Django system checks (including migration check)" + @echo " make superuser - Create a superuser account" + @echo " make static - Collect static files" + @echo " make shell - Open Django shell" + @echo " make test - Run tests" + @echo " make clean - Remove Python compiled files" + @echo " make requirements - Install Python dependencies" + @echo " make lint - Run code linting on project files" + +# Development server +runserver: + $(MANAGE) runserver + +# Database operations +migrations: + $(MANAGE) makemigrations + +migrate: + $(MANAGE) migrate + +# System checks +check: + $(MANAGE) check --fail-level WARNING + $(MANAGE) makemigrations --check --dry-run + +# User management +superuser: + $(MANAGE) createsuperuser + +# Static files +static: + $(MANAGE) collectstatic --noinput + +# Development tools +shell: + $(MANAGE) shell + +test: + $(MANAGE) test + +clean: + find . -type f -name "*.pyc" -delete + find . -type d -name "__pycache__" -delete + +# Dependencies +requirements: + pip install -r requirements.txt + +# Code quality - only check project source files +lint: + flake8 $(PROJECT_NAME) --exclude=migrations,settings.py + black $(PROJECT_NAME) --exclude="migrations|settings.py" + +# Default target when just running 'make' +.DEFAULT_GOAL := help + +# Mark these targets as always needing to run (not files) +.PHONY: help runserver migrations migrate check superuser static shell test clean requirements lint diff --git a/SORT/test/test_case/view.py b/SORT/test/test_case/view.py index bded35ab..7edbfa62 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. diff --git a/survey/management/commands/validate_responses.py b/survey/management/commands/validate_responses.py new file mode 100644 index 00000000..26a55d85 --- /dev/null +++ b/survey/management/commands/validate_responses.py @@ -0,0 +1,23 @@ +from django.core.exceptions import ValidationError +from django.core.management import BaseCommand + +from survey.models import Survey + + +class Command(BaseCommand): + help = "Validate all survey response answers against their survey's JSON Schema" + + def handle(self, *args, **options): + errors = 0 + total = 0 + for survey in Survey.objects.prefetch_related("survey_response").iterator(): + for response in survey.survey_response.all(): + total += 1 + try: + survey.validate(response.answers) + except ValidationError as exc: + errors += 1 + self.stderr.write( + f"Survey {survey.pk} / Response {response.pk}: {exc.message}" + ) + self.stdout.write(f"Validated {total} responses — {errors} error(s)") diff --git a/survey/models.py b/survey/models.py index d35d096a..654092b7 100644 --- a/survey/models.py +++ b/survey/models.py @@ -10,6 +10,7 @@ from typing import Generator, ContextManager from contextlib import contextmanager +import jsonschema import xlsxwriter from django.conf import settings @@ -20,6 +21,7 @@ import django.core.validators from home.models import Project +from survey.schema import field_schema logger = logging.getLogger(__name__) @@ -190,7 +192,6 @@ def response_schema(self) -> dict: """ Generate a JSON Schema that validates the structure of response answers. """ - from survey.schema import field_schema section_schemas = [] for section in self.sections: @@ -418,6 +419,18 @@ def update(self, consent_config: dict, demography_config: dict): + demography_config["sections"] } + def validate(self, answers: list) -> None: + """ + Validate a response answers list against this survey's JSON Schema. + + Raises jsonschema.ValidationError if the answers do not match. + """ + + try: + jsonschema.validate(answers, self.response_schema) + except jsonschema.ValidationError as exc: + raise ValidationError(exc.message) from exc + class SurveyEvidenceSection(models.Model): """ @@ -506,12 +519,7 @@ def clean(self): raise ValueError("Cannot submit response to an inactive survey") # Validate response structure against survey config - import jsonschema - - try: - jsonschema.validate(self.answers, self.survey.response_schema) - except jsonschema.ValidationError as exc: - raise ValidationError(exc.message) from exc + self.survey.validate(self.answers) @property def answers_values(self) -> Generator[str, None, None]: diff --git a/survey/schema.py b/survey/schema.py index b8aa7710..a8668a59 100644 --- a/survey/schema.py +++ b/survey/schema.py @@ -16,6 +16,7 @@ def field_schema(field_config: dict) -> dict: elif field_type in ("text", "textarea"): return _text_schema(field_config) else: + # Empty schema: any value is valid (no constraints imposed) return {} From ab45e2bb9107d13fa8ad9389717954b63c2f6be5 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 10:02:34 +0100 Subject: [PATCH 3/9] refactor: move validate() from Survey to SurveyResponse Validation logic belongs on the model that owns the answers. Also fixes clean() to raise ValidationError (not ValueError) for inactive surveys, makes the test helpers robust against section-order changes, and adds chunk_size to the management command iterator. Co-Authored-By: Claude Sonnet 4.6 --- .../management/commands/validate_responses.py | 4 +-- survey/models.py | 30 +++++++++---------- survey/tests/test_schema.py | 18 ++++++----- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/survey/management/commands/validate_responses.py b/survey/management/commands/validate_responses.py index 26a55d85..4491d0f4 100644 --- a/survey/management/commands/validate_responses.py +++ b/survey/management/commands/validate_responses.py @@ -10,11 +10,11 @@ class Command(BaseCommand): def handle(self, *args, **options): errors = 0 total = 0 - for survey in Survey.objects.prefetch_related("survey_response").iterator(): + for survey in Survey.objects.prefetch_related("survey_response").iterator(chunk_size=100): for response in survey.survey_response.all(): total += 1 try: - survey.validate(response.answers) + response.validate() except ValidationError as exc: errors += 1 self.stderr.write( diff --git a/survey/models.py b/survey/models.py index 654092b7..9f8d86ac 100644 --- a/survey/models.py +++ b/survey/models.py @@ -193,7 +193,7 @@ def response_schema(self) -> dict: Generate a JSON Schema that validates the structure of response answers. """ - section_schemas = [] + section_schemas = list() for section in self.sections: fields = section.get("fields", []) field_schemas = [field_schema(f) for f in fields] @@ -419,18 +419,6 @@ def update(self, consent_config: dict, demography_config: dict): + demography_config["sections"] } - def validate(self, answers: list) -> None: - """ - Validate a response answers list against this survey's JSON Schema. - - Raises jsonschema.ValidationError if the answers do not match. - """ - - try: - jsonschema.validate(answers, self.response_schema) - except jsonschema.ValidationError as exc: - raise ValidationError(exc.message) from exc - class SurveyEvidenceSection(models.Model): """ @@ -511,15 +499,27 @@ def __str__(self): def get_absolute_url(self, token): return reverse("survey", kwargs={"pk": self.survey.pk}) + def validate(self) -> None: + """ + Validate response answers against this survey's JSON Schema. + + Raises django.core.exceptions.ValidationError if the answers do not match. + """ + + try: + jsonschema.validate(self.answers, self.survey.response_schema) + except jsonschema.ValidationError as exc: + raise ValidationError(exc.message) from exc + def clean(self): super().clean() # Paused survey if not self.survey.is_active: - raise ValueError("Cannot submit response to an inactive survey") + raise ValidationError("Cannot submit response to an inactive survey") # Validate response structure against survey config - self.survey.validate(self.answers) + self.validate() @property def answers_values(self) -> Generator[str, None, None]: diff --git a/survey/tests/test_schema.py b/survey/tests/test_schema.py index 2f94165b..2cc06d60 100644 --- a/survey/tests/test_schema.py +++ b/survey/tests/test_schema.py @@ -48,20 +48,24 @@ def test_extra_section_rejected(self): def test_invalid_likert_value(self): answers = self.survey._generate_mock_response() - # Find a likert field (first SORT section, first field is likert) - sort_section_index = 1 # After consent - answers[sort_section_index][0] = copy.deepcopy( - answers[sort_section_index][0] + # Find the first section whose first field is a likert + likert_section_index = next( + i for i, section in enumerate(self.survey.sections) + if section.get("fields") and section["fields"][0]["type"] == "likert" ) - answers[sort_section_index][0][0] = "99" # Invalid value + answers[likert_section_index][0] = copy.deepcopy(answers[likert_section_index][0]) + answers[likert_section_index][0][0] = "99" # Invalid value response = SurveyResponse(survey=self.survey, answers=answers) with self.assertRaises(ValidationError): response.clean() def test_wrong_likert_sublabel_count(self): answers = self.survey._generate_mock_response() - sort_section_index = 1 - answers[sort_section_index][0] = ["0"] # Only 1 instead of 22 + likert_section_index = next( + i for i, section in enumerate(self.survey.sections) + if section.get("fields") and section["fields"][0]["type"] == "likert" + ) + answers[likert_section_index][0] = ["0"] # Only 1 instead of expected count response = SurveyResponse(survey=self.survey, answers=answers) with self.assertRaises(ValidationError): response.clean() From 24b233ad81f12a8355be648db782d6560b13dbd7 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 11:16:37 +0100 Subject: [PATCH 4/9] chore: tweak logging verbosity --- Makefile | 3 ++- SORT/settings.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 331e66d1..158cd005 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,8 @@ shell: $(MANAGE) shell test: - $(MANAGE) test + $(MANAGE) test --parallel=auto --failfast + npm test clean: find . -type f -name "*.pyc" -delete diff --git a/SORT/settings.py b/SORT/settings.py index 8d652df9..4150b2f0 100644 --- a/SORT/settings.py +++ b/SORT/settings.py @@ -280,6 +280,12 @@ def cast_to_boolean(obj: Any) -> bool: "level": "WARNING", "propagate": False, }, + # Suppress 4xx WARNING tracebacks in test output (e.g. expected 403s from permission tests) + "django.request": { + "handlers": ["console"], + "level": "ERROR", + "propagate": False, + }, }, } From 031a75817a23f40e61eec549a085dd81acb6429f Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 11:30:36 +0100 Subject: [PATCH 5/9] test: add unit tests for schema functions and integrate full_clean into submit Co-Authored-By: Claude Sonnet 4.6 --- survey/models.py | 3 +- survey/tests/test_schema.py | 200 ++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/survey/models.py b/survey/models.py index 9f8d86ac..8c46cafb 100644 --- a/survey/models.py +++ b/survey/models.py @@ -274,7 +274,8 @@ def accept_response(self, answers: list): """ Enter a new survey submission. """ - survey_response = SurveyResponse.objects.create(survey=self, answers=answers) + survey_response = SurveyResponse(survey=self, answers=answers) + survey_response.full_clean() survey_response.save() return survey_response diff --git a/survey/tests/test_schema.py b/survey/tests/test_schema.py index 2cc06d60..a548522d 100644 --- a/survey/tests/test_schema.py +++ b/survey/tests/test_schema.py @@ -1,10 +1,18 @@ import copy +import jsonschema from django.core.exceptions import ValidationError from django.test import TestCase from SORT.test.model_factory import SurveyFactory from survey.models import SurveyResponse +from survey.schema import ( + _checkbox_schema, + _likert_schema, + _radio_schema, + _text_schema, + field_schema, +) class TestResponseSchema(TestCase): @@ -77,3 +85,195 @@ def test_required_checkbox_empty(self): response = SurveyResponse(survey=self.survey, answers=answers) with self.assertRaises(ValidationError): response.clean() + + +class TestFieldSchema(TestCase): + """Unit tests for individual schema-generation functions in survey/schema.py.""" + + LIKERT_CONFIG = { + "type": "likert", + "sublabels": ["Q1", "Q2", "Q3"], + "options": ["0", "1", "2", "3", "4"], + } + RADIO_CONFIG = { + "type": "radio", + "options": ["Yes", "No", "Maybe"], + } + CHECKBOX_CONFIG = { + "type": "checkbox", + "options": ["A", "B", "C"], + } + TEXT_CONFIG = {"type": "text"} + + # --- likert --- + + def test_likert_schema_structure(self): + schema = _likert_schema(self.LIKERT_CONFIG) + self.assertEqual(schema["type"], "array") + self.assertEqual(schema["minItems"], 3) + self.assertEqual(schema["maxItems"], 3) + self.assertEqual(schema["items"]["enum"], ["0", "1", "2", "3", "4"]) + + def test_likert_schema_valid_value(self): + schema = _likert_schema(self.LIKERT_CONFIG) + jsonschema.validate(["0", "2", "4"], schema) # Should not raise + + def test_likert_schema_invalid_value(self): + schema = _likert_schema(self.LIKERT_CONFIG) + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate(["0", "99", "4"], schema) + + def test_likert_schema_wrong_length(self): + schema = _likert_schema(self.LIKERT_CONFIG) + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate(["0"], schema) + + # --- radio --- + + def test_radio_schema_structure(self): + schema = _radio_schema(self.RADIO_CONFIG) + self.assertEqual(schema["type"], "string") + self.assertEqual(schema["enum"], ["Yes", "No", "Maybe"]) + + def test_radio_schema_valid_value(self): + schema = _radio_schema(self.RADIO_CONFIG) + jsonschema.validate("Yes", schema) # Should not raise + + def test_radio_schema_invalid_value(self): + schema = _radio_schema(self.RADIO_CONFIG) + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate("Other", schema) + + def test_radio_schema_required_minlength(self): + config = {**self.RADIO_CONFIG, "required": True} + schema = _radio_schema(config) + self.assertEqual(schema["minLength"], 1) + + def test_radio_schema_not_required_no_minlength(self): + schema = _radio_schema(self.RADIO_CONFIG) + self.assertNotIn("minLength", schema) + + # --- select (dispatched same as radio) --- + + def test_select_schema_same_as_radio(self): + radio_config = {**self.RADIO_CONFIG} + select_config = {**self.RADIO_CONFIG, "type": "select"} + self.assertEqual(field_schema(radio_config), field_schema(select_config)) + + # --- checkbox --- + + def test_checkbox_schema_structure(self): + schema = _checkbox_schema(self.CHECKBOX_CONFIG) + self.assertEqual(schema["type"], "array") + self.assertEqual(schema["items"]["enum"], ["A", "B", "C"]) + + def test_checkbox_schema_valid_value(self): + schema = _checkbox_schema(self.CHECKBOX_CONFIG) + jsonschema.validate(["A", "C"], schema) # Should not raise + + def test_checkbox_schema_invalid_value(self): + schema = _checkbox_schema(self.CHECKBOX_CONFIG) + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate(["A", "Z"], schema) + + def test_checkbox_schema_required_min_items(self): + config = {**self.CHECKBOX_CONFIG, "required": True} + schema = _checkbox_schema(config) + self.assertEqual(schema["minItems"], 1) + + def test_checkbox_schema_not_required_no_min_items(self): + schema = _checkbox_schema(self.CHECKBOX_CONFIG) + self.assertNotIn("minItems", schema) + + # --- text / textarea --- + + def test_text_schema_structure(self): + schema = _text_schema(self.TEXT_CONFIG) + self.assertEqual(schema["type"], "string") + + def test_text_schema_required_minlength(self): + config = {**self.TEXT_CONFIG, "required": True} + schema = _text_schema(config) + self.assertEqual(schema["minLength"], 1) + + def test_textarea_schema_same_as_text(self): + text_config = {**self.TEXT_CONFIG} + textarea_config = {**self.TEXT_CONFIG, "type": "textarea"} + self.assertEqual(field_schema(text_config), field_schema(textarea_config)) + + # --- unknown type --- + + def test_unknown_field_type_returns_empty_schema(self): + schema = field_schema({"type": "unknown_widget"}) + self.assertEqual(schema, {}) + + +class TestSurveyResponseValidate(TestCase): + """Integration tests for SurveyResponse.validate() using minimal survey configs.""" + + def _make_survey(self, sections, is_active=True): + survey = SurveyFactory() + survey.survey_config = {"sections": sections} + survey.is_active = is_active + survey.save() + return survey + + def _response(self, survey, answers): + return SurveyResponse(survey=survey, answers=answers) + + # --- radio --- + + def test_radio_valid_answer_passes(self): + survey = self._make_survey([ + {"fields": [{"type": "radio", "options": ["Yes", "No"]}]} + ]) + self._response(survey, [["Yes"]]).validate() # Should not raise + + def test_radio_invalid_answer_raises(self): + survey = self._make_survey([ + {"fields": [{"type": "radio", "options": ["Yes", "No"]}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [["Maybe"]]).validate() + + # --- required text --- + + def test_required_text_empty_raises(self): + survey = self._make_survey([ + {"fields": [{"type": "text", "required": True}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[""]]).validate() + + def test_required_text_valid_passes(self): + survey = self._make_survey([ + {"fields": [{"type": "text", "required": True}]} + ]) + self._response(survey, [["some text"]]).validate() # Should not raise + + # --- checkbox --- + + def test_checkbox_invalid_option_raises(self): + survey = self._make_survey([ + {"fields": [{"type": "checkbox", "options": ["A", "B"]}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[ ["Z"] ]]).validate() + + # --- validate() vs clean() and inactive survey --- + + def test_validate_does_not_check_inactive_survey(self): + survey = self._make_survey( + [{"fields": [{"type": "text"}]}], + is_active=False, + ) + # validate() checks schema only — inactive status is irrelevant + self._response(survey, [["any value"]]).validate() # Should not raise + + def test_clean_rejects_inactive_survey(self): + survey = self._make_survey( + [{"fields": [{"type": "text"}]}], + is_active=False, + ) + with self.assertRaises(ValidationError): + self._response(survey, [["any value"]]).clean() From 52907b1df0b51e03560b5a35a00db439a6ada001 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 11:32:56 +0100 Subject: [PATCH 6/9] chore: exit code on validation error --- survey/management/commands/validate_responses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/survey/management/commands/validate_responses.py b/survey/management/commands/validate_responses.py index 4491d0f4..61990c45 100644 --- a/survey/management/commands/validate_responses.py +++ b/survey/management/commands/validate_responses.py @@ -21,3 +21,5 @@ def handle(self, *args, **options): f"Survey {survey.pk} / Response {response.pk}: {exc.message}" ) self.stdout.write(f"Validated {total} responses — {errors} error(s)") + if errors: + exit(1) From 21cc3c99960b4b95e03a5f84cae3ef433ef5f945 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Tue, 21 Apr 2026 11:42:03 +0100 Subject: [PATCH 7/9] style: code format --- .flake8 | 1 + Makefile | 21 ++++----------------- survey/tests/test_schema.py | 2 +- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/.flake8 b/.flake8 index bb7be0db..7c613f30 100644 --- a/.flake8 +++ b/.flake8 @@ -6,6 +6,7 @@ exclude = migrations, settings.py, venv, + .venv, env, node_modules max-line-length = 120 diff --git a/Makefile b/Makefile index 158cd005..0c89649c 100644 --- a/Makefile +++ b/Makefile @@ -2,25 +2,10 @@ # This Makefile contains common Django management commands for easier development # Variables -PYTHON = python +PYTHON = .venv/bin/python MANAGE = $(PYTHON) manage.py PROJECT_NAME = SORT -# Help command to list all available commands -help: - @echo "Available commands:" - @echo " make runserver - Start Django development server" - @echo " make migrations - Create new database migrations" - @echo " make migrate - Apply database migrations" - @echo " make check - Run Django system checks (including migration check)" - @echo " make superuser - Create a superuser account" - @echo " make static - Collect static files" - @echo " make shell - Open Django shell" - @echo " make test - Run tests" - @echo " make clean - Remove Python compiled files" - @echo " make requirements - Install Python dependencies" - @echo " make lint - Run code linting on project files" - # Development server runserver: $(MANAGE) runserver @@ -63,7 +48,9 @@ requirements: # Code quality - only check project source files lint: - flake8 $(PROJECT_NAME) --exclude=migrations,settings.py + flake8 + +format: black $(PROJECT_NAME) --exclude="migrations|settings.py" # Default target when just running 'make' diff --git a/survey/tests/test_schema.py b/survey/tests/test_schema.py index a548522d..b2f3c178 100644 --- a/survey/tests/test_schema.py +++ b/survey/tests/test_schema.py @@ -258,7 +258,7 @@ def test_checkbox_invalid_option_raises(self): {"fields": [{"type": "checkbox", "options": ["A", "B"]}]} ]) with self.assertRaises(ValidationError): - self._response(survey, [[ ["Z"] ]]).validate() + self._response(survey, [[["Z"]]]).validate() # --- validate() vs clean() and inactive survey --- From 66226aa6a3c8ff6bcb5fe99a616b944a64d4a4c1 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Wed, 22 Apr 2026 14:52:55 +0100 Subject: [PATCH 8/9] style: replace em dash with hyphen in validate_responses output Co-Authored-By: Claude Sonnet 4.6 --- survey/management/commands/validate_responses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/survey/management/commands/validate_responses.py b/survey/management/commands/validate_responses.py index 61990c45..2e68fa71 100644 --- a/survey/management/commands/validate_responses.py +++ b/survey/management/commands/validate_responses.py @@ -20,6 +20,6 @@ def handle(self, *args, **options): self.stderr.write( f"Survey {survey.pk} / Response {response.pk}: {exc.message}" ) - self.stdout.write(f"Validated {total} responses — {errors} error(s)") + self.stdout.write(f"Validated {total} responses - {errors} error(s)") if errors: exit(1) From b368776d19a1596eab89be6e01ae91d03dee5331 Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Wed, 22 Apr 2026 15:01:48 +0100 Subject: [PATCH 9/9] test: extend schema validation tests for all field types Co-Authored-By: Claude Sonnet 4.6 --- survey/tests/test_schema.py | 218 ++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/survey/tests/test_schema.py b/survey/tests/test_schema.py index b2f3c178..4ca333ce 100644 --- a/survey/tests/test_schema.py +++ b/survey/tests/test_schema.py @@ -253,6 +253,45 @@ def test_required_text_valid_passes(self): # --- checkbox --- + def test_radio_wrong_json_type_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "radio", "options": ["Yes", "No"]}]} + ]) + for bad in (["Yes"], 1, None): + with self.subTest(bad=bad): + with self.assertRaises(ValidationError): + self._response(survey, [[bad]]).validate() + + def test_required_radio_empty_string_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "radio", "options": ["Yes", "No"], "required": True}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[""]]).validate() + + def test_radio_without_options_accepts_any_string(self): + survey = self._make_survey([ + {"fields": [{"type": "radio"}]} + ]) + self._response(survey, [["anything"]]).validate() # Should not raise + + # --- select --- + + def test_select_valid_answer_passes(self): + survey = self._make_survey([ + {"fields": [{"type": "select", "options": ["A", "B"]}]} + ]) + self._response(survey, [["A"]]).validate() # Should not raise + + def test_select_invalid_answer_raises(self): + survey = self._make_survey([ + {"fields": [{"type": "select", "options": ["A", "B"]}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [["C"]]).validate() + + # --- checkbox --- + def test_checkbox_invalid_option_raises(self): survey = self._make_survey([ {"fields": [{"type": "checkbox", "options": ["A", "B"]}]} @@ -260,6 +299,185 @@ def test_checkbox_invalid_option_raises(self): with self.assertRaises(ValidationError): self._response(survey, [[["Z"]]]).validate() + def test_checkbox_wrong_json_type_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "checkbox", "options": ["A", "B"]}]} + ]) + for bad in ("A", None): + with self.subTest(bad=bad): + with self.assertRaises(ValidationError): + self._response(survey, [[bad]]).validate() + + def test_checkbox_non_string_item_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "checkbox", "options": ["A", "B"]}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[[1]]]).validate() + + def test_required_checkbox_empty_array_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "checkbox", "options": ["A", "B"], "required": True}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[[]]]).validate() + + def test_optional_checkbox_empty_array_accepted(self): + survey = self._make_survey([ + {"fields": [{"type": "checkbox", "options": ["A", "B"]}]} + ]) + self._response(survey, [[[]]]).validate() # Should not raise + + def test_checkbox_without_options_accepts_any_strings(self): + survey = self._make_survey([ + {"fields": [{"type": "checkbox"}]} + ]) + self._response(survey, [[["anything", "else"]]]).validate() # Should not raise + + # --- text --- + + def test_text_wrong_json_type_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "text"}]} + ]) + for bad in (1, None, []): + with self.subTest(bad=bad): + with self.assertRaises(ValidationError): + self._response(survey, [[bad]]).validate() + + def test_optional_text_empty_string_accepted(self): + survey = self._make_survey([ + {"fields": [{"type": "text"}]} + ]) + self._response(survey, [[""]]).validate() # Should not raise + + def test_text_long_string_accepted(self): + survey = self._make_survey([ + {"fields": [{"type": "text"}]} + ]) + self._response(survey, [["x" * 10_000]]).validate() # Should not raise + + # --- textarea --- + + def test_textarea_valid_answer_passes(self): + survey = self._make_survey([ + {"fields": [{"type": "textarea"}]} + ]) + self._response(survey, [["multi\nline\nvalue"]]).validate() # Should not raise + + def test_required_textarea_empty_string_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "textarea", "required": True}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[""]]).validate() + + def test_textarea_wrong_json_type_rejected(self): + survey = self._make_survey([ + {"fields": [{"type": "textarea"}]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[42]]).validate() + + # --- likert --- + + def test_likert_valid_answer_passes(self): + survey = self._make_survey([ + {"fields": [{ + "type": "likert", + "sublabels": ["Q1", "Q2"], + "options": ["0", "1", "2"], + }]} + ]) + self._response(survey, [[["0", "2"]]]).validate() # Should not raise + + def test_likert_wrong_json_type_rejected(self): + survey = self._make_survey([ + {"fields": [{ + "type": "likert", + "sublabels": ["Q1", "Q2"], + "options": ["0", "1", "2"], + }]} + ]) + for bad in ("0", None, {"0": "1"}): + with self.subTest(bad=bad): + with self.assertRaises(ValidationError): + self._response(survey, [[bad]]).validate() + + def test_likert_non_string_item_rejected(self): + survey = self._make_survey([ + {"fields": [{ + "type": "likert", + "sublabels": ["Q1", "Q2"], + "options": ["0", "1", "2"], + }]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[[0, 1]]]).validate() + + def test_likert_too_many_items_rejected(self): + survey = self._make_survey([ + {"fields": [{ + "type": "likert", + "sublabels": ["Q1", "Q2"], + "options": ["0", "1", "2"], + }]} + ]) + with self.assertRaises(ValidationError): + self._response(survey, [[["0", "1", "2"]]]).validate() + + def test_likert_empty_sublabels_accepts_empty_array(self): + survey = self._make_survey([ + {"fields": [{ + "type": "likert", + "sublabels": [], + "options": ["0", "1"], + }]} + ]) + self._response(survey, [[[]]]).validate() # Should not raise + + # --- unknown / fallback type --- + + def test_unknown_type_accepts_any_value(self): + survey = self._make_survey([ + {"fields": [{"type": "mystery_widget"}]} + ]) + for value in ("string", 42, None, ["x"], {"k": "v"}): + with self.subTest(value=value): + self._response(survey, [[value]]).validate() # Should not raise + + # --- cross-cutting structural --- + + def test_mixed_type_section_valid_answer(self): + survey = self._make_survey([{ + "fields": [ + {"type": "text"}, + {"type": "textarea"}, + {"type": "radio", "options": ["Y", "N"]}, + {"type": "select", "options": ["A", "B"]}, + {"type": "checkbox", "options": ["X", "Z"]}, + {"type": "likert", "sublabels": ["R1", "R2"], "options": ["0", "1"]}, + ], + }]) + self._response( + survey, + [["hello", "multi\nline", "Y", "A", ["X"], ["0", "1"]]], + ).validate() # Should not raise + + def test_validate_reraises_as_django_validation_error(self): + survey = self._make_survey([ + {"fields": [{"type": "radio", "options": ["Yes", "No"]}]} + ]) + response = self._response(survey, [["Maybe"]]) + # Must be Django's ValidationError (not jsonschema's) — the management + # command and model .clean() callers rely on this contract. + with self.assertRaises(ValidationError): + response.validate() + with self.assertRaises(jsonschema.ValidationError): + # Sanity check: the underlying jsonschema call would raise its own + # error class; .validate() must convert it. + jsonschema.validate(response.answers, survey.response_schema) + # --- validate() vs clean() and inactive survey --- def test_validate_does_not_check_inactive_survey(self):