diff --git a/smash/web/forms/study_forms.py b/smash/web/forms/study_forms.py index 76c51a4288da0395d45e580bfa75ad5f689696e1..916f2316a4ff0c3f41a9f75840ea383a4d668bdb 100644 --- a/smash/web/forms/study_forms.py +++ b/smash/web/forms/study_forms.py @@ -1,8 +1,7 @@ import logging -from django.forms import ModelForm - -from web.models import Study, StudyNotificationParameters, StudyColumns +from django.forms import ModelForm, ValidationError +from web.models import Study, StudyNotificationParameters, StudyColumns, StudySubject logger = logging.getLogger(__name__) @@ -12,6 +11,16 @@ class StudyEditForm(ModelForm): def __init__(self, *args, **kwargs): super(StudyEditForm, self).__init__(*args, **kwargs) + def clean_nd_number_study_subject_regex(self): + nd_number_study_subject_regex = self.cleaned_data.get( + 'nd_number_study_subject_regex') + + if StudySubject.check_nd_number_regex(nd_number_study_subject_regex) == False: + raise ValidationError( + 'Please enter a valid nd_number_study_subject_regex regex.') + + return nd_number_study_subject_regex + class Meta: model = Study fields = '__all__' @@ -21,7 +30,8 @@ class StudyEditForm(ModelForm): class StudyNotificationParametersEditForm(ModelForm): def __init__(self, *args, **kwargs): - super(StudyNotificationParametersEditForm, self).__init__(*args, **kwargs) + super(StudyNotificationParametersEditForm, + self).__init__(*args, **kwargs) class Meta: model = StudyNotificationParameters diff --git a/smash/web/forms/study_subject_forms.py b/smash/web/forms/study_subject_forms.py index 44afb25786f47a3d43e6a7f56a9ea1387ccbdab6..76d4d7735119ae75280a3e7a42550d051879c632 100644 --- a/smash/web/forms/study_subject_forms.py +++ b/smash/web/forms/study_subject_forms.py @@ -197,7 +197,7 @@ def validate_subject_nd_number(self, cleaned_data): if self.study.columns.nd_number: nd_number = cleaned_data['nd_number'] if nd_number != "": - if re.match('ND[0-9][0-9][0-9][0-9]', nd_number) is None: + if not self.study.check_nd_number(nd_number): self.add_error('nd_number', "Invalid ND number") else: subjects_from_db = StudySubject.objects.filter(nd_number=nd_number, study=self.study) diff --git a/smash/web/migrations/0119_auto_20181024_1158.py b/smash/web/migrations/0119_auto_20181024_1158.py new file mode 100644 index 0000000000000000000000000000000000000000..cab12fa337487854b82a8cf770efd1cda340dd36 --- /dev/null +++ b/smash/web/migrations/0119_auto_20181024_1158.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-24 11:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0118_voucher_activity_type'), + ] + + operations = [ + migrations.AlterField( + model_name='studyvisitlist', + name='type', + field=models.CharField(choices=[(b'UNFINISHED', b'Unfinished visits'), (b'APPROACHING_WITHOUT_APPOINTMENTS', b'Approaching visits'), (b'APPROACHING_FOR_MAIL_CONTACT', b'Post mail for approaching visits'), (b'GENERIC', b'Generic visit list'), (b'MISSING_APPOINTMENTS', b'Visits with missing appointments'), (b'EXCEEDED_TIME', b'Exceeded visit time')], max_length=50, verbose_name=b'Type of list'), + ), + migrations.AlterField( + model_name='workerstudyrole', + name='role', + field=models.CharField(choices=[(b'DOCTOR', b'Doctor'), (b'NURSE', b'Nurse'), (b'PSYCHOLOGIST', b'Psychologist'), (b'TECHNICIAN', b'Technician'), (b'SECRETARY', b'Secretary'), (b'PROJECT MANAGER', b'Project Manager')], max_length=20, verbose_name=b'Role'), + ), + ] diff --git a/smash/web/migrations/0120_study_nd_number_study_subject_regex.py b/smash/web/migrations/0120_study_nd_number_study_subject_regex.py new file mode 100644 index 0000000000000000000000000000000000000000..9b4146edb8a1bcdfbf19a3144c4e9b932ee0beb6 --- /dev/null +++ b/smash/web/migrations/0120_study_nd_number_study_subject_regex.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-24 12:00 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0119_auto_20181024_1158'), + ] + + operations = [ + migrations.AddField( + model_name='study', + name='nd_number_study_subject_regex', + field=models.CharField(default=b'^ND\\d{4}$', help_text=b'Defines the regex to check the identification number used for each study subject.', max_length=255, verbose_name=b'Study Subject ND Number Regex'), + ), + ] diff --git a/smash/web/migrations/0121_auto_20181024_1859.py b/smash/web/migrations/0121_auto_20181024_1859.py new file mode 100644 index 0000000000000000000000000000000000000000..2e0b4654936fdae97a6ddc06809d3a89918a17d9 --- /dev/null +++ b/smash/web/migrations/0121_auto_20181024_1859.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-24 18:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0120_study_nd_number_study_subject_regex'), + ] + + operations = [ + migrations.AlterField( + model_name='study', + name='nd_number_study_subject_regex', + field=models.CharField(default=b'^ND\\d{4}$', help_text=b'Defines the regex to check the ID used for each study subject. Keep in mind that this regex should be valid for all previous study subjects in the database.', max_length=255, verbose_name=b'Study Subject ND Number Regex'), + ), + ] diff --git a/smash/web/models/study.py b/smash/web/models/study.py index 4501a3be57d712de4cb3f35cf77eae8a9407c67a..831465d00b223c1465a96d11d79a27101c9e31c3 100644 --- a/smash/web/models/study.py +++ b/smash/web/models/study.py @@ -3,13 +3,20 @@ from django.db import models from web.models import StudyColumns, StudyNotificationParameters +import re + class Study(models.Model): + class Meta: app_label = 'web' name = models.CharField(max_length=255, verbose_name='Name') + nd_number_study_subject_regex = models.CharField( + max_length=255, verbose_name='Study Subject ND Number Regex', default=r'^ND\d{4}$', + help_text='Defines the regex to check the ID used for each study subject. Keep in mind that this regex should be valid for all previous study subjects in the database.') + columns = models.OneToOneField( StudyColumns, on_delete=models.CASCADE, @@ -24,8 +31,20 @@ class Study(models.Model): verbose_name="Auto create follow up visit" ) + def check_nd_number(self, nd_number): + regex = re.compile(self.nd_number_study_subject_regex) + return regex.match(nd_number) is not None + def __str__(self): return "%s" % self.name def __unicode__(self): return "%s" % self.name + + @staticmethod + def get_by_id(study_id): + study = Study.objects.filter(id=study_id) + if len(study) > 0: + return study[0] + else: + return None diff --git a/smash/web/models/study_subject.py b/smash/web/models/study_subject.py index 3854138909c993a2dbd98168dffd849b6fa48d97..e62425531401fd2e4808e49797649dbb87625ee4 100644 --- a/smash/web/models/study_subject.py +++ b/smash/web/models/study_subject.py @@ -1,5 +1,6 @@ # coding=utf-8 import logging +import re from django.db import models from web.models import VoucherType, Appointment, Location, Visit @@ -7,7 +8,9 @@ from web.models.constants import BOOL_CHOICES, SUBJECT_TYPE_CHOICES, FILE_STORAG logger = logging.getLogger(__name__) + class StudySubject(models.Model): + class Meta: app_label = 'web' @@ -18,7 +21,8 @@ class StudySubject(models.Model): visit.save() def finish_all_appointments(self): - appointments = Appointment.objects.filter(visit__subject=self, status=Appointment.APPOINTMENT_STATUS_SCHEDULED) + appointments = Appointment.objects.filter( + visit__subject=self, status=Appointment.APPOINTMENT_STATUS_SCHEDULED) for appointment in appointments: appointment.status = Appointment.APPOINTMENT_STATUS_CANCELLED appointment.save() @@ -164,18 +168,28 @@ class StudySubject(models.Model): for part in parts: chunks = part.strip().split('-') if len(chunks) == 2: - letter, number = chunks - tupl = (letter, int(number)) + letter, number = chunks + tupl = (letter, int(number)) else: - logger.warn('There are {} chunks in some parts of this screening_number: |{}|.'.format(len(chunks), self.screening_number)) - tupl = (part.strip(), None) + logger.warn('There are {} chunks in some parts of this screening_number: |{}|.'.format( + len(chunks), self.screening_number)) + tupl = (part.strip(), None) if pattern is not None and pattern in part: matches.append(tupl) else: reminder.append(tupl) return matches + sorted(reminder, reverse=reverse) - + + @staticmethod + def check_nd_number_regex(regex_str): + nd_numbers = StudySubject.objects.all().values_list('nd_number', flat=True) + regex = re.compile(regex_str) + for nd_number in nd_numbers: + if regex.match(nd_number) is None: + return False + return True + def __str__(self): return "%s %s" % (self.subject.first_name, self.subject.last_name) diff --git a/smash/web/templates/study/edit.html b/smash/web/templates/study/edit.html index 54be5ce7bf8131024a775eea19c4ca053ee51e5c..e1e8d594d914cae156ec0bbac6bb0b134b9e488b 100644 --- a/smash/web/templates/study/edit.html +++ b/smash/web/templates/study/edit.html @@ -6,7 +6,12 @@ {{ block.super }} <!-- DataTables --> <link rel="stylesheet" href="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.css' %}"> - + <style type="text/css"> + .tooltip-inner { + max-width: 350px; + width: 350px; + } + </style> {% include "includes/datepicker.css.html" %} {% include "includes/datetimepicker.css.html" %} {% endblock styles %} @@ -45,6 +50,9 @@ <div class="col-md-6 form-group {% if field.errors %}has-error{% endif %}"> <label for="{# TODO #}" class="col-sm-4 control-label"> {{ field.label }} + {% if field.help_text %} + <i class="fa fa-info-circle" aria-hidden="true" data-toggle="tooltip" data-placement="bottom" title="{{field.help_text}}"></i> + {% endif %} </label> <div class="col-sm-8"> diff --git a/smash/web/tests/forms/test_study_forms.py b/smash/web/tests/forms/test_study_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..a150a83c181cee62dcf894648ed7d5bb797a0279 --- /dev/null +++ b/smash/web/tests/forms/test_study_forms.py @@ -0,0 +1,40 @@ +from django.test import TestCase +from django.forms import ValidationError +from web.tests.functions import create_study_subject +from web.forms.study_forms import StudyEditForm +from web.models.study import Study +from web.models.study_subject import StudySubject + +class StudyFormTests(TestCase): + + def test_study_default_regex(self): + # this will add a studysubject with a NDnumber + StudySubject.objects.all().delete() + create_study_subject(nd_number='ND0001') + form = StudyEditForm() + # set default regex + nd_number_study_subject_regex_default = Study._meta.get_field( + 'nd_number_study_subject_regex').get_default() + form.cleaned_data = { + 'nd_number_study_subject_regex': nd_number_study_subject_regex_default} + self.assertTrue(form.clean_nd_number_study_subject_regex() + == nd_number_study_subject_regex_default) + # test wrong regex + form = StudyEditForm() + nd_number_study_subject_regex_default = r'^nd\d{5}$' + form.cleaned_data = { + 'nd_number_study_subject_regex': nd_number_study_subject_regex_default} + self.assertRaises( + ValidationError, form.clean_nd_number_study_subject_regex) + + def test_study_other_regex(self): + StudySubject.objects.all().delete() + # this will add a studysubject with a NDnumber + create_study_subject(nd_number='nd00001') + form = StudyEditForm() + # test new regex + nd_number_study_subject_regex_default = r'^nd\d{5}$' + form.cleaned_data = { + 'nd_number_study_subject_regex': nd_number_study_subject_regex_default} + self.assertTrue(form.clean_nd_number_study_subject_regex() + == nd_number_study_subject_regex_default) diff --git a/smash/web/tests/functions.py b/smash/web/tests/functions.py index 3af08171a7a08d4b512468666240737fd8fd0ef2..db061dd7bbe6a33c4b628c3ffd1cfbc6cb65fb66 100644 --- a/smash/web/tests/functions.py +++ b/smash/web/tests/functions.py @@ -186,16 +186,22 @@ def create_subject(): ) -def create_study_subject(subject_id=1, subject=None): +def create_study_subject(subject_id=1, subject=None, nd_number='ND0001'): if subject is None: subject = create_subject() - return StudySubject.objects.create( + study_subject = StudySubject.objects.create( default_location=get_test_location(), type=SUBJECT_TYPE_CHOICES_CONTROL, screening_number="piotr's number" + str(subject_id), study=get_test_study(), subject=subject ) + if nd_number is not None: + study_subject.nd_number = nd_number + study_subject.save() + + return study_subject + def create_study_subject_with_multiple_screening_numbers(subject_id=1, subject=None): if subject is None: @@ -242,7 +248,8 @@ def create_worker(user=None, with_test_location=False): if with_test_location: worker.locations = [get_test_location()] worker.save() - WorkerStudyRole.objects.create(worker=worker, study_id=GLOBAL_STUDY_ID, role=ROLE_CHOICES_DOCTOR) + WorkerStudyRole.objects.create( + worker=worker, study_id=GLOBAL_STUDY_ID, role=ROLE_CHOICES_DOCTOR) return worker @@ -255,7 +262,8 @@ def create_voucher_partner(): unit="LCSB", phone_number="0123456789" ) - WorkerStudyRole.objects.create(worker=worker, study_id=GLOBAL_STUDY_ID, role=WORKER_VOUCHER_PARTNER) + WorkerStudyRole.objects.create( + worker=worker, study_id=GLOBAL_STUDY_ID, role=WORKER_VOUCHER_PARTNER) return worker @@ -349,10 +357,12 @@ def format_form_field(value): def prepare_test_redcap_connection(): Language.objects.create(name="Finnish").save() Language.objects.create(name="Italian").save() - token_item = ConfigurationItem.objects.filter(type=REDCAP_TOKEN_CONFIGURATION_TYPE)[0] + token_item = ConfigurationItem.objects.filter( + type=REDCAP_TOKEN_CONFIGURATION_TYPE)[0] # noinspection SpellCheckingInspection token_item.value = "5C75EEC3DBDDA5218B6ACC0424B3F695" token_item.save() - url_item = ConfigurationItem.objects.filter(type=REDCAP_BASE_URL_CONFIGURATION_TYPE)[0] + url_item = ConfigurationItem.objects.filter( + type=REDCAP_BASE_URL_CONFIGURATION_TYPE)[0] url_item.value = "https://luxparktest.org/redcap/" url_item.save() diff --git a/smash/web/tests/models/test_study.py b/smash/web/tests/models/test_study.py index 9c9e02f77c08015bd59a4ef19149945caa5a0e6b..c535aa57a01becec96c39fb51ef4da443105de5a 100644 --- a/smash/web/tests/models/test_study.py +++ b/smash/web/tests/models/test_study.py @@ -1,14 +1,22 @@ import logging from django.test import TestCase - -from web.tests.functions import create_study +from django.forms import ValidationError +from web.tests.functions import create_study, create_study_subject +from web.forms.study_forms import StudyEditForm +from web.models.study import Study +from web.models.study_subject import StudySubject logger = logging.getLogger(__name__) class StudyTests(TestCase): + def test_image_img(self): study = create_study() - self.assertTrue(study.name in str(study)) + + def test_check_nd_number(self): + study = create_study() + # check the default regex + self.assertTrue(study.check_nd_number('ND0001')) diff --git a/smash/web/tests/models/test_study_subject.py b/smash/web/tests/models/test_study_subject.py index b70add8cee109783433fdba1e5056bf60b1235cd..f91762f35e0df2c664e7b69b00f0cb40dd45192d 100644 --- a/smash/web/tests/models/test_study_subject.py +++ b/smash/web/tests/models/test_study_subject.py @@ -2,41 +2,73 @@ from django.test import TestCase from web.models import Appointment from web.models import Visit +from web.models import StudySubject, Study from web.tests.functions import create_study_subject, create_appointment, create_study_subject_with_multiple_screening_numbers from web.tests.functions import create_visit class SubjectModelTests(TestCase): + def test_mark_as_resigned(self): subject = create_study_subject() visit = create_visit(subject) appointment = create_appointment(visit) subject.mark_as_resigned() - appointment_status = Appointment.objects.filter(id=appointment.id)[0].status + appointment_status = Appointment.objects.filter(id=appointment.id)[ + 0].status visit_finished = Visit.objects.filter(id=visit.id)[0].is_finished self.assertTrue(subject.resigned) self.assertTrue(visit_finished) - self.assertEquals(Appointment.APPOINTMENT_STATUS_CANCELLED, appointment_status) + self.assertEquals( + Appointment.APPOINTMENT_STATUS_CANCELLED, appointment_status) + + def test_check_nd_number_regex(self): + # delete everything + StudySubject.objects.all().delete() + # get default regex + nd_number_study_subject_regex_default = Study._meta.get_field( + 'nd_number_study_subject_regex').get_default() + self.assertTrue(StudySubject.check_nd_number_regex( + nd_number_study_subject_regex_default)) + # this will add a studysubject with a NDnumber + study_subject = create_study_subject(nd_number='ND0001') + + self.assertTrue(StudySubject.check_nd_number_regex( + nd_number_study_subject_regex_default)) + # delete everything + StudySubject.objects.all().delete() + # this will add a studysubject with a NDnumber + create_study_subject(nd_number='ND00001') + self.assertFalse(StudySubject.check_nd_number_regex( + nd_number_study_subject_regex_default)) def test_sort_matched_screening_first(self): def create_result(phrase, subject_id=1): phrase = phrase.format(subject_id=subject_id) phrase = phrase.split(';') - for i,r in enumerate(phrase): + for i, r in enumerate(phrase): letter, num = phrase[i].strip().split('-') phrase[i] = (letter, int(num)) return phrase - subject = create_study_subject_with_multiple_screening_numbers(subject_id=1) - self.assertEqual(subject.sort_matched_screening_first('L'), create_result('L-00{subject_id}; E-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first('L-00'), create_result('L-00{subject_id}; E-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first('E'), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first('-'), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first(''), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first('001'), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first('00'), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) - self.assertEqual(subject.sort_matched_screening_first('potato'), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) - \ No newline at end of file + subject = create_study_subject_with_multiple_screening_numbers( + subject_id=1) + self.assertEqual(subject.sort_matched_screening_first('L'), create_result( + 'L-00{subject_id}; E-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first( + 'L-00'), create_result('L-00{subject_id}; E-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first('E'), create_result( + 'E-00{subject_id}; L-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first( + '-'), create_result('E-00{subject_id}; L-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first(''), create_result( + 'E-00{subject_id}; L-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first('001'), create_result( + 'E-00{subject_id}; L-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first('00'), create_result( + 'E-00{subject_id}; L-00{subject_id}', subject_id=1)) + self.assertEqual(subject.sort_matched_screening_first('potato'), create_result( + 'E-00{subject_id}; L-00{subject_id}', subject_id=1)) diff --git a/smash/web/tests/view/test_appointments.py b/smash/web/tests/view/test_appointments.py index a1544f213bdcfcb70a699f4b9299c61faa5b069d..8f24b3396fb8f2677b3547e065105cb54e3d6a64 100644 --- a/smash/web/tests/view/test_appointments.py +++ b/smash/web/tests/view/test_appointments.py @@ -102,7 +102,7 @@ class AppointmentsViewTests(LoggedInTestCase): self.assertEqual(Appointment.APPOINTMENT_STATUS_FINISHED, appointment_result.status) def test_save_appointments_edit_with_invalid_nd_number(self): - subject = create_study_subject() + subject = create_study_subject(nd_number=None) visit = create_visit(subject) appointment = create_appointment(visit, get_today_midnight_date()) diff --git a/smash/web/views/appointment.py b/smash/web/views/appointment.py index c4a9ddf1f5a37259e369de156f44b5ea6031d564..ee45e0d173bc980f05d866df56cb70387ae05830 100644 --- a/smash/web/views/appointment.py +++ b/smash/web/views/appointment.py @@ -1,17 +1,18 @@ # coding=utf-8 import logging import re - +import datetime from django.contrib import messages from django.core.exceptions import ValidationError from django.shortcuts import get_object_or_404, redirect +from web.models.constants import GLOBAL_STUDY_ID from web.models.appointment_list import APPOINTMENT_LIST_APPROACHING, APPOINTMENT_LIST_GENERIC, \ APPOINTMENT_LIST_UNFINISHED from . import wrap_response from web.forms import AppointmentDetailForm, AppointmentEditForm, AppointmentAddForm, SubjectEditForm, \ StudySubjectEditForm -from ..models import Appointment, StudySubject, MailTemplate +from ..models import Appointment, StudySubject, MailTemplate, Visit, Study logger = logging.getLogger(__name__) @@ -40,6 +41,13 @@ def appointment_details(request, id): def appointment_add(request, visit_id=None): + if visit_id is not None: + visit = get_object_or_404(Visit, id=visit_id) + visit_start = visit.datetime_begin.strftime("%Y-%m-%d") + visit_end = visit.datetime_end.strftime("%Y-%m-%d") + else: + visit_start = datetime.datetime.today().strftime("%Y-%m-%d") + visit_end = datetime.datetime.today().strftime("%Y-%m-%d") if request.method == 'POST': form = AppointmentAddForm(request.POST, request.FILES, user=request.user) if form.is_valid(): @@ -49,11 +57,16 @@ def appointment_add(request, visit_id=None): return redirect('web.views.appointments') else: return redirect('web.views.visit_details', id=visit_id) + else: + raise ValidationError("Invalid request: Errors: {}. Non field errors: {}".format(form.errors, form.non_field_errors())) + else: form = AppointmentAddForm(user=request.user) return wrap_response(request, 'appointments/add.html', - {'form': form, 'visitID': visit_id, 'full_list': APPOINTMENT_LIST_GENERIC}) + {'form': form, 'visitID': visit_id, 'isGeneral': visit_id is None, + 'visit_start': visit_start, 'visit_end': visit_end, + 'full_list': APPOINTMENT_LIST_GENERIC}) def appointment_edit(request, id): @@ -61,6 +74,7 @@ def appointment_edit(request, id): study_subject_form = None subject_form = None contact_attempts = None + study = Study.get_by_id(GLOBAL_STUDY_ID) if request.method == 'POST': appointment_form = AppointmentEditForm(request.POST, @@ -89,7 +103,7 @@ def appointment_edit(request, id): status=Appointment.APPOINTMENT_STATUS_FINISHED).count() == 0: adjust_date = True if appointment_form.cleaned_data["status"] == Appointment.APPOINTMENT_STATUS_FINISHED: - if re.match('ND[0-9][0-9][0-9][0-9]', study_subject_form.cleaned_data["nd_number"]) is None: + if not study.check_nd_number(study_subject_form.cleaned_data["nd_number"]): study_subject_form.add_error('nd_number', ValidationError("invalid ND number")) is_valid_form = False if is_valid_form: