diff --git a/CHANGELOG b/CHANGELOG index 5de78527ff78208648d5195df7235c19f91ad0ba..88dca169f76a71f2a612d3afaacf89b134e9dd01 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ smasch (1.0.0~alpha.1-0) unstable; urgency=low + * improvement: study subject can be configured to contain custom fields + (#339) * small improvement: django command for creating admin in application (#347) * small improvement: django admin panel contains usable data tables (#346) * small improvement: possibility to unfinish visit (#351) diff --git a/debian-files/smasch.py b/debian-files/smasch.py index 357f4639499f4143ff9a72365c523bb38a4159ec..145ee8a0e23eb346738f4264d152675d2ea8fa32 100644 --- a/debian-files/smasch.py +++ b/debian-files/smasch.py @@ -16,6 +16,7 @@ DATABASES = { STATIC_ROOT = '/usr/lib/smasch/data/static' MEDIA_ROOT = '/usr/lib/smasch/data/media' +UPLOAD_ROOT = '/usr/lib/smasch/data/upload' ALLOWED_HOSTS = ["127.0.0.1", "localhost"] diff --git a/requirements-dev.txt b/requirements-dev.txt index d3241ad8559f7901ec9798eab57ec077aaca301b..8bfe3d4fe71b7aa07a14da2e5763ff2ca97750fc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ coverage==5.3 django-debug-toolbar==1.11 mockito==1.2.2 +parameterized==0.7.4 diff --git a/smash/web/api_urls.py b/smash/web/api_urls.py index 7dae498a26d5e610164cf4a544429a2f9dde03a7..11a02b7b4c553ecb41c5a1272502ab437bf93e3d 100644 --- a/smash/web/api_urls.py +++ b/smash/web/api_urls.py @@ -37,7 +37,7 @@ urlpatterns = [ url(r'^referrals$', subject.referrals, name='web.api.referrals'), url(r'^export_log$', provenance.ExportLog.as_view(), name='web.api.export_log'), url(r'^provenances$', provenance.CompleteLog.as_view(), name='web.api.provenances'), - url(r'^subjects/(?P<type>[A-z]+)$', subject.subjects, name='web.api.subjects'), + url(r'^subjects/(?P<subject_list_type>[A-z]+)$', subject.subjects, name='web.api.subjects'), url(r'^subjects:columns/(?P<subject_list_type>[A-z]+)$', subject.get_subject_columns, name='web.api.subjects.columns'), url(r'^subject_types', subject.types, name='web.api.subject_types'), diff --git a/smash/web/api_views/serialization_utils.py b/smash/web/api_views/serialization_utils.py index 5cf59a19882a3472600b97c581a686e48bc99ac8..0994996d0d71d93e41f7ad2361454fdc85708c09 100644 --- a/smash/web/api_views/serialization_utils.py +++ b/smash/web/api_views/serialization_utils.py @@ -3,14 +3,14 @@ import logging logger = logging.getLogger(__name__) -def bool_to_yes_no(val): +def bool_to_yes_no(val: bool): if val: return "YES" else: return "NO" -def bool_to_yes_no_null(val): +def bool_to_yes_no_null(val: bool): if val is None: return "N/A" if val: @@ -19,6 +19,21 @@ def bool_to_yes_no_null(val): return "NO" +def str_to_yes_no(val: str): + if val.lower() == 'true': + return "YES" + else: + return "NO" + +def str_to_yes_no_null(val: str): + if val is None: + return None + if val.lower() == 'true': + return "YES" + else: + return "NO" + + def virus_test_to_str(test, date): if test is None and date is not None: return "Inconclusive" @@ -60,7 +75,7 @@ def serialize_datetime(date): return result -def add_column(result, name, field_name, column_list, param, columns_used_in_study=None, visible_param=None, +def add_column(result, name, field_name, column_list, param_filter, columns_used_in_study=None, visible_param=None, sortable=True, add_param=True): add = add_param if columns_used_in_study: @@ -75,7 +90,7 @@ def add_column(result, name, field_name, column_list, param, columns_used_in_stu result.append({ "type": field_name, "name": name, - "filter": param, + "filter": param_filter, "visible": visible, "sortable": sortable }) diff --git a/smash/web/api_views/subject.py b/smash/web/api_views/subject.py index 946001644eefa285c19517d3ce72b04d915b8407..7e3ce4e70a1baa14c8471139864cc64fc27c50de 100644 --- a/smash/web/api_views/subject.py +++ b/smash/web/api_views/subject.py @@ -1,17 +1,20 @@ import logging import re +from distutils.util import strtobool -from django.db.models import Count, Case, When, Min, Max +from django.db.models import Count, Case, When, Min, Max, QuerySet from django.db.models import Q from django.http import JsonResponse from django.urls import reverse -from web.models import ConfigurationItem -from distutils.util import strtobool -from web.api_views.serialization_utils import bool_to_yes_no, flying_team_to_str, location_to_str, add_column, \ - serialize_date, serialize_datetime, get_filters_for_data_table_request, virus_test_to_str -from web.models import StudySubject, Visit, Appointment, Subject, SubjectColumns, StudyColumns, Study, ContactAttempt -from web.models.constants import SUBJECT_TYPE_CHOICES, GLOBAL_STUDY_ID, VISIT_SHOW_VISIT_NUMBER_FROM_ZERO +from web.api_views.serialization_utils import str_to_yes_no_null, bool_to_yes_no, flying_team_to_str, location_to_str, add_column, \ + serialize_date, serialize_datetime, get_filters_for_data_table_request, virus_test_to_str, str_to_yes_no +from web.models import ConfigurationItem, StudySubject, Visit, Appointment, Subject, SubjectColumns, StudyColumns, \ + Study, ContactAttempt +from web.models.constants import SUBJECT_TYPE_CHOICES, GLOBAL_STUDY_ID, VISIT_SHOW_VISIT_NUMBER_FROM_ZERO, \ + CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, CUSTOM_FIELD_TYPE_DOUBLE, \ + CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id, CustomStudySubjectField from web.models.study_subject_list import SUBJECT_LIST_GENERIC, SUBJECT_LIST_NO_VISIT, SUBJECT_LIST_REQUIRE_CONTACT, \ StudySubjectList, SUBJECT_LIST_VOUCHER_EXPIRY from web.views import e500_error @@ -37,6 +40,7 @@ def referrals(request): }) +# noinspection PyUnusedLocal def get_subject_columns(request, subject_list_type): study = Study.objects.filter(id=GLOBAL_STUDY_ID)[0] study_subject_lists = StudySubjectList.objects.filter(study=study, type=subject_list_type) @@ -118,6 +122,61 @@ def get_subject_columns(request, subject_list_type): 'serology_filter', study.columns) add_column(result, "Type", "type", study_subject_columns, "type_filter", study.columns) + for custom_study_subject_field in study.customstudysubjectfield_set.all(): + if custom_study_subject_field.type == CUSTOM_FIELD_TYPE_TEXT: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + "string_filter", + visible_param=False) + elif custom_study_subject_field.type == CUSTOM_FIELD_TYPE_BOOLEAN: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + "yes_no_filter", + visible_param=False) + elif custom_study_subject_field.type == CUSTOM_FIELD_TYPE_INTEGER: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + None, + sortable=False, + visible_param=False) + elif custom_study_subject_field.type == CUSTOM_FIELD_TYPE_DOUBLE: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + None, + sortable=False, + visible_param=False) + elif custom_study_subject_field.type == CUSTOM_FIELD_TYPE_DATE: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + None, + visible_param=False) + elif custom_study_subject_field.type == CUSTOM_FIELD_TYPE_SELECT_LIST: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + 'select_filter:' + custom_study_subject_field.possible_values, + visible_param=False) + elif custom_study_subject_field.type == CUSTOM_FIELD_TYPE_FILE: + add_column(result, + custom_study_subject_field.name, + get_study_subject_field_id(custom_study_subject_field), + study_subject_columns, + 'select_filter:N/A;Available', + visible_param=False) + else: + raise NotImplementedError + add_column(result, "Edit", "edit", None, None, sortable=False) for one_based_idx, visit_number in enumerate(visit_numbers, 1): @@ -128,17 +187,17 @@ def get_subject_columns(request, subject_list_type): return JsonResponse({"columns": result}) -def get_subjects(request, type): - if type == SUBJECT_LIST_GENERIC: +def get_subjects(request, list_type): + if list_type == SUBJECT_LIST_GENERIC: return StudySubject.objects.all() - elif type == SUBJECT_LIST_NO_VISIT: + elif list_type == SUBJECT_LIST_NO_VISIT: return get_subjects_with_no_visit(request.user) - elif type == SUBJECT_LIST_REQUIRE_CONTACT: + elif list_type == SUBJECT_LIST_REQUIRE_CONTACT: return get_subjects_with_reminder(request.user) - elif type == SUBJECT_LIST_VOUCHER_EXPIRY: + elif list_type == SUBJECT_LIST_VOUCHER_EXPIRY: return get_subjects_with_almost_expired_vouchers(request.user) else: - raise TypeError("Unknown query type: " + type) + raise TypeError("Unknown query type: " + list_type) def order_by_visit(subjects_to_be_ordered, order_direction, visit_number): @@ -147,14 +206,16 @@ def order_by_visit(subjects_to_be_ordered, order_direction, visit_number): order_direction + 'sort_visit_date') -def get_subjects_order(subjects_to_be_ordered, order_column, order_direction, column_filters={}): +def get_subjects_order(subjects_to_be_ordered: QuerySet, order_column, order_direction, column_filters=None): + if column_filters is None: + column_filters = {} result = subjects_to_be_ordered if order_direction == "asc": order_direction = "" else: order_direction = "-" if order_column is None: - logger.warn("Column cannot be null") + logger.warning("Column cannot be null") elif order_column == "first_name": result = subjects_to_be_ordered.order_by(order_direction + 'subject__first_name') elif order_column == "last_name": @@ -228,8 +289,14 @@ def get_subjects_order(subjects_to_be_ordered, order_column, order_direction, co result = subjects_to_be_ordered.order_by(order_direction + order_column) elif re.search(r'^virus_test_[1-5]_igg_status', order_column): result = subjects_to_be_ordered.order_by(order_direction + order_column) + elif re.search(r'^custom_field-[0-9]+$', order_column): + field_id = int(order_column.replace("custom_field-", "")) + result = subjects_to_be_ordered.annotate( + custom_field_value=Min(Case(When(customstudysubjectvalue__study_subject_field__id=field_id, + then='customstudysubjectvalue__value')))).order_by( + order_direction + 'custom_field_value') else: - logger.warn("Unknown sort column: " + str(order_column)) + logger.warning("Unknown sort column: " + str(order_column)) return result @@ -298,13 +365,13 @@ def filter_by_visit(result, visit_number, visit_type): return result -def get_subjects_filtered(subjects_to_be_filtered, filters): +def get_subjects_filtered(subjects_to_be_filtered: QuerySet, filters) -> QuerySet: result = subjects_to_be_filtered for row in filters: column = row[0] value = row[1] if column is None: - logger.warn("Filter column cannot be null") + logger.warning("Filter column cannot be null") elif column == "first_name": result = result.filter(subject__first_name__icontains=value) elif column == "last_name": @@ -371,6 +438,33 @@ def get_subjects_filtered(subjects_to_be_filtered, filters): elif str(column).startswith("visit_"): visit_number = get_visit_number_from_visit_x_string(column) result = filter_by_visit(result, visit_number, value) + elif re.search(r'^custom_field-[0-9]+$', column): + field_id = int(column.replace("custom_field-", "")) + field = CustomStudySubjectField.objects.get(pk=field_id) + if field.type == CUSTOM_FIELD_TYPE_TEXT: + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value__icontains=value) + elif field.type == CUSTOM_FIELD_TYPE_BOOLEAN: + if value.lower() == 'true' or value.lower() == 'false': + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value__icontains=value) + else: + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value='') + elif field.type == CUSTOM_FIELD_TYPE_INTEGER or field.type == CUSTOM_FIELD_TYPE_DOUBLE: + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value=value) + elif field.type == CUSTOM_FIELD_TYPE_DATE: + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value=value) + elif field.type == CUSTOM_FIELD_TYPE_INTEGER or field.type == CUSTOM_FIELD_TYPE_SELECT_LIST: + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value=value) + elif field.type == CUSTOM_FIELD_TYPE_FILE: + result = result.filter(customstudysubjectvalue__study_subject_field__id=field_id, + customstudysubjectvalue__value__isnull=(value == 'N/A')) + else: + raise NotImplementedError elif column == "": pass else: @@ -379,11 +473,11 @@ def get_subjects_filtered(subjects_to_be_filtered, filters): message += "[None]" else: message += str(column) - logger.warn(message) + logger.warning(message) return result -def subjects(request, type): +def subjects(request, subject_list_type): try: # id of the query from dataTable: https://datatables.net/manual/server-side draw = int(request.GET.get("draw", "-1")) @@ -396,7 +490,7 @@ def subjects(request, type): filters = get_filters_for_data_table_request(request) - all_subjects = get_subjects(request, type) + all_subjects = get_subjects(request, subject_list_type) count = all_subjects.count() @@ -532,4 +626,28 @@ def serialize_subject(study_subject): "virus_test_{}_collection_date".format(i)) result["virus_test_{}_iga_status".format(i)] = getattr(study_subject, "virus_test_{}_iga_status".format(i)) result["virus_test_{}_igg_status".format(i)] = getattr(study_subject, "virus_test_{}_igg_status".format(i)) + + for field_value in study_subject.custom_data_values: + if field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_TEXT \ + or field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_INTEGER \ + or field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_DOUBLE: + val = field_value.value + if val is None: + val = '' + result[get_study_subject_field_id(field_value.study_subject_field)] = val + elif field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_BOOLEAN: + result[get_study_subject_field_id(field_value.study_subject_field)] = str_to_yes_no_null(field_value.value) + elif field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_DATE: + result[get_study_subject_field_id(field_value.study_subject_field)] = field_value.value + elif field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_SELECT_LIST: + result[get_study_subject_field_id(field_value.study_subject_field)] = field_value.value + elif field_value.study_subject_field.type == CUSTOM_FIELD_TYPE_FILE: + if field_value.value is None: + result[get_study_subject_field_id(field_value.study_subject_field)] = '' + else: + result[get_study_subject_field_id(field_value.study_subject_field)] = reverse( + 'web.views.uploaded_files') + '?file=' + str(field_value.value) + else: + raise NotImplementedError + return result diff --git a/smash/web/forms/custom_study_subject_field_forms.py b/smash/web/forms/custom_study_subject_field_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..a986a466a46ea78785d57f3306c695d781e02887 --- /dev/null +++ b/smash/web/forms/custom_study_subject_field_forms.py @@ -0,0 +1,72 @@ +import re + +from django import forms + +from web.models.constants import CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, CUSTOM_FIELD_TYPE_DOUBLE, \ + CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField + + +class CustomStudySubjectFieldForm(forms.ModelForm): + possible_values = forms.CharField(label='Possible values', + widget=forms.TextInput(attrs={'placeholder': "values separated by ';'"}), + required=False) + + def __init__(self, *args, **kwargs): + super(CustomStudySubjectFieldForm, self).__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super(CustomStudySubjectFieldForm, self).clean() + + if cleaned_data['default_value'] != '' \ + and cleaned_data['default_value'] is not None: + if cleaned_data['type'] == CUSTOM_FIELD_TYPE_BOOLEAN \ + and cleaned_data['default_value'].lower() != 'false' \ + and cleaned_data['default_value'].lower() != 'true': + self.add_error('default_value', + "Default value can be one of the following: 'true', 'false', ''.") + elif cleaned_data['type'] == CUSTOM_FIELD_TYPE_INTEGER \ + and re.match(r"[-+]?\d+$", cleaned_data['default_value']) is None: + self.add_error('default_value', + "Default value must be integer (or empty).") + elif cleaned_data['type'] == CUSTOM_FIELD_TYPE_DOUBLE \ + and re.match(r"[-+]?\d+?\.\d+?$", cleaned_data['default_value']) is None: + self.add_error('default_value', + "Default value must be numeric (or empty).") + elif cleaned_data['type'] == CUSTOM_FIELD_TYPE_DATE \ + and re.match(r"\d\d\d\d-\d\d-\d\d?$", cleaned_data['default_value']) is None: + self.add_error('default_value', + "Default value must be in format YYYY-MM-DD (or empty).") + elif cleaned_data['type'] == CUSTOM_FIELD_TYPE_SELECT_LIST \ + and not cleaned_data['default_value'] in cleaned_data['possible_values'].split(';'): + self.add_error('default_value', + "Default value must be listed in possible values (semicolon separated list)") + elif cleaned_data['type'] == CUSTOM_FIELD_TYPE_FILE \ + and cleaned_data['default_value'] is not None \ + and cleaned_data['default_value'] != '': + self.add_error('default_value', + "Default value is not available for files") + return cleaned_data + + +class CustomStudySubjectFieldAddForm(CustomStudySubjectFieldForm): + class Meta: + model = CustomStudySubjectField + fields = '__all__' + + def __init__(self, *args, **kwargs): + self.study = kwargs.pop('study', None) + super(CustomStudySubjectFieldForm, self).__init__(*args, **kwargs) + + def save(self, commit=True) -> CustomStudySubjectField: + self.instance.study_id = self.study.id + return super(CustomStudySubjectFieldAddForm, self).save(commit) + + +class CustomStudySubjectFieldEditForm(CustomStudySubjectFieldForm): + class Meta: + model = CustomStudySubjectField + fields = '__all__' + + def __init__(self, *args, **kwargs): + super(CustomStudySubjectFieldForm, self).__init__(*args, **kwargs) diff --git a/smash/web/forms/study_subject_forms.py b/smash/web/forms/study_subject_forms.py index bce5929d844935d572b90776d8ae5d28d8212e69..6211544bd6817c549a35561bb9ce1e60786a3b86 100644 --- a/smash/web/forms/study_subject_forms.py +++ b/smash/web/forms/study_subject_forms.py @@ -1,19 +1,101 @@ +import datetime import logging - +import re from distutils.util import strtobool + from django import forms from django.forms import ModelForm -from web.forms.forms import DATETIMEPICKER_DATE_ATTRS, get_worker_from_args -from web.models import ConfigurationItem -from web.models import StudySubject, Study, StudyColumns, VoucherType, Worker -from web.models.constants import SCREENING_NUMBER_PREFIXES_FOR_TYPE, VISIT_SHOW_VISIT_NUMBER_FROM_ZERO +from web.forms.forms import DATETIMEPICKER_DATE_ATTRS, get_worker_from_args, DATEPICKER_DATE_ATTRS +from web.models import ConfigurationItem, StudySubject, Study, StudyColumns, VoucherType, Worker +from web.models.constants import SCREENING_NUMBER_PREFIXES_FOR_TYPE, VISIT_SHOW_VISIT_NUMBER_FROM_ZERO, \ + CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, CUSTOM_FIELD_TYPE_DOUBLE, \ + CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField, CustomStudySubjectValue +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id from web.models.worker_study_role import WORKER_HEALTH_PARTNER from web.widgets.secure_file_widget import SecuredFileWidget logger = logging.getLogger(__name__) +def get_custom_select_choices(possible_values): + result = list() + index = 1 + result.append(('0', '---')) + for value in possible_values.split(";"): + result.append((str(index), value)) + index += 1 + return result + + +def create_field_for_custom_study_subject_field(study_subject_field: CustomStudySubjectField, + field_value: CustomStudySubjectValue = None) -> forms.Field: + val = study_subject_field.default_value + if field_value is not None: + val = field_value.value + if study_subject_field.type == CUSTOM_FIELD_TYPE_TEXT: + field = forms.CharField(label=study_subject_field.name, initial=val, + required=study_subject_field.required, disabled=study_subject_field.readonly) + elif study_subject_field.type == CUSTOM_FIELD_TYPE_BOOLEAN: + initial = False + if val is not None and val.lower() == 'true': + initial = True + field = forms.BooleanField(label=study_subject_field.name, initial=initial, + required=study_subject_field.required, disabled=study_subject_field.readonly) + elif study_subject_field.type == CUSTOM_FIELD_TYPE_INTEGER: + initial = None + if val is not None and re.match(r"[-+]?\d+$", val) is not None: + initial = int(val) + field = forms.IntegerField(label=study_subject_field.name, initial=initial, + required=study_subject_field.required, disabled=study_subject_field.readonly) + elif study_subject_field.type == CUSTOM_FIELD_TYPE_DOUBLE: + initial = None + if val is not None and re.match(r"[-+]?\d+?\.\d+?$", val) is not None: + initial = float(val) + field = forms.FloatField(label=study_subject_field.name, initial=initial, + required=study_subject_field.required, disabled=study_subject_field.readonly) + elif study_subject_field.type == CUSTOM_FIELD_TYPE_DATE: + initial = None + if val is not None and val != '': + initial = datetime.datetime.strptime(val, '%Y-%m-%d') + field = forms.DateTimeField(label=study_subject_field.name, initial=initial, + required=study_subject_field.required, disabled=study_subject_field.readonly, + widget=forms.DateInput(DATEPICKER_DATE_ATTRS, "%Y-%m-%d")) + elif study_subject_field.type == CUSTOM_FIELD_TYPE_SELECT_LIST: + initial = '0' + for v, k in get_custom_select_choices(study_subject_field.possible_values): + if k == val: + initial = v + field = forms.ChoiceField(label=study_subject_field.name, initial=initial, + required=study_subject_field.required, disabled=study_subject_field.readonly, + choices=get_custom_select_choices(study_subject_field.possible_values)) + elif study_subject_field.type == CUSTOM_FIELD_TYPE_FILE: + initial = None + if val is not None: + class CustomFileField: + url = '' + + def __str__(self): + return "%s" % self.url + + def __unicode__(self): + return "%s" % self.url + initial = CustomFileField() + initial.url = val + + field = forms.FileField( + label=study_subject_field.name, + required=study_subject_field.required, + disabled=study_subject_field.readonly, + initial=initial, + widget=SecuredFileWidget(attrs={'multiple': True}) + ) + else: + raise NotImplementedError + return field + + class StudySubjectForm(ModelForm): datetime_contact_reminder = forms.DateTimeField(label="Contact on", widget=forms.DateTimeInput(DATETIMEPICKER_DATE_ATTRS), @@ -24,6 +106,16 @@ class StudySubjectForm(ModelForm): def __init__(self, *args, **kwargs): super(StudySubjectForm, self).__init__(*args, **kwargs) + instance = kwargs.get('instance') + if instance: + for value in instance.custom_data_values: + field_id = get_study_subject_field_id(value.study_subject_field) + self.fields[field_id] = create_field_for_custom_study_subject_field(value.study_subject_field, value) + else: + for field_type in CustomStudySubjectField.objects.filter(study=self.study): + field_id = get_study_subject_field_id(field_type) + self.fields[field_id] = create_field_for_custom_study_subject_field(field_type) + self.fields['health_partner'].queryset = Worker.get_workers_by_worker_type( WORKER_HEALTH_PARTNER) @@ -62,6 +154,25 @@ class StudySubjectForm(ModelForm): if test_result is None and test_date is not None: self.initial[test_result_column_name] = "Inc" + def clean(self): + cleaned_data = super(StudySubjectForm, self).clean() + subject_id = -1 + if getattr(self, 'instance', None) is not None: + subject_id = getattr(self, 'instance', None).id + for field_type in CustomStudySubjectField.objects.filter(study=self.study): + if field_type.unique: + field_id = get_study_subject_field_id(field_type) + value = cleaned_data[field_id] + if value is not None and value != "": + count = StudySubject.objects.filter(customstudysubjectvalue__study_subject_field=field_type, + customstudysubjectvalue__value=value, + study=self.study) \ + .exclude(id=subject_id) \ + .count() + if count > 0: + self.add_error(field_id, "Value must be unique within the study") + return cleaned_data + class StudySubjectAddForm(StudySubjectForm): class Meta: @@ -79,9 +190,14 @@ class StudySubjectAddForm(StudySubjectForm): prepare_study_subject_fields(fields=self.fields, study=self.study) - def save(self, commit=True): + def save(self, commit=True) -> StudySubject: self.instance.study_id = self.study.id - return super(StudySubjectAddForm, self).save(commit) + instance = super(StudySubjectAddForm, self).save(commit) + # we can add custom values only after object exists in the database + for field_type in CustomStudySubjectField.objects.filter(study=self.study): + self.instance.set_custom_data_value(field_type, get_study_subject_field_value(field_type, self[ + get_study_subject_field_id(field_type)])) + return instance def build_screening_number(self, cleaned_data): screening_number = cleaned_data.get('screening_number', None) @@ -154,6 +270,39 @@ def get_study_from_study_subject_instance(study_subject): return Study(columns=StudyColumns()) +def get_study_subject_field_value(field_type: CustomStudySubjectField, field: forms.BoundField): + if field_type.type == CUSTOM_FIELD_TYPE_TEXT: + return field.value() + elif field_type.type == CUSTOM_FIELD_TYPE_BOOLEAN: + return str(field.value()) + elif field_type.type == CUSTOM_FIELD_TYPE_INTEGER: + if field.value() is None: + return None + return str(field.value()) + elif field_type.type == CUSTOM_FIELD_TYPE_DOUBLE: + if field.value() is None: + return None + return str(field.value()) + elif field_type.type == CUSTOM_FIELD_TYPE_DATE: + if field.value() is None: + return None + return field.value() + elif field_type.type == CUSTOM_FIELD_TYPE_SELECT_LIST: + if field.value() == '0': + return '' + if field.value() is None: + return None + for v, k in get_custom_select_choices(field_type.possible_values): + if v == field.value(): + return k + return None + elif field_type.type == CUSTOM_FIELD_TYPE_FILE: + # this must be handled in view + return None + else: + raise NotImplementedError + + class StudySubjectEditForm(StudySubjectForm): def __init__(self, *args, **kwargs): @@ -171,9 +320,17 @@ class StudySubjectEditForm(StudySubjectForm): prepare_study_subject_fields(fields=self.fields, study=self.study) def clean(self): - validate_subject_nd_number(self, self.cleaned_data) - validate_subject_mpower_number(self, self.cleaned_data) - validate_subject_resign_reason(self, self.cleaned_data) + cleaned_data = super(StudySubjectEditForm, self).clean() + validate_subject_nd_number(self, cleaned_data) + validate_subject_mpower_number(self, cleaned_data) + validate_subject_resign_reason(self, cleaned_data) + return cleaned_data + + def save(self, commit=True) -> StudySubject: + for field_type in CustomStudySubjectField.objects.filter(study=self.study): + self.instance.set_custom_data_value(field_type, get_study_subject_field_value(field_type, self[ + get_study_subject_field_id(field_type)])) + return super(StudySubjectForm, self).save(commit) class Meta: model = StudySubject diff --git a/smash/web/forms/subject_forms.py b/smash/web/forms/subject_forms.py index e60082bc21ccfc1253e8108370fdb6fb192d34bd..38555a3459b80225c49143904ca6bbe39d7732d5 100644 --- a/smash/web/forms/subject_forms.py +++ b/smash/web/forms/subject_forms.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def validate_subject_country(self, cleaned_data): - if cleaned_data['country'].id == COUNTRY_OTHER_ID: + if 'country' not in cleaned_data or cleaned_data['country'].id == COUNTRY_OTHER_ID: self.add_error('country', "Select valid country") @@ -20,6 +20,7 @@ def validate_social_security_number(self, number): if not is_valid_social_security_number(number): self.add_error('social_security_number', "Social security number is invalid") + FIELD_ORDER = ["first_name", "last_name", "sex", "date_born", "social_security_number", "default_written_communication_language", "languages", "phone_number", "phone_number_2", "phone_number_3", "address", "city", "postal_code", "country"] @@ -27,7 +28,7 @@ FIELD_ORDER = ["first_name", "last_name", "sex", "date_born", "social_security_n class SubjectAddForm(ModelForm): date_born = forms.DateField(label="Date of birth", - widget=forms.DateInput( DATEPICKER_DATE_ATTRS, "%Y-%m-%d"), required=False) + widget=forms.DateInput(DATEPICKER_DATE_ATTRS, "%Y-%m-%d"), required=False) field_order = FIELD_ORDER diff --git a/smash/web/migrations/0177_customstudysubjectfield_customstudysubjectvalue.py b/smash/web/migrations/0177_customstudysubjectfield_customstudysubjectvalue.py new file mode 100644 index 0000000000000000000000000000000000000000..25e16fa4fc684c640e69cfe713f22b31f1c26229 --- /dev/null +++ b/smash/web/migrations/0177_customstudysubjectfield_customstudysubjectvalue.py @@ -0,0 +1,37 @@ +# Generated by Django 2.0.13 on 2020-11-06 08:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0176_configurationitem_local_setting_clean'), + ] + + operations = [ + migrations.CreateModel( + name='CustomStudySubjectField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20)), + ('type', models.CharField(choices=[('TEXT', 'Text'), ('BOOL', 'Boolean (True/False)'), ('INTEGER', 'Integer'), ('DOUBLE', 'Double (real number)'), ('DATE', 'Date'), ('SELECT_LIST', 'Select list')], max_length=20)), + ('possible_values', models.CharField(blank=True, default='', max_length=1024, null=True)), + ('default_value', models.CharField(blank=True, max_length=20, null=True)), + ('readonly', models.BooleanField(default=False)), + ('required', models.BooleanField(default=False)), + ('unique', models.BooleanField(default=False)), + ('study', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='web.Study', verbose_name='Study')), + ], + ), + migrations.CreateModel( + name='CustomStudySubjectValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(blank=True, max_length=2048, null=True)), + ('study_subject', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='web.StudySubject', verbose_name='Study')), + ('study_subject_field', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='web.CustomStudySubjectField', verbose_name='Custom Field')), + ], + ), + ] diff --git a/smash/web/migrations/0178_auto_20201116_1250.py b/smash/web/migrations/0178_auto_20201116_1250.py new file mode 100644 index 0000000000000000000000000000000000000000..1f94b8c9e8b6a8846490aa674d4156a807ac137d --- /dev/null +++ b/smash/web/migrations/0178_auto_20201116_1250.py @@ -0,0 +1,1356 @@ +# Generated by Django 2.0.13 on 2020-11-16 12:50 + +import django.core.files.storage +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0177_customstudysubjectfield_customstudysubjectvalue'), + ] + + operations = [ + migrations.AlterField( + model_name='appointment', + name='appointment_types', + field=models.ManyToManyField(blank=True, related_name='new_appointment', through='web.AppointmentTypeLink', to='web.AppointmentType', verbose_name='Appointment types'), + ), + migrations.AlterField( + model_name='appointment', + name='post_mail_sent', + field=models.BooleanField(default=False, verbose_name='Post mail sent'), + ), + migrations.AlterField( + model_name='appointment', + name='status', + field=models.CharField(choices=[('SCHEDULED', 'Scheduled'), ('FINISHED', 'Finished'), ('CANCELLED', 'Cancelled'), ('NO_SHOW', 'No Show')], default='SCHEDULED', max_length=20, verbose_name='Status'), + ), + migrations.AlterField( + model_name='appointment', + name='visit', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='web.Visit', verbose_name='Visit ID'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='appointment_types', + field=models.BooleanField(default=True, verbose_name='Appointment types'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='comment', + field=models.BooleanField(default=False, verbose_name='Comment'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='datetime_when', + field=models.BooleanField(default=True, verbose_name='Comment'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='flying_team', + field=models.BooleanField(default=False, verbose_name='Flying team'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='length', + field=models.BooleanField(default=False, verbose_name='Appointment length'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='location', + field=models.BooleanField(default=False, verbose_name='Location'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='post_mail_sent', + field=models.BooleanField(default=False, verbose_name='Post mail sent'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='room', + field=models.BooleanField(default=False, verbose_name='Room'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='status', + field=models.BooleanField(default=False, verbose_name='Status'), + ), + migrations.AlterField( + model_name='appointmentcolumns', + name='worker_assigned', + field=models.BooleanField(default=False, verbose_name='Worker conducting the assessment'), + ), + migrations.AlterField( + model_name='appointmentlist', + name='type', + field=models.CharField(choices=[('GENERIC', 'Generic'), ('UNFINISHED', 'Unfinished'), ('APPROACHING', 'Approaching')], max_length=50, verbose_name='Type of list'), + ), + migrations.AlterField( + model_name='appointmenttype', + name='calendar_font_color', + field=models.CharField(default='#00000', max_length=2000, verbose_name='Calendar font color'), + ), + migrations.AlterField( + model_name='availability', + name='available_from', + field=models.TimeField(verbose_name='Available from'), + ), + migrations.AlterField( + model_name='availability', + name='available_till', + field=models.TimeField(verbose_name='Available until'), + ), + migrations.AlterField( + model_name='availability', + name='day_number', + field=models.IntegerField(choices=[(1, 'MONDAY'), (2, 'TUESDAY'), (3, 'WEDNESDAY'), (4, 'THURSDAY'), (5, 'FRIDAY'), (6, 'SATURDAY'), (7, 'SUNDAY')], verbose_name='Day of the week'), + ), + migrations.AlterField( + model_name='availability', + name='person', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='web.Worker', verbose_name='Worker'), + ), + migrations.AlterField( + model_name='configurationitem', + name='name', + field=models.CharField(editable=False, max_length=255, verbose_name='Name'), + ), + migrations.AlterField( + model_name='configurationitem', + name='type', + field=models.CharField(editable=False, max_length=50, verbose_name='Type'), + ), + migrations.AlterField( + model_name='configurationitem', + name='value', + field=models.CharField(max_length=1024, verbose_name='Value'), + ), + migrations.AlterField( + model_name='contactattempt', + name='datetime_when', + field=models.DateTimeField(help_text='When did the contact occurred?', verbose_name='When'), + ), + migrations.AlterField( + model_name='contactattempt', + name='subject', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='web.StudySubject', verbose_name='Subject'), + ), + migrations.AlterField( + model_name='contactattempt', + name='type', + field=models.CharField(choices=[('E', 'Email'), ('M', 'Post mail'), ('F', 'Face to face'), ('X', 'Fax'), ('P', 'Phone'), ('S', 'SMS')], default='P', max_length=2), + ), + migrations.AlterField( + model_name='contactattempt', + name='worker', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='web.Worker', verbose_name='Worker'), + ), + migrations.AlterField( + model_name='customstudysubjectfield', + name='type', + field=models.CharField(choices=[('TEXT', 'Text'), ('BOOL', 'Boolean (True/False)'), ('INTEGER', 'Integer'), ('DOUBLE', 'Double (real number)'), ('DATE', 'Date'), ('SELECT_LIST', 'Select list'), ('FILE', 'File')], max_length=20), + ), + migrations.AlterField( + model_name='holiday', + name='info', + field=models.TextField(blank=True, max_length=2000, verbose_name='Comments'), + ), + migrations.AlterField( + model_name='holiday', + name='kind', + field=models.CharField(choices=[('H', 'Holiday'), ('X', 'Extra Availability')], default='H', help_text='Defines the kind of availability. Either Holiday or Extra Availability.', max_length=1), + ), + migrations.AlterField( + model_name='inconsistentfield', + name='inconsistent_subject', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='web.InconsistentSubject', verbose_name='Invalid fields'), + ), + migrations.AlterField( + model_name='inconsistentfield', + name='name', + field=models.CharField(max_length=255, verbose_name='Field name'), + ), + migrations.AlterField( + model_name='inconsistentfield', + name='redcap_value', + field=models.CharField(max_length=255, verbose_name='RED Cap value'), + ), + migrations.AlterField( + model_name='inconsistentfield', + name='smash_value', + field=models.CharField(max_length=255, verbose_name='Smash value'), + ), + migrations.AlterField( + model_name='inconsistentsubject', + name='redcap_url', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='URL to RED Cap subject'), + ), + migrations.AlterField( + model_name='inconsistentsubject', + name='subject', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='web.StudySubject', verbose_name='Subject'), + ), + migrations.AlterField( + model_name='language', + name='image', + field=models.ImageField(upload_to=''), + ), + migrations.AlterField( + model_name='language', + name='locale', + field=models.CharField(choices=[('af_ZA', 'af_ZA'), ('am_ET', 'am_ET'), ('ar_AE', 'ar_AE'), ('ar_BH', 'ar_BH'), ('ar_DZ', 'ar_DZ'), ('ar_EG', 'ar_EG'), ('ar_IQ', 'ar_IQ'), ('ar_JO', 'ar_JO'), ('ar_KW', 'ar_KW'), ('ar_LB', 'ar_LB'), ('ar_LY', 'ar_LY'), ('ar_MA', 'ar_MA'), ('ar_OM', 'ar_OM'), ('ar_QA', 'ar_QA'), ('ar_SA', 'ar_SA'), ('ar_SY', 'ar_SY'), ('ar_TN', 'ar_TN'), ('ar_YE', 'ar_YE'), ('arn_CL', 'arn_CL'), ('as_IN', 'as_IN'), ('az_AZ', 'az_AZ'), ('az_AZ', 'az_AZ'), ('ba_RU', 'ba_RU'), ('be_BY', 'be_BY'), ('bg_BG', 'bg_BG'), ('bn_IN', 'bn_IN'), ('bo_BT', 'bo_BT'), ('bo_CN', 'bo_CN'), ('br_FR', 'br_FR'), ('bs_BA', 'bs_BA'), ('bs_BA', 'bs_BA'), ('ca_ES', 'ca_ES'), ('co_FR', 'co_FR'), ('cs_CZ', 'cs_CZ'), ('cy_GB', 'cy_GB'), ('da_DK', 'da_DK'), ('de_AT', 'de_AT'), ('de_CH', 'de_CH'), ('de_DE', 'de_DE'), ('de_LI', 'de_LI'), ('de_LU', 'de_LU'), ('div_MV', 'div_MV'), ('dsb_DE', 'dsb_DE'), ('el_GR', 'el_GR'), ('en_AU', 'en_AU'), ('en_BZ', 'en_BZ'), ('en_CA', 'en_CA'), ('en_CB', 'en_CB'), ('en_GB', 'en_GB'), ('en_IE', 'en_IE'), ('en_IN', 'en_IN'), ('en_IN', 'en_IN'), ('en_JA', 'en_JA'), ('en_MY', 'en_MY'), ('en_NZ', 'en_NZ'), ('en_PH', 'en_PH'), ('en_TT', 'en_TT'), ('en_US', 'en_US'), ('en_ZA', 'en_ZA'), ('en_ZW', 'en_ZW'), ('es_AR', 'es_AR'), ('es_BO', 'es_BO'), ('es_CL', 'es_CL'), ('es_CO', 'es_CO'), ('es_CR', 'es_CR'), ('es_DO', 'es_DO'), ('es_EC', 'es_EC'), ('es_ES', 'es_ES'), ('es_ES', 'es_ES'), ('es_GT', 'es_GT'), ('es_HN', 'es_HN'), ('es_MX', 'es_MX'), ('es_NI', 'es_NI'), ('es_PA', 'es_PA'), ('es_PE', 'es_PE'), ('es_PR', 'es_PR'), ('es_PY', 'es_PY'), ('es_SV', 'es_SV'), ('es_UR', 'es_UR'), ('es_US', 'es_US'), ('es_VE', 'es_VE'), ('et_EE', 'et_EE'), ('eu_ES', 'eu_ES'), ('fa_IR', 'fa_IR'), ('fi_FI', 'fi_FI'), ('fil_PH', 'fil_PH'), ('fo_FO', 'fo_FO'), ('fr_BE', 'fr_BE'), ('fr_CA', 'fr_CA'), ('fr_CH', 'fr_CH'), ('fr_FR', 'fr_FR'), ('fr_LU', 'fr_LU'), ('fr_MC', 'fr_MC'), ('fy_NL', 'fy_NL'), ('ga_IE', 'ga_IE'), ('gbz_AF', 'gbz_AF'), ('gl_ES', 'gl_ES'), ('gsw_FR', 'gsw_FR'), ('gu_IN', 'gu_IN'), ('ha_NG', 'ha_NG'), ('he_IL', 'he_IL'), ('hi_IN', 'hi_IN'), ('hr_BA', 'hr_BA'), ('hr_HR', 'hr_HR'), ('hu_HU', 'hu_HU'), ('hy_AM', 'hy_AM'), ('id_ID', 'id_ID'), ('ii_CN', 'ii_CN'), ('is_IS', 'is_IS'), ('it_CH', 'it_CH'), ('it_IT', 'it_IT'), ('iu_CA', 'iu_CA'), ('iu_CA', 'iu_CA'), ('ja_JP', 'ja_JP'), ('ka_GE', 'ka_GE'), ('kh_KH', 'kh_KH'), ('kk_KZ', 'kk_KZ'), ('kl_GL', 'kl_GL'), ('kn_IN', 'kn_IN'), ('ko_KR', 'ko_KR'), ('kok_IN', 'kok_IN'), ('ky_KG', 'ky_KG'), ('lb_LU', 'lb_LU'), ('lo_LA', 'lo_LA'), ('lt_LT', 'lt_LT'), ('lv_LV', 'lv_LV'), ('mi_NZ', 'mi_NZ'), ('mk_MK', 'mk_MK'), ('ml_IN', 'ml_IN'), ('mn_CN', 'mn_CN'), ('mn_MN', 'mn_MN'), ('moh_CA', 'moh_CA'), ('mr_IN', 'mr_IN'), ('ms_BN', 'ms_BN'), ('ms_MY', 'ms_MY'), ('mt_MT', 'mt_MT'), ('nb_NO', 'nb_NO'), ('ne_NP', 'ne_NP'), ('nl_BE', 'nl_BE'), ('nl_NL', 'nl_NL'), ('nn_NO', 'nn_NO'), ('ns_ZA', 'ns_ZA'), ('oc_FR', 'oc_FR'), ('or_IN', 'or_IN'), ('pa_IN', 'pa_IN'), ('pl_PL', 'pl_PL'), ('ps_AF', 'ps_AF'), ('pt_BR', 'pt_BR'), ('pt_PT', 'pt_PT'), ('qut_GT', 'qut_GT'), ('quz_BO', 'quz_BO'), ('quz_EC', 'quz_EC'), ('quz_PE', 'quz_PE'), ('rm_CH', 'rm_CH'), ('ro_RO', 'ro_RO'), ('ru_RU', 'ru_RU'), ('rw_RW', 'rw_RW'), ('sa_IN', 'sa_IN'), ('sah_RU', 'sah_RU'), ('se_FI', 'se_FI'), ('se_NO', 'se_NO'), ('se_SE', 'se_SE'), ('si_LK', 'si_LK'), ('sk_SK', 'sk_SK'), ('sl_SI', 'sl_SI'), ('sma_NO', 'sma_NO'), ('sma_SE', 'sma_SE'), ('smj_NO', 'smj_NO'), ('smj_SE', 'smj_SE'), ('smn_FI', 'smn_FI'), ('sms_FI', 'sms_FI'), ('sq_AL', 'sq_AL'), ('sr_BA', 'sr_BA'), ('sr_BA', 'sr_BA'), ('sr_SP', 'sr_SP'), ('sr_SP', 'sr_SP'), ('sv_FI', 'sv_FI'), ('sv_SE', 'sv_SE'), ('sw_KE', 'sw_KE'), ('syr_SY', 'syr_SY'), ('ta_IN', 'ta_IN'), ('te_IN', 'te_IN'), ('tg_TJ', 'tg_TJ'), ('th_TH', 'th_TH'), ('tk_TM', 'tk_TM'), ('tmz_DZ', 'tmz_DZ'), ('tn_ZA', 'tn_ZA'), ('tr_TR', 'tr_TR'), ('tt_RU', 'tt_RU'), ('ug_CN', 'ug_CN'), ('uk_UA', 'uk_UA'), ('ur_IN', 'ur_IN'), ('ur_PK', 'ur_PK'), ('uz_UZ', 'uz_UZ'), ('uz_UZ', 'uz_UZ'), ('vi_VN', 'vi_VN'), ('wen_DE', 'wen_DE'), ('wo_SN', 'wo_SN'), ('xh_ZA', 'xh_ZA'), ('yo_NG', 'yo_NG'), ('zh_CHS', 'zh_CHS'), ('zh_CHT', 'zh_CHT'), ('zh_CN', 'zh_CN'), ('zh_HK', 'zh_HK'), ('zh_MO', 'zh_MO'), ('zh_SG', 'zh_SG'), ('zh_TW', 'zh_TW'), ('zu_ZA', 'zu_ZA')], default='fr_FR', max_length=10), + ), + migrations.AlterField( + model_name='language', + name='windows_locale_name', + field=models.CharField(choices=[('af_ZA', 'af_ZA'), ('am_ET', 'am_ET'), ('ar_AE', 'ar_AE'), ('ar_BH', 'ar_BH'), ('ar_DZ', 'ar_DZ'), ('ar_EG', 'ar_EG'), ('ar_IQ', 'ar_IQ'), ('ar_JO', 'ar_JO'), ('ar_KW', 'ar_KW'), ('ar_LB', 'ar_LB'), ('ar_LY', 'ar_LY'), ('ar_MA', 'ar_MA'), ('ar_OM', 'ar_OM'), ('ar_QA', 'ar_QA'), ('ar_SA', 'ar_SA'), ('ar_SY', 'ar_SY'), ('ar_TN', 'ar_TN'), ('ar_YE', 'ar_YE'), ('arn_CL', 'arn_CL'), ('as_IN', 'as_IN'), ('az_AZ', 'az_AZ'), ('az_AZ', 'az_AZ'), ('ba_RU', 'ba_RU'), ('be_BY', 'be_BY'), ('bg_BG', 'bg_BG'), ('bn_IN', 'bn_IN'), ('bo_BT', 'bo_BT'), ('bo_CN', 'bo_CN'), ('br_FR', 'br_FR'), ('bs_BA', 'bs_BA'), ('bs_BA', 'bs_BA'), ('ca_ES', 'ca_ES'), ('co_FR', 'co_FR'), ('cs_CZ', 'cs_CZ'), ('cy_GB', 'cy_GB'), ('da_DK', 'da_DK'), ('de_AT', 'de_AT'), ('de_CH', 'de_CH'), ('de_DE', 'de_DE'), ('de_LI', 'de_LI'), ('de_LU', 'de_LU'), ('div_MV', 'div_MV'), ('dsb_DE', 'dsb_DE'), ('el_GR', 'el_GR'), ('en_AU', 'en_AU'), ('en_BZ', 'en_BZ'), ('en_CA', 'en_CA'), ('en_CB', 'en_CB'), ('en_GB', 'en_GB'), ('en_IE', 'en_IE'), ('en_IN', 'en_IN'), ('en_IN', 'en_IN'), ('en_JA', 'en_JA'), ('en_MY', 'en_MY'), ('en_NZ', 'en_NZ'), ('en_PH', 'en_PH'), ('en_TT', 'en_TT'), ('en_US', 'en_US'), ('en_ZA', 'en_ZA'), ('en_ZW', 'en_ZW'), ('es_AR', 'es_AR'), ('es_BO', 'es_BO'), ('es_CL', 'es_CL'), ('es_CO', 'es_CO'), ('es_CR', 'es_CR'), ('es_DO', 'es_DO'), ('es_EC', 'es_EC'), ('es_ES', 'es_ES'), ('es_ES', 'es_ES'), ('es_GT', 'es_GT'), ('es_HN', 'es_HN'), ('es_MX', 'es_MX'), ('es_NI', 'es_NI'), ('es_PA', 'es_PA'), ('es_PE', 'es_PE'), ('es_PR', 'es_PR'), ('es_PY', 'es_PY'), ('es_SV', 'es_SV'), ('es_UR', 'es_UR'), ('es_US', 'es_US'), ('es_VE', 'es_VE'), ('et_EE', 'et_EE'), ('eu_ES', 'eu_ES'), ('fa_IR', 'fa_IR'), ('fi_FI', 'fi_FI'), ('fil_PH', 'fil_PH'), ('fo_FO', 'fo_FO'), ('fr_BE', 'fr_BE'), ('fr_CA', 'fr_CA'), ('fr_CH', 'fr_CH'), ('fr_FR', 'fr_FR'), ('fr_LU', 'fr_LU'), ('fr_MC', 'fr_MC'), ('fy_NL', 'fy_NL'), ('ga_IE', 'ga_IE'), ('gbz_AF', 'gbz_AF'), ('gl_ES', 'gl_ES'), ('gsw_FR', 'gsw_FR'), ('gu_IN', 'gu_IN'), ('ha_NG', 'ha_NG'), ('he_IL', 'he_IL'), ('hi_IN', 'hi_IN'), ('hr_BA', 'hr_BA'), ('hr_HR', 'hr_HR'), ('hu_HU', 'hu_HU'), ('hy_AM', 'hy_AM'), ('id_ID', 'id_ID'), ('ii_CN', 'ii_CN'), ('is_IS', 'is_IS'), ('it_CH', 'it_CH'), ('it_IT', 'it_IT'), ('iu_CA', 'iu_CA'), ('iu_CA', 'iu_CA'), ('ja_JP', 'ja_JP'), ('ka_GE', 'ka_GE'), ('kh_KH', 'kh_KH'), ('kk_KZ', 'kk_KZ'), ('kl_GL', 'kl_GL'), ('kn_IN', 'kn_IN'), ('ko_KR', 'ko_KR'), ('kok_IN', 'kok_IN'), ('ky_KG', 'ky_KG'), ('lb_LU', 'lb_LU'), ('lo_LA', 'lo_LA'), ('lt_LT', 'lt_LT'), ('lv_LV', 'lv_LV'), ('mi_NZ', 'mi_NZ'), ('mk_MK', 'mk_MK'), ('ml_IN', 'ml_IN'), ('mn_CN', 'mn_CN'), ('mn_MN', 'mn_MN'), ('moh_CA', 'moh_CA'), ('mr_IN', 'mr_IN'), ('ms_BN', 'ms_BN'), ('ms_MY', 'ms_MY'), ('mt_MT', 'mt_MT'), ('nb_NO', 'nb_NO'), ('ne_NP', 'ne_NP'), ('nl_BE', 'nl_BE'), ('nl_NL', 'nl_NL'), ('nn_NO', 'nn_NO'), ('ns_ZA', 'ns_ZA'), ('oc_FR', 'oc_FR'), ('or_IN', 'or_IN'), ('pa_IN', 'pa_IN'), ('pl_PL', 'pl_PL'), ('ps_AF', 'ps_AF'), ('pt_BR', 'pt_BR'), ('pt_PT', 'pt_PT'), ('qut_GT', 'qut_GT'), ('quz_BO', 'quz_BO'), ('quz_EC', 'quz_EC'), ('quz_PE', 'quz_PE'), ('rm_CH', 'rm_CH'), ('ro_RO', 'ro_RO'), ('ru_RU', 'ru_RU'), ('rw_RW', 'rw_RW'), ('sa_IN', 'sa_IN'), ('sah_RU', 'sah_RU'), ('se_FI', 'se_FI'), ('se_NO', 'se_NO'), ('se_SE', 'se_SE'), ('si_LK', 'si_LK'), ('sk_SK', 'sk_SK'), ('sl_SI', 'sl_SI'), ('sma_NO', 'sma_NO'), ('sma_SE', 'sma_SE'), ('smj_NO', 'smj_NO'), ('smj_SE', 'smj_SE'), ('smn_FI', 'smn_FI'), ('sms_FI', 'sms_FI'), ('sq_AL', 'sq_AL'), ('sr_BA', 'sr_BA'), ('sr_BA', 'sr_BA'), ('sr_SP', 'sr_SP'), ('sr_SP', 'sr_SP'), ('sv_FI', 'sv_FI'), ('sv_SE', 'sv_SE'), ('sw_KE', 'sw_KE'), ('syr_SY', 'syr_SY'), ('ta_IN', 'ta_IN'), ('te_IN', 'te_IN'), ('tg_TJ', 'tg_TJ'), ('th_TH', 'th_TH'), ('tk_TM', 'tk_TM'), ('tmz_DZ', 'tmz_DZ'), ('tn_ZA', 'tn_ZA'), ('tr_TR', 'tr_TR'), ('tt_RU', 'tt_RU'), ('ug_CN', 'ug_CN'), ('uk_UA', 'uk_UA'), ('ur_IN', 'ur_IN'), ('ur_PK', 'ur_PK'), ('uz_UZ', 'uz_UZ'), ('uz_UZ', 'uz_UZ'), ('vi_VN', 'vi_VN'), ('wen_DE', 'wen_DE'), ('wo_SN', 'wo_SN'), ('xh_ZA', 'xh_ZA'), ('yo_NG', 'yo_NG'), ('zh_CHS', 'zh_CHS'), ('zh_CHT', 'zh_CHT'), ('zh_CN', 'zh_CN'), ('zh_HK', 'zh_HK'), ('zh_MO', 'zh_MO'), ('zh_SG', 'zh_SG'), ('zh_TW', 'zh_TW'), ('zu_ZA', 'zu_ZA')], default='French', max_length=10), + ), + migrations.AlterField( + model_name='location', + name='color', + field=models.CharField(blank=True, default='', max_length=20, verbose_name='Calendar appointment color'), + ), + migrations.AlterField( + model_name='mailtemplate', + name='context', + field=models.CharField(choices=[('A', 'Appointment'), ('S', 'Subject'), ('V', 'Visit'), ('C', 'Voucher')], max_length=1), + ), + migrations.AlterField( + model_name='mailtemplate', + name='template_file', + field=models.FileField(upload_to='templates/'), + ), + migrations.AlterField( + model_name='missingsubject', + name='ignore', + field=models.BooleanField(default=False, verbose_name='Ignore missing subject'), + ), + migrations.AlterField( + model_name='missingsubject', + name='redcap_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='RED Cap id'), + ), + migrations.AlterField( + model_name='missingsubject', + name='redcap_url', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='URL to RED Cap subject'), + ), + migrations.AlterField( + model_name='missingsubject', + name='subject', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='web.StudySubject', verbose_name='Subject'), + ), + migrations.AlterField( + model_name='provenance', + name='modification_author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='web.Worker', verbose_name='Worker who modified the row'), + ), + migrations.AlterField( + model_name='provenance', + name='modification_date', + field=models.DateTimeField(auto_now_add=True, verbose_name='Modified on'), + ), + migrations.AlterField( + model_name='provenance', + name='modification_description', + field=models.CharField(max_length=20480, verbose_name='Description'), + ), + migrations.AlterField( + model_name='provenance', + name='modified_field', + field=models.CharField(blank='', max_length=1024, verbose_name='Modified field'), + ), + migrations.AlterField( + model_name='provenance', + name='new_value', + field=models.CharField(blank=True, max_length=2048, null=True, verbose_name='New Value'), + ), + migrations.AlterField( + model_name='provenance', + name='previous_value', + field=models.CharField(blank=True, max_length=2048, null=True, verbose_name='Previous Value'), + ), + migrations.AlterField( + model_name='study', + name='auto_create_follow_up', + field=models.BooleanField(default=True, verbose_name='Auto create follow up visit'), + ), + migrations.AlterField( + model_name='study', + name='default_delta_time_for_control_follow_up', + field=models.IntegerField(default=4, help_text='Time difference between visits used to automatically create follow up visits', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Time difference between control visits'), + ), + migrations.AlterField( + model_name='study', + name='default_delta_time_for_follow_up_units', + field=models.CharField(choices=[('years', 'Years'), ('days', 'Days')], default='years', help_text='Units for the number of days between visits for both patients and controls', max_length=10, verbose_name='Units for the follow up incrementals'), + ), + migrations.AlterField( + model_name='study', + name='default_delta_time_for_patient_follow_up', + field=models.IntegerField(default=1, help_text='Time difference between visits used to automatically create follow up visits', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Time difference between patient visits'), + ), + migrations.AlterField( + model_name='study', + name='default_visit_duration_in_months', + field=models.IntegerField(default=6, help_text='Duration of the visit, this is, the time interval, in months, when the appointments may take place', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Duration of the visits in months'), + ), + migrations.AlterField( + model_name='study', + name='default_voucher_expiration_in_months', + field=models.IntegerField(default=3, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Duration of the vouchers in months'), + ), + migrations.AlterField( + model_name='study', + name='name', + field=models.CharField(max_length=255, verbose_name='Name'), + ), + migrations.AlterField( + model_name='study', + name='nd_number_study_subject_regex', + field=models.CharField(default='^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.', max_length=255, verbose_name='Study Subject ND Number Regex'), + ), + migrations.AlterField( + model_name='study', + name='redcap_first_visit_number', + field=models.IntegerField(default=1, verbose_name='Number of the first visit in redcap system'), + ), + migrations.AlterField( + model_name='study', + name='sample_mail_statistics', + field=models.BooleanField(default=False, verbose_name='Email with sample collections should use statistics'), + ), + migrations.AlterField( + model_name='study', + name='visits_to_show_in_subject_list', + field=models.IntegerField(default=5, validators=[django.core.validators.MaxValueValidator(100), django.core.validators.MinValueValidator(1)], verbose_name='Number of visits to show in the subject list'), + ), + migrations.AlterField( + model_name='studycolumns', + name='brain_donation_agreement', + field=models.BooleanField(default=False, verbose_name='Brain donation agreement'), + ), + migrations.AlterField( + model_name='studycolumns', + name='comments', + field=models.BooleanField(default=True, verbose_name='Comments'), + ), + migrations.AlterField( + model_name='studycolumns', + name='datetime_contact_reminder', + field=models.BooleanField(default=True, verbose_name='Please make a contact on'), + ), + migrations.AlterField( + model_name='studycolumns', + name='default_location', + field=models.BooleanField(default=True, verbose_name='Default appointment location'), + ), + migrations.AlterField( + model_name='studycolumns', + name='diagnosis', + field=models.BooleanField(default=True, verbose_name='Diagnosis'), + ), + migrations.AlterField( + model_name='studycolumns', + name='endpoint_reached', + field=models.BooleanField(default=True, verbose_name='Endpoint reached'), + ), + migrations.AlterField( + model_name='studycolumns', + name='excluded', + field=models.BooleanField(default=False, verbose_name='Excluded'), + ), + migrations.AlterField( + model_name='studycolumns', + name='flying_team', + field=models.BooleanField(default=True, verbose_name='Default flying team location (if applicable)'), + ), + migrations.AlterField( + model_name='studycolumns', + name='health_partner', + field=models.BooleanField(default=False, verbose_name='Health partner'), + ), + migrations.AlterField( + model_name='studycolumns', + name='health_partner_feedback_agreement', + field=models.BooleanField(default=False, verbose_name='Agrees to give information to referral'), + ), + migrations.AlterField( + model_name='studycolumns', + name='information_sent', + field=models.BooleanField(default=True, verbose_name='Information sent'), + ), + migrations.AlterField( + model_name='studycolumns', + name='mpower_id', + field=models.BooleanField(default=True, verbose_name='MPower ID'), + ), + migrations.AlterField( + model_name='studycolumns', + name='nd_number', + field=models.BooleanField(default=True, verbose_name='ND number'), + ), + migrations.AlterField( + model_name='studycolumns', + name='pd_in_family', + field=models.BooleanField(default=True, verbose_name='PD in family'), + ), + migrations.AlterField( + model_name='studycolumns', + name='postponed', + field=models.BooleanField(default=True, verbose_name='Postponed'), + ), + migrations.AlterField( + model_name='studycolumns', + name='previously_in_study', + field=models.BooleanField(default=False, verbose_name='Previously in PDP study'), + ), + migrations.AlterField( + model_name='studycolumns', + name='referral', + field=models.BooleanField(default=True, verbose_name='Referred by'), + ), + migrations.AlterField( + model_name='studycolumns', + name='referral_letter', + field=models.BooleanField(default=False, verbose_name='Referral letter'), + ), + migrations.AlterField( + model_name='studycolumns', + name='resign_reason', + field=models.BooleanField(default=True, verbose_name='Endpoint reached comments'), + ), + migrations.AlterField( + model_name='studycolumns', + name='resigned', + field=models.BooleanField(default=True, verbose_name='Resigned'), + ), + migrations.AlterField( + model_name='studycolumns', + name='screening', + field=models.BooleanField(default=False, verbose_name='Screening'), + ), + migrations.AlterField( + model_name='studycolumns', + name='screening_number', + field=models.BooleanField(default=True, verbose_name='Screening number'), + ), + migrations.AlterField( + model_name='studycolumns', + name='type', + field=models.BooleanField(default=True, verbose_name='Type'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_1', + field=models.BooleanField(default=False, verbose_name='Visit 1 virus results'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_1_collection_date', + field=models.BooleanField(default=False, verbose_name='Visit 1 virus collection date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_1_iga_status', + field=models.BooleanField(default=False, verbose_name='Visit 1 virus IgA status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_1_igg_status', + field=models.BooleanField(default=False, verbose_name='Visit 1 virus IgG status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_1_updated', + field=models.BooleanField(default=False, verbose_name='Visit 1 virus results date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_2', + field=models.BooleanField(default=False, verbose_name='Visit 2 virus results'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_2_collection_date', + field=models.BooleanField(default=False, verbose_name='Visit 2 virus collection date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_2_iga_status', + field=models.BooleanField(default=False, verbose_name='Visit 2 virus IgA status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_2_igg_status', + field=models.BooleanField(default=False, verbose_name='Visit 2 virus IgG status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_2_updated', + field=models.BooleanField(default=False, verbose_name='Visit 2 virus results date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_3', + field=models.BooleanField(default=False, verbose_name='Visit 3 virus results'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_3_collection_date', + field=models.BooleanField(default=False, verbose_name='Visit 3 virus collection date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_3_iga_status', + field=models.BooleanField(default=False, verbose_name='Visit 3 virus IgA status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_3_igg_status', + field=models.BooleanField(default=False, verbose_name='Visit 3 virus IgG status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_3_updated', + field=models.BooleanField(default=False, verbose_name='Visit 3 virus results date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_4', + field=models.BooleanField(default=False, verbose_name='Visit 4 virus results'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_4_collection_date', + field=models.BooleanField(default=False, verbose_name='Visit 4 virus collection date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_4_iga_status', + field=models.BooleanField(default=False, verbose_name='Visit 4 virus IgA status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_4_igg_status', + field=models.BooleanField(default=False, verbose_name='Visit 4 virus IgG status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_4_updated', + field=models.BooleanField(default=False, verbose_name='Visit 4 virus results date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_5', + field=models.BooleanField(default=False, verbose_name='Visit 5 virus results'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_5_collection_date', + field=models.BooleanField(default=False, verbose_name='Visit 5 virus collection date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_5_iga_status', + field=models.BooleanField(default=False, verbose_name='Visit 5 virus IgA status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_5_igg_status', + field=models.BooleanField(default=False, verbose_name='Visit 5 virus IgG status'), + ), + migrations.AlterField( + model_name='studycolumns', + name='virus_test_5_updated', + field=models.BooleanField(default=False, verbose_name='Visit 5 virus results date'), + ), + migrations.AlterField( + model_name='studycolumns', + name='voucher_types', + field=models.BooleanField(default=False, verbose_name='Voucher types'), + ), + migrations.AlterField( + model_name='studycolumns', + name='vouchers', + field=models.BooleanField(default=False, verbose_name='Vouchers'), + ), + migrations.AlterField( + model_name='studycolumns', + name='year_of_diagnosis', + field=models.BooleanField(default=True, verbose_name='Year of diagnosis (YYYY)'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='approaching_visits_for_mail_contact_visible', + field=models.BooleanField(default=True, verbose_name='post mail for approaching visits'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='approaching_visits_without_appointments_visible', + field=models.BooleanField(default=True, verbose_name='approaching visits'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='exceeded_visits_visible', + field=models.BooleanField(default=True, verbose_name='exceeded visit time'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='inconsistent_redcap_subject_visible', + field=models.BooleanField(default=True, verbose_name='inconsistent RED Cap subject'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='missing_redcap_subject_visible', + field=models.BooleanField(default=True, verbose_name='missing RED Cap subject'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='subject_no_visits_visible', + field=models.BooleanField(default=True, verbose_name='subject without visit'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='subject_require_contact_visible', + field=models.BooleanField(default=True, verbose_name='subject required contact'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='subject_voucher_expiry_visible', + field=models.BooleanField(default=False, verbose_name='subject vouchers almost expired'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='unfinished_appointments_visible', + field=models.BooleanField(default=True, verbose_name='unfinished appointments'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='unfinished_visits_visible', + field=models.BooleanField(default=True, verbose_name='unfinished visits'), + ), + migrations.AlterField( + model_name='studynotificationparameters', + name='visits_with_missing_appointments_visible', + field=models.BooleanField(default=True, verbose_name='visits with missing appointments'), + ), + migrations.AlterField( + model_name='studyredcapcolumns', + name='date_born', + field=models.BooleanField(default=True, verbose_name='Date of birth'), + ), + migrations.AlterField( + model_name='studyredcapcolumns', + name='dead', + field=models.BooleanField(default=True, verbose_name='Dead'), + ), + migrations.AlterField( + model_name='studyredcapcolumns', + name='languages', + field=models.BooleanField(default=True, verbose_name='Languages'), + ), + migrations.AlterField( + model_name='studyredcapcolumns', + name='mpower_id', + field=models.BooleanField(default=True, verbose_name='MPower ID'), + ), + migrations.AlterField( + model_name='studyredcapcolumns', + name='sex', + field=models.BooleanField(default=True, verbose_name='Sex'), + ), + migrations.AlterField( + model_name='studysubject', + name='brain_donation_agreement', + field=models.BooleanField(default=False, verbose_name='Brain donation agreement'), + ), + migrations.AlterField( + model_name='studysubject', + name='date_added', + field=models.DateField(auto_now_add=True, verbose_name='Added on'), + ), + migrations.AlterField( + model_name='studysubject', + name='datetime_contact_reminder', + field=models.DateTimeField(blank=True, null=True, verbose_name='Please make a contact on'), + ), + migrations.AlterField( + model_name='studysubject', + name='default_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='web.Location', verbose_name='Default appointment location'), + ), + migrations.AlterField( + model_name='studysubject', + name='diagnosis', + field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='Diagnosis'), + ), + migrations.AlterField( + model_name='studysubject', + name='endpoint_reached', + field=models.BooleanField(default=False, verbose_name='Endpoint Reached'), + ), + migrations.AlterField( + model_name='studysubject', + name='endpoint_reached_reason', + field=models.TextField(blank=True, max_length=2000, verbose_name='Endpoint reached comments'), + ), + migrations.AlterField( + model_name='studysubject', + name='exclude_reason', + field=models.TextField(blank=True, max_length=2000, verbose_name='Exclude reason'), + ), + migrations.AlterField( + model_name='studysubject', + name='excluded', + field=models.BooleanField(default=False, verbose_name='Excluded'), + ), + migrations.AlterField( + model_name='studysubject', + name='flying_team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='web.FlyingTeam', verbose_name='Default flying team location (if applicable)'), + ), + migrations.AlterField( + model_name='studysubject', + name='health_partner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='web.Worker', verbose_name='Health partner'), + ), + migrations.AlterField( + model_name='studysubject', + name='health_partner_feedback_agreement', + field=models.BooleanField(default=False, verbose_name='Agrees to give information to referral'), + ), + migrations.AlterField( + model_name='studysubject', + name='information_sent', + field=models.BooleanField(default=False, verbose_name='Information sent'), + ), + migrations.AlterField( + model_name='studysubject', + name='nd_number', + field=models.CharField(blank=True, max_length=25, verbose_name='ND number'), + ), + migrations.AlterField( + model_name='studysubject', + name='pd_in_family', + field=models.NullBooleanField(default=None, verbose_name='PD in family'), + ), + migrations.AlterField( + model_name='studysubject', + name='previously_in_study', + field=models.BooleanField(default=False, verbose_name='Previously in PDP study'), + ), + migrations.AlterField( + model_name='studysubject', + name='referral_letter', + field=models.FileField(blank=True, null=True, storage=django.core.files.storage.FileSystemStorage(location='~/tmp/upload'), upload_to='referral_letters', verbose_name='Referral letter'), + ), + migrations.AlterField( + model_name='studysubject', + name='resign_reason', + field=models.TextField(blank=True, max_length=2000, verbose_name='Resign reason'), + ), + migrations.AlterField( + model_name='studysubject', + name='resigned', + field=models.BooleanField(default=False, verbose_name='Resigned'), + ), + migrations.AlterField( + model_name='studysubject', + name='screening', + field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='Screening'), + ), + migrations.AlterField( + model_name='studysubject', + name='screening_number', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Screening number'), + ), + migrations.AlterField( + model_name='studysubject', + name='study', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='web.Study', verbose_name='Study'), + ), + migrations.AlterField( + model_name='studysubject', + name='subject', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='web.Subject', verbose_name='Subject'), + ), + migrations.AlterField( + model_name='studysubject', + name='type', + field=models.CharField(blank=True, choices=[('C', 'CONTROL'), ('P', 'PATIENT')], max_length=1, null=True, verbose_name='Type'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_1', + field=models.NullBooleanField(choices=[(True, 'Yes'), (False, 'No'), (None, 'N/A')], default=None, verbose_name='Visit 1 virus result'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_1_collection_date', + field=models.DateField(blank=True, null=True, verbose_name='Visit 1 virus collection date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_1_iga_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 1 IgA status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_1_igg_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 1 IgG status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_1_updated', + field=models.DateField(blank=True, null=True, verbose_name='Visit 1 virus result date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_2', + field=models.NullBooleanField(choices=[(True, 'Yes'), (False, 'No'), (None, 'N/A')], default=None, verbose_name='Visit 2 virus result'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_2_collection_date', + field=models.DateField(blank=True, null=True, verbose_name='Visit 2 virus collection date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_2_iga_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 2 IgA status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_2_igg_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 2 IgG status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_2_updated', + field=models.DateField(blank=True, null=True, verbose_name='Visit 2 virus result date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_3', + field=models.NullBooleanField(choices=[(True, 'Yes'), (False, 'No'), (None, 'N/A')], default=None, verbose_name='Visit 3 virus result'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_3_collection_date', + field=models.DateField(blank=True, null=True, verbose_name='Visit 3 virus collection date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_3_iga_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 3 IgA status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_3_igg_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 3 IgG status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_3_updated', + field=models.DateField(blank=True, null=True, verbose_name='Visit 3 virus result date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_4', + field=models.NullBooleanField(choices=[(True, 'Yes'), (False, 'No'), (None, 'N/A')], default=None, verbose_name='Visit 4 virus result'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_4_collection_date', + field=models.DateField(blank=True, null=True, verbose_name='Visit 4 virus collection date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_4_iga_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 4 IgA status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_4_igg_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 4 IgG status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_4_updated', + field=models.DateField(blank=True, null=True, verbose_name='Visit 4 virus result date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_5', + field=models.NullBooleanField(choices=[(True, 'Yes'), (False, 'No'), (None, 'N/A')], default=None, verbose_name='Visit 5 virus result'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_5_collection_date', + field=models.DateField(blank=True, null=True, verbose_name='Visit 5 virus collection date'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_5_iga_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 5 IgA status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_5_igg_status', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Visit 5 IgG status'), + ), + migrations.AlterField( + model_name='studysubject', + name='virus_test_5_updated', + field=models.DateField(blank=True, null=True, verbose_name='Visit 5 virus result date'), + ), + migrations.AlterField( + model_name='studysubject', + name='voucher_types', + field=models.ManyToManyField(blank=True, to='web.VoucherType', verbose_name='Voucher types'), + ), + migrations.AlterField( + model_name='studysubject', + name='year_of_diagnosis', + field=models.IntegerField(blank=True, null=True, verbose_name='Year of diagnosis (YYYY)'), + ), + migrations.AlterField( + model_name='studysubjectlist', + name='last_contact_attempt', + field=models.BooleanField(default=False, verbose_name='Last contact attempt'), + ), + migrations.AlterField( + model_name='studysubjectlist', + name='type', + field=models.CharField(blank=True, choices=[('GENERIC', 'Generic subject list'), ('NO_VISIT', 'Subjects without visit'), ('REQUIRE_CONTACT', 'Subjects required contact'), ('VOUCHER_EXPIRY', 'Subject with vouchers to be expired soon')], max_length=50, null=True, verbose_name='Type of list'), + ), + migrations.AlterField( + model_name='studysubjectlist', + name='visits', + field=models.BooleanField(default=True, verbose_name='Visits summary'), + ), + migrations.AlterField( + model_name='studyvisitlist', + name='type', + field=models.CharField(choices=[('GENERIC', 'Generic visit list'), ('EXCEEDED_TIME', 'Exceeded visit time'), ('UNFINISHED', 'Unfinished visits'), ('MISSING_APPOINTMENTS', 'Visits with missing appointments'), ('APPROACHING_WITHOUT_APPOINTMENTS', 'Approaching visits'), ('APPROACHING_FOR_MAIL_CONTACT', 'Post mail for approaching visits')], max_length=50, verbose_name='Type of list'), + ), + migrations.AlterField( + model_name='studyvisitlist', + name='visible_appointment_types_done', + field=models.BooleanField(default=False, verbose_name='Done appointments'), + ), + migrations.AlterField( + model_name='studyvisitlist', + name='visible_appointment_types_in_progress', + field=models.BooleanField(default=False, verbose_name='Appointments in progress'), + ), + migrations.AlterField( + model_name='studyvisitlist', + name='visible_appointment_types_missing', + field=models.BooleanField(default=False, verbose_name='Missing appointments'), + ), + migrations.AlterField( + model_name='subject', + name='address', + field=models.CharField(blank=True, max_length=255, verbose_name='Address'), + ), + migrations.AlterField( + model_name='subject', + name='city', + field=models.CharField(blank=True, max_length=50, verbose_name='City'), + ), + migrations.AlterField( + model_name='subject', + name='country', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='web.Country', verbose_name='Country'), + ), + migrations.AlterField( + model_name='subject', + name='date_born', + field=models.DateField(blank=True, null=True, verbose_name='Date of birth (YYYY-MM-DD)'), + ), + migrations.AlterField( + model_name='subject', + name='dead', + field=models.BooleanField(default=False, verbose_name='Deceased'), + ), + migrations.AlterField( + model_name='subject', + name='default_written_communication_language', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subjects_written_communication', to='web.Language', verbose_name='Default language for document generation'), + ), + migrations.AlterField( + model_name='subject', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail'), + ), + migrations.AlterField( + model_name='subject', + name='first_name', + field=models.CharField(max_length=50, verbose_name='First name'), + ), + migrations.AlterField( + model_name='subject', + name='languages', + field=models.ManyToManyField(blank=True, to='web.Language', verbose_name='Known languages'), + ), + migrations.AlterField( + model_name='subject', + name='last_name', + field=models.CharField(max_length=50, verbose_name='Last name'), + ), + migrations.AlterField( + model_name='subject', + name='next_of_keen_address', + field=models.TextField(blank=True, max_length=2000, verbose_name='Next of keen address'), + ), + migrations.AlterField( + model_name='subject', + name='next_of_keen_name', + field=models.CharField(blank=True, max_length=255, verbose_name='Next of keen'), + ), + migrations.AlterField( + model_name='subject', + name='next_of_keen_phone', + field=models.CharField(blank=True, max_length=50, verbose_name='Next of keen phone'), + ), + migrations.AlterField( + model_name='subject', + name='phone_number', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='Phone number'), + ), + migrations.AlterField( + model_name='subject', + name='phone_number_2', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='Phone number 2'), + ), + migrations.AlterField( + model_name='subject', + name='phone_number_3', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='Phone number 3'), + ), + migrations.AlterField( + model_name='subject', + name='postal_code', + field=models.CharField(blank=True, max_length=7, verbose_name='Postal code'), + ), + migrations.AlterField( + model_name='subject', + name='sex', + field=models.CharField(choices=[('M', 'Male'), ('F', 'Female')], max_length=1, verbose_name='Sex'), + ), + migrations.AlterField( + model_name='subject', + name='social_security_number', + field=models.CharField(blank=True, max_length=50, verbose_name='Social security number'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='address', + field=models.BooleanField(default=False, max_length=1, verbose_name='Address'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='city', + field=models.BooleanField(default=False, max_length=1, verbose_name='City'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='country', + field=models.BooleanField(default=False, max_length=1, verbose_name='Country'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='date_born', + field=models.BooleanField(default=False, max_length=1, verbose_name='Date of birth'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='dead', + field=models.BooleanField(default=True, max_length=1, verbose_name='Deceased'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='default_written_communication_language', + field=models.BooleanField(default=False, max_length=1, verbose_name='Default language for document generation'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='email', + field=models.BooleanField(default=False, max_length=1, verbose_name='E-mail'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='first_name', + field=models.BooleanField(default=True, max_length=1, verbose_name='First name'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='languages', + field=models.BooleanField(default=False, max_length=1, verbose_name='Known languages'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='last_name', + field=models.BooleanField(default=True, max_length=1, verbose_name='Last name'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='next_of_keen_address', + field=models.BooleanField(default=False, max_length=1, verbose_name='Next of kin address'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='next_of_keen_name', + field=models.BooleanField(default=False, max_length=1, verbose_name='Next of kin'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='next_of_keen_phone', + field=models.BooleanField(default=False, max_length=1, verbose_name='Next of kin phone'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='phone_number', + field=models.BooleanField(default=False, max_length=1, verbose_name='Phone number'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='phone_number_2', + field=models.BooleanField(default=False, max_length=1, verbose_name='Phone number 2'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='phone_number_3', + field=models.BooleanField(default=False, max_length=1, verbose_name='Phone number 3'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='postal_code', + field=models.BooleanField(default=False, max_length=1, verbose_name='Postal code'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='sex', + field=models.BooleanField(default=False, max_length=1, verbose_name='Sex'), + ), + migrations.AlterField( + model_name='subjectcolumns', + name='social_security_number', + field=models.BooleanField(default=False, verbose_name='Social security_number'), + ), + migrations.AlterField( + model_name='visit', + name='visit_number', + field=models.IntegerField(default=1, verbose_name='Visit number'), + ), + migrations.AlterField( + model_name='visitcolumns', + name='datetime_begin', + field=models.BooleanField(choices=[(True, 'Yes'), (False, 'No')], default=True, verbose_name='Visit starts date'), + ), + migrations.AlterField( + model_name='visitcolumns', + name='datetime_end', + field=models.BooleanField(choices=[(True, 'Yes'), (False, 'No')], default=True, verbose_name='Visit ends date'), + ), + migrations.AlterField( + model_name='visitcolumns', + name='is_finished', + field=models.BooleanField(choices=[(True, 'Yes'), (False, 'No')], default=True, verbose_name='Is finished'), + ), + migrations.AlterField( + model_name='visitcolumns', + name='post_mail_sent', + field=models.BooleanField(choices=[(True, 'Yes'), (False, 'No')], default=True, verbose_name='Post mail sent'), + ), + migrations.AlterField( + model_name='visitcolumns', + name='visible_appointment_types', + field=models.BooleanField(default=False, verbose_name='All appointments'), + ), + migrations.AlterField( + model_name='visitcolumns', + name='visit_number', + field=models.BooleanField(choices=[(True, 'Yes'), (False, 'No')], default=True, verbose_name='Visit number'), + ), + migrations.AlterField( + model_name='voucher', + name='activity_type', + field=models.CharField(blank=True, default='', max_length=40, verbose_name='Activity type'), + ), + migrations.AlterField( + model_name='voucher', + name='expiry_date', + field=models.DateField(verbose_name='Expiry date'), + ), + migrations.AlterField( + model_name='voucher', + name='feedback', + field=models.TextField(blank=True, max_length=2000, verbose_name='Feedback'), + ), + migrations.AlterField( + model_name='voucher', + name='hours', + field=models.IntegerField(default=0, verbose_name='Hours'), + ), + migrations.AlterField( + model_name='voucher', + name='issue_date', + field=models.DateField(verbose_name='Issue date'), + ), + migrations.AlterField( + model_name='voucher', + name='issue_worker', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issued_vouchers', to='web.Worker', verbose_name='Issued by'), + ), + migrations.AlterField( + model_name='voucher', + name='number', + field=models.CharField(max_length=50, unique=True, verbose_name='Number'), + ), + migrations.AlterField( + model_name='voucher', + name='status', + field=models.CharField(choices=[('NEW', 'New'), ('IN_USE', 'In use'), ('USED', 'Used'), ('EXPIRED', 'Expired'), ('REMOVED', 'Removed')], default='NEW', max_length=20, verbose_name='Status'), + ), + migrations.AlterField( + model_name='voucherpartnersession', + name='date', + field=models.DateTimeField(verbose_name='Issue date'), + ), + migrations.AlterField( + model_name='voucherpartnersession', + name='length', + field=models.IntegerField(verbose_name='Length (minutes)'), + ), + migrations.AlterField( + model_name='vouchertype', + name='code', + field=models.CharField(max_length=20, verbose_name='Code'), + ), + migrations.AlterField( + model_name='vouchertype', + name='description', + field=models.CharField(blank=True, max_length=1024, verbose_name='Description'), + ), + migrations.AlterField( + model_name='vouchertypeprice', + name='end_date', + field=models.DateField(verbose_name='End date'), + ), + migrations.AlterField( + model_name='vouchertypeprice', + name='price', + field=models.DecimalField(decimal_places=2, max_digits=6, verbose_name='Price'), + ), + migrations.AlterField( + model_name='vouchertypeprice', + name='start_date', + field=models.DateField(verbose_name='Start date'), + ), + migrations.AlterField( + model_name='worker', + name='address', + field=models.CharField(blank=True, max_length=255, verbose_name='Address'), + ), + migrations.AlterField( + model_name='worker', + name='city', + field=models.CharField(blank=True, max_length=50, verbose_name='City'), + ), + migrations.AlterField( + model_name='worker', + name='comment', + field=models.TextField(blank=True, max_length=1024, null=True, verbose_name='Comment'), + ), + migrations.AlterField( + model_name='worker', + name='country', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='web.Country', verbose_name='Country'), + ), + migrations.AlterField( + model_name='worker', + name='email', + field=models.EmailField(blank=True, max_length=254, verbose_name='E-mail'), + ), + migrations.AlterField( + model_name='worker', + name='fax_number', + field=models.CharField(blank=True, max_length=20, verbose_name='Fax number'), + ), + migrations.AlterField( + model_name='worker', + name='first_name', + field=models.CharField(blank=True, max_length=50, verbose_name='First name'), + ), + migrations.AlterField( + model_name='worker', + name='languages', + field=models.ManyToManyField(blank=True, to='web.Language', verbose_name='Known languages'), + ), + migrations.AlterField( + model_name='worker', + name='last_name', + field=models.CharField(blank=True, max_length=50, verbose_name='Last name'), + ), + migrations.AlterField( + model_name='worker', + name='locations', + field=models.ManyToManyField(blank=True, to='web.Location', verbose_name='Locations'), + ), + migrations.AlterField( + model_name='worker', + name='name', + field=models.CharField(blank=True, default='', max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='worker', + name='phone_number', + field=models.CharField(blank=True, max_length=20, verbose_name='Phone number'), + ), + migrations.AlterField( + model_name='worker', + name='phone_number_2', + field=models.CharField(blank=True, max_length=20, verbose_name='Phone number 2'), + ), + migrations.AlterField( + model_name='worker', + name='postal_code', + field=models.CharField(blank=True, max_length=7, verbose_name='Postal code'), + ), + migrations.AlterField( + model_name='worker', + name='specialization', + field=models.CharField(blank=True, max_length=20, verbose_name='Specialization'), + ), + migrations.AlterField( + model_name='worker', + name='unit', + field=models.CharField(blank=True, max_length=50, verbose_name='Unit'), + ), + migrations.AlterField( + model_name='worker', + name='voucher_partner_code', + field=models.CharField(blank=True, max_length=10, verbose_name='Code'), + ), + migrations.AlterField( + model_name='worker', + name='voucher_types', + field=models.ManyToManyField(blank=True, to='web.VoucherType', verbose_name='Voucher types'), + ), + migrations.AlterField( + model_name='workerstudyrole', + name='name', + field=models.CharField(choices=[('DOCTOR', 'Doctor'), ('NURSE', 'Nurse'), ('PSYCHOLOGIST', 'Psychologist'), ('TECHNICIAN', 'Technician'), ('SECRETARY', 'Secretary'), ('PROJECT MANAGER', 'Project Manager')], max_length=20, verbose_name='Role'), + ), + migrations.AlterField( + model_name='workerstudyrole', + name='permissions', + field=models.ManyToManyField(blank=True, to='auth.Permission', verbose_name='Worker Study Permissions'), + ), + ] diff --git a/smash/web/models/constants.py b/smash/web/models/constants.py index f061f3061fe03099a3355dd489092b1cb96f3fcf..34f44a4886090fcfb416ce73e20e7ab3ecb06970 100644 --- a/smash/web/models/constants.py +++ b/smash/web/models/constants.py @@ -2,6 +2,7 @@ import locale from django.core.files.storage import FileSystemStorage +from django.conf import settings BOOL_CHOICES = ((True, 'Yes'), (False, 'No')) SEX_CHOICES_MALE = 'M' @@ -154,4 +155,21 @@ VOUCHER_STATUS_CHOICES = ( (VOUCHER_STATUS_REMOVED, 'Removed'), ) -FILE_STORAGE = FileSystemStorage(location='uploads') +FILE_STORAGE = FileSystemStorage(location=settings.UPLOAD_ROOT) + +CUSTOM_FIELD_TYPE_TEXT = "TEXT" +CUSTOM_FIELD_TYPE_BOOLEAN = "BOOL" +CUSTOM_FIELD_TYPE_INTEGER = "INTEGER" +CUSTOM_FIELD_TYPE_DOUBLE = "DOUBLE" +CUSTOM_FIELD_TYPE_DATE = "DATE" +CUSTOM_FIELD_TYPE_SELECT_LIST = "SELECT_LIST" +CUSTOM_FIELD_TYPE_FILE = "FILE" +CUSTOM_FIELD_TYPE = ( + (CUSTOM_FIELD_TYPE_TEXT, "Text"), + (CUSTOM_FIELD_TYPE_BOOLEAN, "Boolean (True/False)"), + (CUSTOM_FIELD_TYPE_INTEGER, "Integer"), + (CUSTOM_FIELD_TYPE_DOUBLE, "Double (real number)"), + (CUSTOM_FIELD_TYPE_DATE, "Date"), + (CUSTOM_FIELD_TYPE_SELECT_LIST, "Select list"), + (CUSTOM_FIELD_TYPE_FILE, "File"), +) diff --git a/smash/web/models/custom_data/__init__.py b/smash/web/models/custom_data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e9587ebe1ac196cca3e76610cce3e02bcaff672c --- /dev/null +++ b/smash/web/models/custom_data/__init__.py @@ -0,0 +1,4 @@ +from .custom_study_subject_field import CustomStudySubjectField +from .custom_study_subject_value import CustomStudySubjectValue + +__all__ = [CustomStudySubjectField, CustomStudySubjectValue] diff --git a/smash/web/models/custom_data/custom_study_subject_field.py b/smash/web/models/custom_data/custom_study_subject_field.py new file mode 100644 index 0000000000000000000000000000000000000000..5e096da35e40804ca12975a303bfe153cc7e15e2 --- /dev/null +++ b/smash/web/models/custom_data/custom_study_subject_field.py @@ -0,0 +1,30 @@ +# coding=utf-8 + +from django.db import models + +from web.models.constants import CUSTOM_FIELD_TYPE + + +class CustomStudySubjectField(models.Model): + name = models.CharField(max_length=20, null=False, blank=False) + type = models.CharField(max_length=20, choices=CUSTOM_FIELD_TYPE, null=False, blank=False) + + possible_values = models.CharField(max_length=1024, null=True, blank=True, default='') + + default_value = models.CharField(max_length=20, null=True, blank=True) + readonly = models.BooleanField(default=False) + + required = models.BooleanField(default=False) + + unique = models.BooleanField(default=False) + + study = models.ForeignKey("web.Study", + verbose_name='Study', + editable=False, + null=False, + on_delete=models.CASCADE + ) + + +def get_study_subject_field_id(study_subject_field: CustomStudySubjectField) -> str: + return "custom_field-" + str(study_subject_field.id) diff --git a/smash/web/models/custom_data/custom_study_subject_value.py b/smash/web/models/custom_data/custom_study_subject_value.py new file mode 100644 index 0000000000000000000000000000000000000000..6398dfe615cdbcb1e5d07ae20a179f5348e46830 --- /dev/null +++ b/smash/web/models/custom_data/custom_study_subject_value.py @@ -0,0 +1,20 @@ +# coding=utf-8 + +from django.db import models + + +class CustomStudySubjectValue(models.Model): + value = models.CharField(max_length=2048, null=True, blank=True) + + study_subject_field = models.ForeignKey("web.CustomStudySubjectField", + verbose_name='Custom Field', + editable=False, + null=False, + on_delete=models.CASCADE + ) + study_subject = models.ForeignKey("web.StudySubject", + verbose_name='Study', + editable=False, + null=False, + on_delete=models.CASCADE + ) diff --git a/smash/web/models/study.py b/smash/web/models/study.py index 05e8706d90e9bd7c8a54ceb81cba1d576bdfadbe..f61834f6aacb44a17ec824b47ca7a0a672991630 100644 --- a/smash/web/models/study.py +++ b/smash/web/models/study.py @@ -1,10 +1,10 @@ # coding=utf-8 -from django.db import models +import re -from web.models import StudyColumns, StudyNotificationParameters, StudyRedCapColumns from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models -import re +from web.models import StudyColumns, StudyNotificationParameters, StudyRedCapColumns FOLLOW_UP_INCREMENT_IN_YEARS = 'years' FOLLOW_UP_INCREMENT_IN_DAYS = 'days' @@ -13,8 +13,8 @@ FOLLOW_UP_INCREMENT_UNIT_CHOICE = { FOLLOW_UP_INCREMENT_IN_DAYS: 'Days' } -class Study(models.Model): +class Study(models.Model): class Meta: app_label = 'web' @@ -87,12 +87,12 @@ class Study(models.Model): ) default_delta_time_for_follow_up_units = models.CharField(max_length=10, - choices=list(FOLLOW_UP_INCREMENT_UNIT_CHOICE.items()), - verbose_name='Units for the follow up incrementals', - help_text='Units for the number of days between visits for both patients and controls', - default=FOLLOW_UP_INCREMENT_IN_YEARS, - blank=False - ) + choices=list(FOLLOW_UP_INCREMENT_UNIT_CHOICE.items()), + verbose_name='Units for the follow up incrementals', + help_text='Units for the number of days between visits for both patients and controls', + default=FOLLOW_UP_INCREMENT_IN_YEARS, + blank=False + ) study_privacy_notice = models.ForeignKey("web.PrivacyNotice", verbose_name='Study Privacy Note', diff --git a/smash/web/models/study_subject.py b/smash/web/models/study_subject.py index 95cf4c5e8208253695e2a43e60f7bb8f48266640..3a438d81f07cbcc439dc591c2b938ca054ed907d 100644 --- a/smash/web/models/study_subject.py +++ b/smash/web/models/study_subject.py @@ -2,12 +2,14 @@ import logging import re +from typing import Optional from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from web.models import VoucherType, Appointment, Location, Visit, Provenance from web.models.constants import BOOL_CHOICES, SUBJECT_TYPE_CHOICES, FILE_STORAGE, BOOL_CHOICES_WITH_NONE +from web.models.custom_data import CustomStudySubjectValue, CustomStudySubjectField logger = logging.getLogger(__name__) @@ -369,9 +371,38 @@ class StudySubject(models.Model): else: return 'Normal' + @property + def custom_data_values(self): + values = CustomStudySubjectValue.objects.filter(study_subject=self) + fields = list(CustomStudySubjectField.objects.filter(study=self.study)) + for value in values: + fields.remove(value.study_subject_field) + for field in fields: + CustomStudySubjectValue.objects.create(study_subject=self, value=field.default_value, + study_subject_field=field) + return CustomStudySubjectValue.objects.filter(study_subject=self) + + def set_custom_data_value(self, custom_study_subject_field: CustomStudySubjectField, value: str): + found = False + for existing_value in self.customstudysubjectvalue_set.all(): + if existing_value.study_subject_field == custom_study_subject_field: + found = True + existing_value.value = value + existing_value.save() + if not found: + self.customstudysubjectvalue_set.add( + CustomStudySubjectValue.objects.create(study_subject=self, value=value, + study_subject_field=custom_study_subject_field)) + def __str__(self): return "%s %s" % (self.subject.first_name, self.subject.last_name) + def get_custom_data_value(self, custom_field) -> Optional[CustomStudySubjectValue]: + for value in self.custom_data_values: + if value.study_subject_field == custom_field: + return value + return None + # SIGNALS @receiver(post_save, sender=StudySubject) diff --git a/smash/web/static/js/smash.js b/smash/web/static/js/smash.js index 4737d47311ea8a2fadf9346de3fb557ec7ceeaeb..a7a3dcba9c3c820b7337fd3d5ec3451fdf77d8a7 100644 --- a/smash/web/static/js/smash.js +++ b/smash/web/static/js/smash.js @@ -105,16 +105,16 @@ function createColumn(dataType, name, filter, visible, sortable, renderFunction) We use an auxiliary function to create the function due to the lack of block scope in JS. https://stackoverflow.com/questions/750486/javascript-closure-inside-loops-simple-practical-example */ -function createRenderFunction(columnRow){ - return function(data, type, row, meta){ +function createRenderFunction(columnRow) { + return function (data, type, row, meta) { /* Fancy highlighting for the column matches. */ - if(columnRow.filter == 'string_filter'){ + if (columnRow.filter == 'string_filter') { //obtain the input element by its placeholdername which matches the column name: e.g.: <input type="text" style="width:80px" placeholder="First name"> filter_value = $(`input[placeholder="${columnRow.name}"]`).val(); //if there is any filter, we highlight the matching part - if(filter_value != undefined && filter_value.length > 0){ + if (filter_value != undefined && filter_value.length > 0) { //global and case insensitive replacement return data.replace(RegExp(filter_value, 'gi'), `<span class="highlight_match">${filter_value}</span>`); } @@ -128,7 +128,7 @@ function getColumns(columns, getSubjectEditUrl) { var result = []; for (var i = 0; i < columns.length; i++) { var columnRow = columns[i]; - if (columnRow.type === "address"){ + if (columnRow.type === "address") { var renderFunction = (function () { return function (data, type, row) { @@ -186,7 +186,17 @@ function createFilter(columnsDefinition) { var column = columnsDefinition[i]; var element = document.createElement("th"); if (column.filter !== null) { - element.innerHTML = "<div name='" + column.filter + "'>" + column.name + "</div>"; + if (column.filter.indexOf('select_filter:') === 0) { + var options = column.filter.replace('select_filter:', '').split(";"); + var content = '<select style="width:60px" ><option value selected="selected">---</option>'; + for (var j = 0; j < options.length; j++) { + content += '<option value="' + options[j] + '">' + options[j] + '</option>'; + } + content += '</select>'; + element.innerHTML = content; + } else { + element.innerHTML = "<div name='" + column.filter + "'>" + column.name + "</div>"; + } } footerRow.appendChild(element); } @@ -222,9 +232,9 @@ function create_visit_row(visit) { text = `<span title="Appointments are taking place.">IN PROGRESS</span>`; } - + var start_date = moment(visit.datetime_start); - var end_date = moment(visit.datetime_end); + var end_date = moment(visit.datetime_end); text += ` <br/> <span data-html="true" title="From: ${start_date.format('ddd Do MMMM YYYY')} </br> To: ${end_date.format('ddd Do MMMM YYYY')}"> @@ -234,14 +244,14 @@ function create_visit_row(visit) { <span data-html="true" title="From: ${start_date.format('ddd Do MMMM YYYY')} </br> To: ${end_date.format('ddd Do MMMM YYYY')}"> To: ${end_date.format('D MMM. YYYY')} </span>` - + text += `<br/><span data-html="true" title="Visit details<br/>Appointment Types:<br/><div class='appointment_type_list'>${visit.appointment_types.join('<br/>')}</div>"> <a href="${visit.edit_visit_url}"><i class="fa fa-list" aria-hidden="true"></i></a> </span>`; - if(!visit.is_finished){ + if (!visit.is_finished) { text += `<span title="Add new appointment to visit"><a href="${visit.add_appointment_url}"><i class="fa fa-plus-square-o" aria-hidden="true"></i></a></span>`; - }else{ + } else { text += `<span title="Visit is marked as finished" ><i class="fa fa-check-circle" aria-hidden="true"></i></span>`; } @@ -317,7 +327,7 @@ function createTable(params) { }); //replace the hyphen with non breaking space hyphen to ensure the column names are more readable - $(tableElement).find('th:contains("RT-PCR")').each(function(){ + $(tableElement).find('th:contains("RT-PCR")').each(function () { non_breaking_hyphen = $.parseHTML('‑'); var text = $(this).text().replace('-', $(non_breaking_hyphen).text()); $(this).text(text); @@ -464,17 +474,22 @@ function createTable(params) { columnDefs: columnDefs, order: [[0, 'desc']], buttons: [ - { extend: 'excel', - exportOptions: { - columns: ':visible', - modifier: { page: 'all', search: 'applied', order: 'applied' } - }, - text: `<span class='excel-export-button'><i class="fa fa-file-excel-o" aria-hidden="true"></i> Export selection to excel</span>` + { + extend: 'excel', + exportOptions: { + columns: ':visible', + modifier: {page: 'all', search: 'applied', order: 'applied'} + }, + text: `<span class='excel-export-button'><i class="fa fa-file-excel-o" aria-hidden="true"></i> Export selection to excel</span>` } ], dom: dom_settings, //see docs: https://datatables.net/reference/option/dom - initComplete: function(settings, json) { - $('.excel-export-button').parents('button').tooltip({container: 'body', title: `Show <b>All</b> entries to consider all rows instead of current page. Filters and sorting will prevail.`, html: true}); + initComplete: function (settings, json) { + $('.excel-export-button').parents('button').tooltip({ + container: 'body', + title: `Show <b>All</b> entries to consider all rows instead of current page. Filters and sorting will prevail.`, + html: true + }); } }); @@ -502,7 +517,7 @@ function createTable(params) { var column = table.column($(this).attr('data-column')); // Toggle the visibility column.visible(visible); - }); + }); } //------------------------------------------------------ diff --git a/smash/web/templates/custom_study_subject_field/add.html b/smash/web/templates/custom_study_subject_field/add.html new file mode 100644 index 0000000000000000000000000000000000000000..b61d7bffecd820ac8c78d7754b94445c80798c9a --- /dev/null +++ b/smash/web/templates/custom_study_subject_field/add.html @@ -0,0 +1,9 @@ +{% extends "custom_study_subject_field/add_edit.html" %} + +{% block page_header %}Add custom study subject field{% endblock page_header %} + +{% block title %}{{ block.super }} - Add custom study subject field{% endblock %} + +{% block form-title %}Enter custom study subject field details{% endblock %} + +{% block save-button %}Add{% endblock %} diff --git a/smash/web/templates/custom_study_subject_field/add_edit.html b/smash/web/templates/custom_study_subject_field/add_edit.html new file mode 100644 index 0000000000000000000000000000000000000000..c7d6fafaa29d65a9377c33362fdf9774ff46a2f8 --- /dev/null +++ b/smash/web/templates/custom_study_subject_field/add_edit.html @@ -0,0 +1,105 @@ +{% extends "_base.html" %} +{% load static %} +{% load filters %} + +{% block styles %} + {{ block.super }} + <link rel="stylesheet" href="{% static 'AdminLTE/plugins/awesomplete/awesomplete.css' %}"/> + + {% include "includes/datepicker.css.html" %} +{% endblock styles %} + +{% block ui_active_tab %}'configuration'{% endblock ui_active_tab %} + +{% block page_description %}{% endblock page_description %} + +{% block breadcrumb %} + {% include "study/breadcrumb.html" %} +{% endblock breadcrumb %} + +{% block maincontent %} + + {% block content %} + <div class="row"> + <div class="col-md-12"> + <div class="box box-success"> + <div class="box-header with-border"> + <h3 class="box-title">{% block form-title %}Enter custom study subject field + details{% endblock %}</h3> + </div> + + + <form method="post" action="" class="form-horizontal"> + {% csrf_token %} + + <div class="box-body"> + {% for field in form %} + <div class="form-group {% if field.errors %}has-error{% endif %}"> + <label class="col-sm-4 col-lg-offset-1 col-lg-2 control-label"> + {{ field.label }} + </label> + + <div class="col-sm-8 col-lg-4"> + {{ field|add_class:'form-control' }} + </div> + + {% if field.errors %} + <span class="help-block"> + {{ field.errors }} + </span> + {% endif %} + </div> + {% endfor %} + </div><!-- /.box-body --> + <div class="box-footer"> + <div class="col-sm-6"> + <button type="submit" class="btn btn-block btn-success">{% block save-button %} + Add{% endblock %} + </button> + </div> + <div class="col-sm-6"> + <a href="{% url 'web.views.edit_study' study_id %}" + class="btn btn-block btn-default">Cancel</a> + </div> + </div><!-- /.box-footer --> + </form> + </div> + + </div> + </div> + + {% endblock %} + + +{% endblock maincontent %} + +{% block scripts %} + {{ block.super }} + + <script src="{% static 'AdminLTE/plugins/awesomplete/awesomplete.min.js' %}"></script> + + {% include "includes/datetimepicker.js.html" %} + + <script> + var hide_fields = function () { + if ($('#id_type').val() === 'SELECT_LIST') { + $('#id_possible_values').parent().parent().show(); + } else { + $('#id_possible_values').parent().parent().hide(); + } + if ($('#id_type').val() === 'FILE') { + $('#id_default_value').parent().parent().hide(); + $('#id_unique').parent().parent().hide(); + } else { + $('#id_default_value').parent().parent().show(); + $('#id_unique').parent().parent().show(); + } + } + hide_fields(); + $('#id_type').on('change', function () { + hide_fields(); + }); + + </script> + +{% endblock scripts %} diff --git a/smash/web/templates/custom_study_subject_field/edit.html b/smash/web/templates/custom_study_subject_field/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..653879e55187cab20a865c8ad1a562b5f3200241 --- /dev/null +++ b/smash/web/templates/custom_study_subject_field/edit.html @@ -0,0 +1,9 @@ +{% extends "custom_study_subject_field/add_edit.html" %} + +{% block page_header %}Edit custom study subject field{% endblock page_header %} + +{% block title %}{{ block.super }} - Edit custom study subject field{% endblock %} + +{% block form-title %}Enter custom study subject field details{% endblock %} + +{% block save-button %}Save{% endblock %} diff --git a/smash/web/templates/export/index.html b/smash/web/templates/export/index.html index 4e99d70b88409d4c55f35182c106db6c0c3f3cc9..114714cb2c09b785129ceaed0d7a1c34a6ad6a37 100644 --- a/smash/web/templates/export/index.html +++ b/smash/web/templates/export/index.html @@ -70,10 +70,10 @@ </ul> </div> <ul> - <li><a onclick="addFields(this, 'subject_fields')" href="{% url 'web.views.export_to_excel' 'subjects' %}"><i class="fa fa-file-excel-o"></i> XLS - + <li><a onclick="addFields(this, 'subject_fields')" href="{% url 'web.views.export_to_excel' study_id 'subjects' %}"><i class="fa fa-file-excel-o"></i> XLS - Excel</a> </li> - <li><a onclick="addFields(this, 'subject_fields')" href="{% url 'web.views.export_to_csv' 'subjects' %}"><i class="fa fa-file-text-o"></i> CSV - + <li><a onclick="addFields(this, 'subject_fields')" href="{% url 'web.views.export_to_csv' study_id 'subjects' %}"><i class="fa fa-file-text-o"></i> CSV - Text based</a></li> </ul> <h3>Appointments</h3> @@ -89,9 +89,9 @@ </ul> </div> <ul> - <li><a onclick="addFields(this, 'appointment_fields')" href="{% url 'web.views.export_to_excel' 'appointments' %}"><i class="fa fa-file-excel-o"></i> XLS - + <li><a onclick="addFields(this, 'appointment_fields')" href="{% url 'web.views.export_to_excel' study_id 'appointments' %}"><i class="fa fa-file-excel-o"></i> XLS - Excel</a></li> - <li><a onclick="addFields(this, 'appointment_fields')" href="{% url 'web.views.export_to_csv' 'appointments' %}"><i class="fa fa-file-text-o"></i> CSV - + <li><a onclick="addFields(this, 'appointment_fields')" href="{% url 'web.views.export_to_csv' study_id 'appointments' %}"><i class="fa fa-file-text-o"></i> CSV - Text based</a></li> </ul> diff --git a/smash/web/templates/includes/custom_study_subject_field_box.html b/smash/web/templates/includes/custom_study_subject_field_box.html new file mode 100644 index 0000000000000000000000000000000000000000..23e0c461a4eaaaaa706035498bb2b5f494b8350d --- /dev/null +++ b/smash/web/templates/includes/custom_study_subject_field_box.html @@ -0,0 +1,52 @@ +<div class="row"> + <div class="col-lg-12"> + <div class="box box-success"> + <div class="box-header with-border"> + <h3>Custom study subject fields <a title="add a new field" + id="add-study-subject-field" + href="{% url 'web.views.custom_study_subject_field_add' study.id %}" + class="text-primary" + ><i class="fa fa-plus-circle text-success"></i></a></h3> + </div> + <div class="box-body"> + <table class="table table-bordered table-striped"> + <thead> + <tr> + <th class="text-center">Name</th> + <th class="text-center">Type</th> + <th class="text-center">Default value</th> + <th class="text-center">Readonly</th> + <th class="text-center">Unique</th> + <th class="text-center">Obligatory</th> + <th class="text-center">Edit</th> + <th class="text-center">Remove</th> + </tr> + </thead> + <tbody> + {% for field in fields %} + <tr> + <td class="text-center">{{ field.name }}</td> + <td class="text-center">{{ field.type }}</td> + <td class="text-center">{{ field.default_value }}</td> + <td class="text-center">{% if field.readonly %}YES{% else %}NO{% endif %}</td> + <td class="text-center">{% if field.unique %}YES{% else %}NO{% endif %}</td> + <td class="text-center">{% if field.obligatory %}YES{% else %}NO{% endif %}</td> + <td class="text-center"><a title="edit field" + href="{% url 'web.views.custom_study_subject_field_edit' field.study.id field.id %}" + type="button" + class="btn btn-block btn-default" + >EDIT</a></td> + <td class="text-center"><a title="remove field" + href="{% url 'web.views.custom_study_subject_field_delete' field.study.id field.id %}" + type="button" + class="btn btn-block btn-danger" + >REMOVE</a></td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + + </div> +</div> diff --git a/smash/web/templates/sidebar.html b/smash/web/templates/sidebar.html index a575319957dc7bab0a8d2cf03b4204c9796d53b3..bea2f85b80f6a78b75564abe3becd7a04f0a7427 100644 --- a/smash/web/templates/sidebar.html +++ b/smash/web/templates/sidebar.html @@ -26,12 +26,12 @@ {% endif %} {% if "change_worker" in permissions %} - <li data-desc="workers"> - <a href="{% url 'web.views.workers' %}"> - <i class="fa fa-user-md"></i> - <span>Worker</span> - </a> - </li> + <li data-desc="workers"> + <a href="{% url 'web.views.workers' %}"> + <i class="fa fa-user-md"></i> + <span>Worker</span> + </a> + </li> {% endif %} {% if equipment_perms %} @@ -85,57 +85,59 @@ {% if "export_subjects" in permissions %} <li data-desc="export"> - <a href="{% url 'web.views.export' %}"> + <a href="{% url 'web.views.export' study_id %}"> <i class="fa fa-file-excel-o"></i> <span>Export</span> </a> </li> {% endif %} - {% if study.has_vouchers and "change_voucher" in permissions%} - <li data-desc="vouchers"> - <a href="{% url 'web.views.vouchers' %}"> - <i class="fa fa-user-md"></i> - <span>Vouchers</span> - </a> - </li> + {% if study.has_vouchers and "change_voucher" in permissions %} + <li data-desc="vouchers"> + <a href="{% url 'web.views.vouchers' %}"> + <i class="fa fa-user-md"></i> + <span>Vouchers</span> + </a> + </li> {% endif %} {% if conf_perms %} - <li data-desc="configuration" class="treeview"> - <a href="#"> - <i class="fa fa-wrench"></i> <span>Configuration</span> - <span class="pull-right-container"> + <li data-desc="configuration" class="treeview"> + <a href="#"> + <i class="fa fa-wrench"></i> <span>Configuration</span> + <span class="pull-right-container"> <i class="fa fa-angle-left pull-right"></i> </span> - </a> - <ul class="treeview-menu"> - {% if "change_configurationitem" in permissions %} - <li data-desc="settings"><a href="{% url 'web.views.configuration' %}">General</a></li> - {% endif %} - - {% if "change_language" in permissions %} - <li data-desc="languages"><a href="{% url 'web.views.languages' %}">Languages</a></li> - {% endif %} - - {% if study.has_voucher_types and "change_vouchertype" in permissions %} - <li data-desc="voucher_types"><a href="{% url 'web.views.voucher_types' %}">Voucher types</a></li> - {% endif %} - - {% if study.has_vouchers and "change_worker" in permissions %} - <li data-desc="voucher_partner"><a href="{% url 'web.views.workers' 'VOUCHER_PARTNER' %}">Voucher partners</a></li> - {% endif %} - - {% if "change_worker" in permissions %} - <li data-desc="health_partner"><a href="{% url 'web.views.workers' 'HEALTH_PARTNER' %}">Health partners</a></li> - {% endif %} - - {% if "change_study" in permissions %} - <li data-desc="study_conf"><a href="{% url 'web.views.edit_study' study_id %}">Study</a></li> - {% endif %} - </ul> - </li> + </a> + <ul class="treeview-menu"> + {% if "change_configurationitem" in permissions %} + <li data-desc="settings"><a href="{% url 'web.views.configuration' %}">General</a></li> + {% endif %} + + {% if "change_language" in permissions %} + <li data-desc="languages"><a href="{% url 'web.views.languages' %}">Languages</a></li> + {% endif %} + + {% if study.has_voucher_types and "change_vouchertype" in permissions %} + <li data-desc="voucher_types"><a href="{% url 'web.views.voucher_types' %}">Voucher types</a></li> + {% endif %} + + {% if study.has_vouchers and "change_worker" in permissions %} + <li data-desc="voucher_partner"><a href="{% url 'web.views.workers' 'VOUCHER_PARTNER' %}">Voucher + partners</a></li> + {% endif %} + + {% if "change_worker" in permissions %} + <li data-desc="health_partner"><a href="{% url 'web.views.workers' 'HEALTH_PARTNER' %}">Health + partners</a></li> + {% endif %} + + {% if "change_study" in permissions %} + <li data-desc="study_conf"><a href="{% url 'web.views.edit_study' study_id %}">Study</a></li> + {% endif %} + </ul> + </li> {% endif %} diff --git a/smash/web/templates/study/edit.html b/smash/web/templates/study/edit.html index 8e20e1e1ace1e7d4ea94f0dae72c9f53dd812247..23fb256da409bae69b027de9d8fcbd93f3bf1dd9 100644 --- a/smash/web/templates/study/edit.html +++ b/smash/web/templates/study/edit.html @@ -56,7 +56,8 @@ <label for="{{ field.id_for_label }}" 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> + <i class="fa fa-info-circle" aria-hidden="true" data-toggle="tooltip" + data-placement="bottom" title="{{ field.help_text }}"></i> {% endif %} </label> @@ -117,6 +118,8 @@ </div> </div><!-- /.box-body --> + {% include 'includes/custom_study_subject_field_box.html' with study=study fields=study.customstudysubjectfield_set.all %} + <div class="box-header with-border"> <h3>Available subject study data</h3> </div> diff --git a/smash/web/templates/subjects/index.html b/smash/web/templates/subjects/index.html index 29a4d2308145826a49e5d49091eb949a3157b524..acd65e2701d66b6b21151692ddc55cea85393689 100644 --- a/smash/web/templates/subjects/index.html +++ b/smash/web/templates/subjects/index.html @@ -9,35 +9,42 @@ .box-body { overflow-x: scroll; } + .highlight_match { font-weight: bolder; border-bottom: black 1px solid; } - .dataTables_length{ + .dataTables_length { display: inline-block; } - .dt-buttons{ + + .dt-buttons { margin-left: 10px; } - .visit_row{ + + .visit_row { font-size: 10pt; max-width: 22ch; min-width: 18ch; text-align: center; } - .visit_row > span{ + + .visit_row > span { padding-right: 2px; padding-left: 2px; } - .visit_row > span > a{ + + .visit_row > span > a { color: inherit; } - .appointment_type_list{ + + .appointment_type_list { margin-top: 10px; text-align: left; } - td.subject-address{ + + td.subject-address { word-break: break-word; } </style> @@ -45,10 +52,10 @@ {% endblock styles %} {% block ui_active_tab %}'subjects'{% endblock ui_active_tab %} -{% block page_header %}{{ list_description }}{% endblock page_header %} +{% block page_header %}{{ list_description }}{% endblock page_header %} {% block page_description %}{% endblock page_description %} -{% block title %}{{ block.super }} - {{ list_description }}{% endblock %} +{% block title %}{{ block.super }} - {{ list_description }}{% endblock %} {% block breadcrumb %} {% include "subjects/breadcrumb.html" %} @@ -57,7 +64,7 @@ {% block maincontent %} <div> - <a href="{% url 'web.views.subject_add' %}" class="btn btn-app" + <a href="{% url 'web.views.subject_add' study_id %}" class="btn btn-app" {% if not "add_subject" in permissions %} disabled {% endif %} @@ -80,14 +87,22 @@ {{ block.super }} <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/jquery.dataTables.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/dataTables.buttons.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/buttons.bootstrap.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/buttons.colVis.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/buttons.html5.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/JSZip/jszip.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/pdfmake/pdfmake.min.js' %}"></script> - <script type="text/javascript" src="{% static 'AdminLTE/plugins/datatables/extensions/pdfmake/vfs_fonts.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/dataTables.buttons.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/buttons.bootstrap.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/buttons.colVis.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/Buttons/js/buttons.html5.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/JSZip/jszip.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/pdfmake/pdfmake.min.js' %}"></script> + <script type="text/javascript" + src="{% static 'AdminLTE/plugins/datatables/extensions/pdfmake/vfs_fonts.js' %}"></script> <script type="text/javascript" src="{% static 'js/subject.js' %}"></script> <script> diff --git a/smash/web/tests/api_views/test_serialization_utils.py b/smash/web/tests/api_views/test_serialization_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..fa556639930051bd06636dd2a4af2b5e0dbf0fe8 --- /dev/null +++ b/smash/web/tests/api_views/test_serialization_utils.py @@ -0,0 +1,33 @@ +import logging + +from django.core.files.images import ImageFile +from django.test import TestCase + +from web.api_views.serialization_utils import * +from web.tests.functions import get_resource_path + +logger = logging.getLogger(__name__) + + +class SerializationUtilsTests(TestCase): + def test_str_to_yes_no(self): + self.assertEqual(str_to_yes_no('true'), 'YES') + with self.assertRaises(AttributeError): + str_to_yes_no(None) + self.assertEqual(str_to_yes_no('potato'), 'NO') + self.assertEqual(str_to_yes_no('false'), 'NO') + + def test_str_to_yes_no_null(self): + self.assertEqual(str_to_yes_no_null('true'), 'YES') + self.assertEqual(str_to_yes_no_null(None), None) + self.assertEqual(str_to_yes_no_null('potato'), 'NO') + self.assertEqual(str_to_yes_no_null('false'), 'NO') + + def test_bool_to_yes_no_null(self): + self.assertEqual(bool_to_yes_no_null(True), 'YES') + self.assertEqual(bool_to_yes_no_null(None), 'N/A') + self.assertEqual(bool_to_yes_no_null(False), 'NO') + + def test_bool_to_yes_no(self): + self.assertEqual(bool_to_yes_no(True), 'YES') + self.assertEqual(bool_to_yes_no(False), 'NO') diff --git a/smash/web/tests/api_views/test_subject.py b/smash/web/tests/api_views/test_subject.py index bbfecae04b530cfa628872ba14132ca5feec0268..7f5c992faac5dd1844a2e6c62c0f8687dc91c098 100644 --- a/smash/web/tests/api_views/test_subject.py +++ b/smash/web/tests/api_views/test_subject.py @@ -4,18 +4,24 @@ import json import logging from django.urls import reverse +from parameterized import parameterized from django.utils import timezone from six import ensure_str from web.api_views.subject import get_subjects_order, get_subjects_filtered, serialize_subject, get_subject_columns from web.importer.warning_counter import MsgCounterHandler from web.models import StudySubject, Appointment, Study, Worker, SubjectColumns, StudyColumns -from web.models.constants import GLOBAL_STUDY_ID, SUBJECT_TYPE_CHOICES_PATIENT, SUBJECT_TYPE_CHOICES_CONTROL +from web.models.constants import GLOBAL_STUDY_ID, SUBJECT_TYPE_CHOICES_PATIENT, SUBJECT_TYPE_CHOICES_CONTROL, \ + CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, CUSTOM_FIELD_TYPE_DOUBLE, \ + CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id from web.models.study_subject_list import SUBJECT_LIST_GENERIC, SUBJECT_LIST_NO_VISIT, SUBJECT_LIST_REQUIRE_CONTACT, \ StudySubjectList, SUBJECT_LIST_VOUCHER_EXPIRY from web.tests import LoggedInWithWorkerTestCase from web.tests.functions import create_study_subject, create_get_suffix, create_visit, \ - create_appointment, create_empty_study_columns, create_contact_attempt, create_flying_team, create_worker + create_appointment, create_empty_study_columns, create_contact_attempt, create_flying_team, create_worker, \ + get_test_study from web.views.notifications import get_today_midnight_date logger = logging.getLogger(__name__) @@ -114,31 +120,33 @@ class TestSubjectApi(LoggedInWithWorkerTestCase): self.assertTrue(referral_name in referrals) def test_subjects_general(self): - response = self.client.get(reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_GENERIC})) + response = self.client.get(reverse('web.api.subjects', kwargs={'subject_list_type': SUBJECT_LIST_GENERIC})) self.assertEqual(response.status_code, 200) def test_subjects_general_with_special_characters(self): contact_attempt = create_contact_attempt(subject=self.study_subject) contact_attempt.worker.first_name = "Ã special character" contact_attempt.worker.save() - response = self.client.get(reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_GENERIC})) + response = self.client.get(reverse('web.api.subjects', kwargs={'subject_list_type': SUBJECT_LIST_GENERIC})) self.assertEqual(response.status_code, 200) logger.debug(response.content) def test_subjects_voucher_almost_expired(self): - response = self.client.get(reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_VOUCHER_EXPIRY})) + response = self.client.get( + reverse('web.api.subjects', kwargs={'subject_list_type': SUBJECT_LIST_VOUCHER_EXPIRY})) self.assertEqual(response.status_code, 200) def test_subjects_no_visit(self): - response = self.client.get(reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_NO_VISIT})) + response = self.client.get(reverse('web.api.subjects', kwargs={'subject_list_type': SUBJECT_LIST_NO_VISIT})) self.assertEqual(response.status_code, 200) def test_subjects_require_contact(self): - response = self.client.get(reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_REQUIRE_CONTACT})) + response = self.client.get( + reverse('web.api.subjects', kwargs={'subject_list_type': SUBJECT_LIST_REQUIRE_CONTACT})) self.assertEqual(response.status_code, 200) def test_subjects_invalid(self): - response = self.client.get(reverse('web.api.subjects', kwargs={'type': "bla"})) + response = self.client.get(reverse('web.api.subjects', kwargs={'subject_list_type': "bla"})) self.assertEqual(response.status_code, 500) def test_subjects_general_search(self): @@ -150,13 +158,15 @@ class TestSubjectApi(LoggedInWithWorkerTestCase): "columns[0][search][value]": "another_name", "columns[0][data]": "first_name" } - url = ("%s" + create_get_suffix(params)) % reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_GENERIC}) + url = ("%s" + create_get_suffix(params)) % reverse('web.api.subjects', + kwargs={'subject_list_type': SUBJECT_LIST_GENERIC}) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertFalse(name.encode('utf8') in response.content) params["columns[0][search][value]"] = name - url = ("%s" + create_get_suffix(params)) % reverse('web.api.subjects', kwargs={'type': SUBJECT_LIST_GENERIC}) + url = ("%s" + create_get_suffix(params)) % reverse('web.api.subjects', + kwargs={'subject_list_type': SUBJECT_LIST_GENERIC}) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertTrue(name.encode('utf8') in response.content) @@ -577,8 +587,8 @@ class TestSubjectApi(LoggedInWithWorkerTestCase): appointment.status = Appointment.APPOINTMENT_STATUS_FINISHED appointment.save() - visit = create_visit(subject2, datetime_begin=get_today_midnight_date() + datetime.timedelta(days=31), - datetime_end=get_today_midnight_date() + datetime.timedelta(days=61)) + create_visit(subject2, datetime_begin=get_today_midnight_date() + datetime.timedelta(days=31), + datetime_end=get_today_midnight_date() + datetime.timedelta(days=61)) self.check_subject_ordered("visit_1", [subject, subject2]) @@ -664,21 +674,21 @@ class TestSubjectApi(LoggedInWithWorkerTestCase): def test_get_subject_order_for_columns(self): study = Study.objects.filter(id=GLOBAL_STUDY_ID)[0] - list = StudySubjectList.objects.create(study=study, type="custom", - visible_subject_columns=SubjectColumns.objects.create(), - visible_subject_study_columns=StudyColumns.objects.create()) - for key in vars(list.visible_subject_columns): - if getattr(list.visible_subject_columns, key) == False: - setattr(list.visible_subject_columns, key, True) - list.visible_subject_columns.save() - - for key in vars(list.visible_subject_study_columns): - if getattr(list.visible_subject_study_columns, key) == False: - setattr(list.visible_subject_study_columns, key, True) - list.visible_subject_study_columns.save() + subject_list = StudySubjectList.objects.create(study=study, type="custom", + visible_subject_columns=SubjectColumns.objects.create(), + visible_subject_study_columns=StudyColumns.objects.create()) + for key in vars(subject_list.visible_subject_columns): + if not getattr(subject_list.visible_subject_columns, key): + setattr(subject_list.visible_subject_columns, key, True) + subject_list.visible_subject_columns.save() + + for key in vars(subject_list.visible_subject_study_columns): + if not getattr(subject_list.visible_subject_study_columns, key): + setattr(subject_list.visible_subject_study_columns, key, True) + subject_list.visible_subject_study_columns.save() for key in vars(study.columns): - if getattr(study.columns, key) == False: + if not getattr(study.columns, key): setattr(study.columns, key, True) study.columns.save() @@ -708,3 +718,99 @@ class TestSubjectApi(LoggedInWithWorkerTestCase): self.check_subject_filtered([["virus_test_1", "true"]], [study_subject_positive]) self.check_subject_filtered([["virus_test_1", "false"]], [study_subject_negative]) self.check_subject_filtered([["virus_test_1", "inconclusive"]], [study_subject_inconclusive]) + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, '103'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, '205.5'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-05'), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, 'BLA'), + ]) + def test_subjects_filter_by_custom_field(self, _, field_type, value): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="", type=field_type) + study_subject = create_study_subject(2) + study_subject.set_custom_data_value(field, value) + + self.check_subject_filtered([[get_study_subject_field_id(field), value]], [study_subject]) + self.check_subject_filtered([[get_study_subject_field_id(field), value + "-bla"]], []) + + def test_subjects_filter_by_custom_file_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="", + type=CUSTOM_FIELD_TYPE_FILE) + study_subject = create_study_subject(2) + study_subject.set_custom_data_value(field, 'test_file.txt') + + self.check_subject_filtered([[get_study_subject_field_id(field), 'Available']], [study_subject]) + self.check_subject_filtered([[get_study_subject_field_id(field), "N/A"]], []) + + def test_subjects_filter_by_two_custom_fields(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", + type=CUSTOM_FIELD_TYPE_TEXT) + field2 = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", + type=CUSTOM_FIELD_TYPE_TEXT) + study_subject = create_study_subject(2) + study_subject.set_custom_data_value(field, "bla") + study_subject.set_custom_data_value(field2, "ab") + + self.check_subject_filtered([[get_study_subject_field_id(field), "bla"], + [get_study_subject_field_id(field2), "ab"]], [study_subject]) + self.check_subject_filtered([[get_study_subject_field_id(field), "bla"], + [get_study_subject_field_id(field2), "abcs"]], []) + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla', 'cla', 'dla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, '', 'False', 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, '106', '107', '201'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, '206.12', '300.3', '400'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-05', '2021-01-05', '2021-01-06',), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, 'BLA', 'BLA2', 'BLA3'), + ('file list', CUSTOM_FIELD_TYPE_FILE, 'file1.txt', 'file2.txt', 'file3.txt'), + ]) + def test_subjects_sort_by_custom_fields(self, _, field_type, value1, value2, value3): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="", type=field_type) + study_subject = self.study_subject + study_subject2 = create_study_subject(3) + study_subject3 = create_study_subject(4) + study_subject.set_custom_data_value(field, value1) + study_subject2.set_custom_data_value(field, value3) + study_subject3.set_custom_data_value(field, value2) + + self.check_subject_ordered(get_study_subject_field_id(field), [study_subject, study_subject3, study_subject2]) + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, '340'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, '540.8'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-07'), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, 'BLA'), + ('file', CUSTOM_FIELD_TYPE_FILE, 'test_file.txt'), + ]) + def test_subjects_columns_for_custom(self, _, field_type, value): + CustomStudySubjectField.objects.create(study=Study.objects.filter(id=GLOBAL_STUDY_ID)[0], default_value=value, + type=field_type) + response = self.client.get( + reverse('web.api.subjects.columns', kwargs={'subject_list_type': SUBJECT_LIST_GENERIC})) + self.assertEqual(response.status_code, 200) + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, '341'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, '541.54'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-07'), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, 'BLA'), + ('file', CUSTOM_FIELD_TYPE_FILE, 'test_file.txt'), + ]) + def test_serialize_subject_with_custom_field(self, _, field_type, value): + field = CustomStudySubjectField.objects.create(study=get_test_study(), + default_value='', + type=field_type) + + study_subject = self.study_subject + study_subject.set_custom_data_value(field, value) + + subject_json = serialize_subject(study_subject) + self.assertIsNotNone(subject_json[get_study_subject_field_id(field)]) + self.assertTrue(subject_json[get_study_subject_field_id(field)] != '') diff --git a/smash/web/tests/forms/test_CustomStudySubjectFieldAddForm.py b/smash/web/tests/forms/test_CustomStudySubjectFieldAddForm.py new file mode 100644 index 0000000000000000000000000000000000000000..e238aa3a5f2eff643e2b91eee188e54c37ead811 --- /dev/null +++ b/smash/web/tests/forms/test_CustomStudySubjectFieldAddForm.py @@ -0,0 +1,44 @@ +from django.test import TestCase +from parameterized import parameterized + +from web.forms.custom_study_subject_field_forms import CustomStudySubjectFieldAddForm +from web.models.constants import CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, \ + CUSTOM_FIELD_TYPE_DOUBLE, CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField +from web.tests.functions import get_test_study + + +class CustomStudySubjectFieldAddFormTest(TestCase): + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla', True), + ('bool valid', CUSTOM_FIELD_TYPE_BOOLEAN, 'True', True), + ('bool invalid', CUSTOM_FIELD_TYPE_BOOLEAN, 'bla', False), + ('int valid', CUSTOM_FIELD_TYPE_INTEGER, '102', True), + ('int invalid', CUSTOM_FIELD_TYPE_INTEGER, 'bla', False), + ('double valid', CUSTOM_FIELD_TYPE_DOUBLE, '202.25', True), + ('double invalid', CUSTOM_FIELD_TYPE_DOUBLE, 'bla', False), + ('date valid', CUSTOM_FIELD_TYPE_DATE, '2021-01-20', True), + ('date invalid', CUSTOM_FIELD_TYPE_DATE, 'bla', False), + ('select list valid', CUSTOM_FIELD_TYPE_SELECT_LIST, 'abc', True, 'abc;def;xyz'), + ('select list invalid', CUSTOM_FIELD_TYPE_SELECT_LIST, 'bla', False, 'abc;def'), + ('file', CUSTOM_FIELD_TYPE_FILE, None, True), + ('file invalid', CUSTOM_FIELD_TYPE_FILE, 'tmp', False), + ]) + def test_add_field(self, _, field_type, default_value, valid, possible_values=''): + sample_data = { + 'default_value': default_value, + 'name': '1. name', + 'type': field_type, + 'possible_values': possible_values, + } + + form = CustomStudySubjectFieldAddForm(sample_data, study=get_test_study()) + if valid: + self.assertTrue(form.is_valid()) + field = form.save() + + self.assertEquals(1, CustomStudySubjectField.objects.filter(id=field.id).count()) + self.assertEquals(default_value, field.default_value) + else: + self.assertFalse(form.is_valid()) diff --git a/smash/web/tests/forms/test_CustomStudySubjectFieldEditForm.py b/smash/web/tests/forms/test_CustomStudySubjectFieldEditForm.py new file mode 100644 index 0000000000000000000000000000000000000000..2a4bd25fef20f3ebbd368660e839db3b1cdc93bf --- /dev/null +++ b/smash/web/tests/forms/test_CustomStudySubjectFieldEditForm.py @@ -0,0 +1,45 @@ +from django.test import TestCase +from parameterized import parameterized + +from web.forms.custom_study_subject_field_forms import CustomStudySubjectFieldEditForm +from web.models.constants import CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, \ + CUSTOM_FIELD_TYPE_DOUBLE, CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField +from web.tests.functions import get_test_study + + +class CustomStudySubjectFieldEditFormTest(TestCase): + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla', True), + ('bool valid', CUSTOM_FIELD_TYPE_BOOLEAN, 'True', True), + ('bool invalid', CUSTOM_FIELD_TYPE_BOOLEAN, 'bla', False), + ('int valid', CUSTOM_FIELD_TYPE_INTEGER, '911', True), + ('int invalid', CUSTOM_FIELD_TYPE_INTEGER, 'bla', False), + ('double valid', CUSTOM_FIELD_TYPE_DOUBLE, '821.45', True), + ('double invalid', CUSTOM_FIELD_TYPE_DOUBLE, 'bla', False), + ('date valid', CUSTOM_FIELD_TYPE_DATE, '2020-10-04', True), + ('date invalid', CUSTOM_FIELD_TYPE_DATE, 'bla', False), + ('select list valid', CUSTOM_FIELD_TYPE_SELECT_LIST, 'abc', True, 'abc;def;xyz'), + ('select list invalid', CUSTOM_FIELD_TYPE_SELECT_LIST, 'bla', False, 'abc;def'), + ('file', CUSTOM_FIELD_TYPE_FILE, None, True), + ('file invalid', CUSTOM_FIELD_TYPE_FILE, 'tmp', False), + ]) + def test_edit_field(self, _, field_type, default_value, valid, possible_values=''): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="", type=field_type) + + sample_data = { + 'default_value': default_value, + 'name': '1. name', + 'type': field_type, + 'possible_values': possible_values, + } + + form = CustomStudySubjectFieldEditForm(sample_data, instance=field) + if valid: + self.assertTrue(form.is_valid()) + form.save() + + self.assertEquals(default_value, field.default_value) + else: + self.assertFalse(form.is_valid()) diff --git a/smash/web/tests/forms/test_study_forms.py b/smash/web/tests/forms/test_StudyEditForm.py similarity index 90% rename from smash/web/tests/forms/test_study_forms.py rename to smash/web/tests/forms/test_StudyEditForm.py index 0fe2bd2cd5f0b9fce56a6f11feef5e9bb471fd49..cde133cd73d78c771803af408844866a676f310b 100644 --- a/smash/web/tests/forms/test_study_forms.py +++ b/smash/web/tests/forms/test_StudyEditForm.py @@ -1,14 +1,15 @@ from django.test import TestCase -from django.forms import ValidationError -from web.tests.functions import get_test_study, create_study_subject + from web.forms.study_forms import StudyEditForm from web.models.study import Study from web.models.study_subject import StudySubject +from web.tests.functions import get_test_study, create_study_subject + -class StudyFormTests(TestCase): +class StudyEditFormTests(TestCase): def test_study_default_regex(self): - # this will add a studysubject with a NDnumber + # this will add a StudySubject with a ND number StudySubject.objects.all().delete() create_study_subject(nd_number='ND0001') form = StudyEditForm() @@ -30,7 +31,7 @@ class StudyFormTests(TestCase): def test_study_other_regex(self): StudySubject.objects.all().delete() - # this will add a studysubject with a NDnumber + # this will add a StudySubject with a ND number create_study_subject(nd_number='nd00001') form = StudyEditForm() form.instance = get_test_study() diff --git a/smash/web/tests/forms/test_StudySubjectAddForm.py b/smash/web/tests/forms/test_StudySubjectAddForm.py index 29b4a7b7715b80db243e0a3b30053f1a7f3ba7b7..62ea530a82927bf964586ae771b651822e3a5f1e 100644 --- a/smash/web/tests/forms/test_StudySubjectAddForm.py +++ b/smash/web/tests/forms/test_StudySubjectAddForm.py @@ -1,8 +1,15 @@ +import datetime import logging -from web.forms.study_subject_forms import get_new_screening_number +from django.core.files.uploadedfile import SimpleUploadedFile +from parameterized import parameterized + from web.forms import StudySubjectAddForm -from web.models.constants import SUBJECT_TYPE_CHOICES_CONTROL +from web.forms.study_subject_forms import get_new_screening_number, get_study_subject_field_id +from web.models.constants import SUBJECT_TYPE_CHOICES_CONTROL, CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, \ + CUSTOM_FIELD_TYPE_INTEGER, CUSTOM_FIELD_TYPE_DOUBLE, CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, \ + CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField, CustomStudySubjectValue from web.tests import LoggedInWithWorkerTestCase from web.tests.functions import create_study_subject, create_subject, get_test_study, create_empty_study @@ -28,6 +35,29 @@ class StudySubjectAddFormTests(LoggedInWithWorkerTestCase): form.is_valid() self.assertTrue(form.is_valid()) + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla', 'bla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, True, 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, 104, '104'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, 201.25, '201.25'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-03', '2020-11-03'), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, '1', 'BLA', 'BLA;BLA-BLA'), + ]) + def test_add_with_custom_field(self, _, field_type, value, expected_serialized_value, possible_values=''): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="", + type=field_type, possible_values=possible_values) + count = CustomStudySubjectValue.objects.filter(study_subject_field=field).count() + + self.sample_data[get_study_subject_field_id(field)] = value + + form = StudySubjectAddForm(data=self.sample_data, user=self.user, study=self.study) + form.instance.subject_id = create_subject().id + self.assertTrue(form.is_valid()) + subject = form.save() + + self.assertEqual(count + 1, CustomStudySubjectValue.objects.filter(study_subject_field=field).count()) + self.assertEqual(expected_serialized_value, subject.get_custom_data_value(field).value) + def test_validation_for_study_without_columns(self): form = StudySubjectAddForm(data=self.sample_data, user=self.user, study=create_empty_study()) self.assertTrue(form.is_valid()) @@ -151,3 +181,26 @@ class StudySubjectAddFormTests(LoggedInWithWorkerTestCase): new_screening_number = get_new_screening_number(prefix) self.assertEqual(prefix + "001", new_screening_number) + + def test_form_with_readonly_custom_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", readonly=True, + type=CUSTOM_FIELD_TYPE_TEXT) + + form = StudySubjectAddForm(user=self.user, study=self.study) + + self.assertTrue(form.fields[get_study_subject_field_id(field)].disabled) + + def test_form_with_unique_custom_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", unique=True, + type=CUSTOM_FIELD_TYPE_TEXT) + + study_subject = create_study_subject() + study_subject.set_custom_data_value(field, "bla") + + self.sample_data[get_study_subject_field_id(field)] = "bla2" + form = StudySubjectAddForm(data=self.sample_data, user=self.user, study=self.study) + self.assertTrue(form.is_valid()) + + self.sample_data[get_study_subject_field_id(field)] = "bla" + form = StudySubjectAddForm(data=self.sample_data, user=self.user, study=self.study) + self.assertFalse(form.is_valid()) diff --git a/smash/web/tests/forms/test_StudySubjectEditForm.py b/smash/web/tests/forms/test_StudySubjectEditForm.py index da51f0a6a8d77fbdf15b5b35a6805ee1a6fbfa77..3e0a4676323068c713a08f74ef3eb4d1dbd5a175 100644 --- a/smash/web/tests/forms/test_StudySubjectEditForm.py +++ b/smash/web/tests/forms/test_StudySubjectEditForm.py @@ -1,9 +1,15 @@ import logging +from parameterized import parameterized + from web.forms import StudySubjectEditForm from web.models import StudySubject +from web.models.constants import CUSTOM_FIELD_TYPE_TEXT, CUSTOM_FIELD_TYPE_BOOLEAN, CUSTOM_FIELD_TYPE_INTEGER, \ + CUSTOM_FIELD_TYPE_DOUBLE, CUSTOM_FIELD_TYPE_DATE, CUSTOM_FIELD_TYPE_SELECT_LIST, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id from web.tests import LoggedInWithWorkerTestCase -from web.tests.functions import create_study_subject, create_empty_study +from web.tests.functions import create_study_subject, create_empty_study, get_test_study logger = logging.getLogger(__name__) @@ -27,7 +33,7 @@ class StudySubjectEditFormTests(LoggedInWithWorkerTestCase): StudySubject.objects.all().delete() def test_validation(self): - edit_form = StudySubjectEditForm(self.sample_data) + edit_form = StudySubjectEditForm(self.sample_data, instance=self.study_subject) save_status = edit_form.is_valid() logger.debug(edit_form.errors) self.assertTrue(save_status) @@ -36,7 +42,7 @@ class StudySubjectEditFormTests(LoggedInWithWorkerTestCase): self.study_subject.study = create_empty_study() self.study_subject.save() - edit_form = StudySubjectEditForm(self.sample_data) + edit_form = StudySubjectEditForm(self.sample_data, instance=self.study_subject) save_status = edit_form.is_valid() self.assertTrue(save_status) @@ -53,6 +59,47 @@ class StudySubjectEditFormTests(LoggedInWithWorkerTestCase): self.assertTrue("nd_number" in edit_form.errors) self.assertFalse(save_status) + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla', 'bla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, True, 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, 10, '10'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, 30.5, '30.5'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-05', '2020-11-05'), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, '1', 'BLA', 'BLA;BLA-BLA'), + ('select list empty value', CUSTOM_FIELD_TYPE_SELECT_LIST, '0', '', 'BLA;BLA-BLA'), + ]) + def test_edit_with_custom_field(self, _, field_type, value, expected_serialized_value, possible_values=''): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="", + type=field_type, possible_values=possible_values) + + self.assertEqual(field.default_value, self.study_subject.get_custom_data_value(field).value) + + self.sample_data[get_study_subject_field_id(field)] = value + + edit_form = StudySubjectEditForm(self.sample_data, instance=self.study_subject) + self.assertTrue(edit_form.is_valid()) + subject = edit_form.save() + + self.assertEqual(expected_serialized_value, subject.get_custom_data_value(field).value) + + @parameterized.expand([ + ('text', CUSTOM_FIELD_TYPE_TEXT, 'bla', 'bla'), + ('bool', CUSTOM_FIELD_TYPE_BOOLEAN, True, 'True'), + ('int', CUSTOM_FIELD_TYPE_INTEGER, 10, '10'), + ('double', CUSTOM_FIELD_TYPE_DOUBLE, 30.5, '30.5'), + ('date', CUSTOM_FIELD_TYPE_DATE, '2020-11-05', '2020-11-05'), + ('select list', CUSTOM_FIELD_TYPE_SELECT_LIST, '1', 'BLA', 'BLA;BLA-BLA'), + ('select list empty value', CUSTOM_FIELD_TYPE_SELECT_LIST, '0', '', 'BLA;BLA-BLA'), + ('file', CUSTOM_FIELD_TYPE_FILE, 'bla.txt', 'bla.txt'), + ]) + def test_initial_edit_with_custom_field(self, _, field_type, expected_initial_value, value, possible_values=''): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value=value, + type=field_type, possible_values=possible_values) + + form = StudySubjectEditForm(instance=self.study_subject) + + self.assertTrue(form[get_study_subject_field_id(field)].initial, expected_initial_value) + def test_invalid_mpower_id_edit(self): self.study_subject.mpower_id = "mpower_002" self.study_subject.nd_number = "ND0002" @@ -62,8 +109,31 @@ class StudySubjectEditFormTests(LoggedInWithWorkerTestCase): self.sample_data['mpower_id'] = "mpower_002" self.sample_data['nd_number'] = "ND0001" self.sample_data['screening_number'] = "001" - edit_form = StudySubjectEditForm(self.sample_data) + edit_form = StudySubjectEditForm(self.sample_data, instance=self.study_subject) save_status = edit_form.is_valid() self.assertTrue("mpower_id" in edit_form.errors) self.assertFalse(save_status) + + def test_form_with_readonly_custom_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", readonly=True, + type=CUSTOM_FIELD_TYPE_TEXT) + + form = StudySubjectEditForm(instance=self.study_subject) + + self.assertTrue(form.fields[get_study_subject_field_id(field)].disabled) + + def test_form_with_unique_custom_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", unique=True, + type=CUSTOM_FIELD_TYPE_TEXT) + + study_subject2 = create_study_subject() + study_subject2.set_custom_data_value(field, "bla") + + self.sample_data[get_study_subject_field_id(field)] = "bla2" + form = StudySubjectEditForm(self.sample_data, instance=self.study_subject) + self.assertTrue(form.is_valid()) + + self.sample_data[get_study_subject_field_id(field)] = "bla" + form = StudySubjectEditForm(self.sample_data, instance=self.study_subject) + self.assertFalse(form.is_valid()) diff --git a/smash/web/tests/functions.py b/smash/web/tests/functions.py index fd899d8f80dd75eb34defca69fb04c0654560169..26f7ebb241e39e206bd7e7dad27a5fce53ab51ad 100644 --- a/smash/web/tests/functions.py +++ b/smash/web/tests/functions.py @@ -2,6 +2,7 @@ import datetime import logging import os +from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.utils.timezone import make_aware, is_aware @@ -14,7 +15,6 @@ from web.models.constants import REDCAP_TOKEN_CONFIGURATION_TYPE, REDCAP_BASE_UR from web.models.worker_study_role import ROLE_CHOICES_DOCTOR, WORKER_VOUCHER_PARTNER from web.redcap_connector import RedcapSubject from web.views.notifications import get_today_midnight_date -from django.contrib.auth.models import Permission logger = logging.getLogger(__name__) @@ -70,7 +70,8 @@ def create_study(name="test"): study_columns = StudyColumns.objects.create() notification_parameters = StudyNotificationParameters.objects.create() redcap_columns = StudyRedCapColumns.objects.create() - return Study.objects.create(name=name, columns=study_columns, notification_parameters=notification_parameters, redcap_columns=redcap_columns) + return Study.objects.create(name=name, columns=study_columns, notification_parameters=notification_parameters, + redcap_columns=redcap_columns) TEST_ID_COUNTER = 0 @@ -191,7 +192,7 @@ def create_subject(): ) -def create_study_subject(subject_id=1, subject=None, nd_number='ND0001'): +def create_study_subject(subject_id=1, subject=None, nd_number='ND0001') -> StudySubject: if subject is None: subject = create_subject() study_subject = StudySubject.objects.create( @@ -201,7 +202,7 @@ def create_study_subject(subject_id=1, subject=None, nd_number='ND0001'): study=get_test_study(), subject=subject ) - if nd_number is not None: #null value in column "nd_number" violates not-null constraint + if nd_number is not None: # null value in column "nd_number" violates not-null constraint study_subject.nd_number = nd_number study_subject.save() @@ -242,12 +243,14 @@ def create_user(username=None, password=None): create_worker(user) return user + def add_permissions_to_worker(worker, codenames): - roles = WorkerStudyRole.objects.filter(worker=worker, study_id=GLOBAL_STUDY_ID) - perms = Permission.objects.filter(codename__in=codenames).all() + roles = WorkerStudyRole.objects.filter(worker=worker, study_id=GLOBAL_STUDY_ID) + perms = Permission.objects.filter(codename__in=codenames).all() roles[0].permissions.set(perms) roles[0].save() + def create_worker(user=None, with_test_location=False): worker = Worker.objects.create( first_name='piotr', @@ -321,7 +324,7 @@ def create_appointment(visit=None, when=None, length=30): make_aware(when_datetime) else: when_datetime = when - + return Appointment.objects.create( visit=visit, length=length, @@ -427,6 +430,5 @@ def datetimeify_date(date, timezone=datetime.timezone.utc): """ actual_type = str(type(date)) - raise TypeError("Date should be either a subclass of 'datetime.date', string or bytes! But is: {} instead".format(actual_type)) - - + raise TypeError( + "Date should be either a subclass of 'datetime.date', string or bytes! But is: {} instead".format(actual_type)) diff --git a/smash/web/tests/models/test_study_subject.py b/smash/web/tests/models/test_study_subject.py index 343aa758cfaabb4bd57fd67f60322fe1d53af74b..11878f57384cd028d4bcb9412e06330be9d34db8 100644 --- a/smash/web/tests/models/test_study_subject.py +++ b/smash/web/tests/models/test_study_subject.py @@ -1,10 +1,11 @@ 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 get_test_study, create_study_subject, create_appointment, create_study_subject_with_multiple_screening_numbers -from web.tests.functions import create_visit +from web.models import Appointment, Visit, StudySubject, Study +from web.models.constants import CUSTOM_FIELD_TYPE_TEXT +from web.models.custom_data import CustomStudySubjectField +from web.tests.functions import create_visit, create_study +from web.tests.functions import get_test_study, create_study_subject, create_appointment, \ + create_study_subject_with_multiple_screening_numbers class SubjectModelTests(TestCase): @@ -123,7 +124,6 @@ class SubjectModelTests(TestCase): self.assertFalse(StudySubject.check_nd_number_regex(nd_number_study_subject_regex_default, study)) def test_sort_matched_screening_first(self): - def create_result(phrase, subject_id=1): phrase = phrase.format(subject_id=subject_id) phrase = phrase.split(';') @@ -152,7 +152,6 @@ class SubjectModelTests(TestCase): 'E-00{subject_id}; L-00{subject_id}', subject_id=1)) def test_sort_matched_screening_first_bad_number(self): - subject_id = 2 screening_number = 'L-0/0{}; E-00{}; F-0/1{}'.format(subject_id, subject_id, subject_id) @@ -164,10 +163,53 @@ class SubjectModelTests(TestCase): self.assertEqual(subject.sort_matched_screening_first('L-'), [('L', '0/02'), ('E', subject_id), ('F', '0/12')]) def test_sort_matched_screening_first_bad_format(self): - screening_number = 'potato; tomato' subject_id = 3 subject = create_study_subject_with_multiple_screening_numbers( subject_id=subject_id, screening_number=screening_number) self.assertEqual(subject.sort_matched_screening_first('L-'), [('potato', None), ('tomato', None)]) + + def test_subject_with_custom_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", + type=CUSTOM_FIELD_TYPE_TEXT) + + subject = create_study_subject() + + self.assertEqual(1, len(subject.custom_data_values)) + value = subject.custom_data_values[0] + self.assertEqual("xyz", value.value) + self.assertEqual(subject, value.study_subject) + self.assertEqual(field, value.study_subject_field) + + def test_subject_with_removed_custom_field(self): + field = CustomStudySubjectField.objects.create(study=get_test_study(), default_value="xyz", + type=CUSTOM_FIELD_TYPE_TEXT) + + subject = create_study_subject() + self.assertEqual(1, len(subject.custom_data_values)) + field.delete() + + self.assertEqual(0, len(subject.custom_data_values)) + + def test_subject_with_field_from_different_study(self): + field = CustomStudySubjectField.objects.create(study=create_study('bla'), type=CUSTOM_FIELD_TYPE_TEXT) + + subject = create_study_subject() + self.assertIsNone(subject.get_custom_data_value(field)) + + def test_status(self): + subject = create_study_subject() + # noinspection PySetFunctionToLiteral + statuses = set([subject.status, ]) + subject.endpoint_reached = True + statuses.add(subject.status) + subject.excluded = True + statuses.add(subject.status) + subject.resigned = True + statuses.add(subject.status) + subject.subject.dead = True + statuses.add(subject.status) + + print(statuses) + self.assertEqual(5, len(statuses)) diff --git a/smash/web/tests/view/test_custom_study_subject_field.py b/smash/web/tests/view/test_custom_study_subject_field.py new file mode 100644 index 0000000000000000000000000000000000000000..72bb61dceeee9c4311ef3f2374451e4269b6b81d --- /dev/null +++ b/smash/web/tests/view/test_custom_study_subject_field.py @@ -0,0 +1,73 @@ +import logging + +from django.urls import reverse + +from web.forms.custom_study_subject_field_forms import CustomStudySubjectFieldEditForm +from web.models.constants import CUSTOM_FIELD_TYPE_TEXT +from web.models.custom_data import CustomStudySubjectField +from web.tests import LoggedInWithWorkerTestCase +from web.tests.functions import get_test_study, format_form_field + +logger = logging.getLogger(__name__) + + +class CustomStudySubjectFieldViewTests(LoggedInWithWorkerTestCase): + def setUp(self): + super(CustomStudySubjectFieldViewTests, self).setUp() + self.study = get_test_study() + + def test_render_add(self): + self.login_as_admin() + response = self.client.get( + reverse('web.views.custom_study_subject_field_add', kwargs={'study_id': self.study.id})) + self.assertEqual(response.status_code, 200) + + def test_render_edit(self): + self.login_as_admin() + field = CustomStudySubjectField.objects.create(study=self.study, type=CUSTOM_FIELD_TYPE_TEXT) + response = self.client.get( + reverse('web.views.custom_study_subject_field_edit', + kwargs={'study_id': self.study.id, 'field_id': field.id})) + self.assertEqual(response.status_code, 200) + + def test_save_edit_field(self): + self.login_as_admin() + field = CustomStudySubjectField.objects.create(study=self.study, name='HW', type=CUSTOM_FIELD_TYPE_TEXT) + form_data = CustomStudySubjectFieldViewTests.create_edit_form_data_for_study(field) + form_data['name'] = 'test' + response = self.client.post( + reverse('web.views.custom_study_subject_field_edit', + kwargs={'study_id': self.study.id, 'field_id': field.id}), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertTrue("study" in response['Location']) + field = CustomStudySubjectField.objects.get(id=field.id) + self.assertEqual('test', field.name) + + def test_save_add_field(self): + self.login_as_admin() + field = CustomStudySubjectField.objects.create(study=self.study, name='HW', type=CUSTOM_FIELD_TYPE_TEXT) + count = CustomStudySubjectField.objects.all().count() + form_data = CustomStudySubjectFieldViewTests.create_edit_form_data_for_study(field) + response = self.client.post( + reverse('web.views.custom_study_subject_field_add', kwargs={'study_id': self.study.id}), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertEqual(count + 1, CustomStudySubjectField.objects.all().count()) + + def test_delete_field(self): + self.login_as_admin() + field = CustomStudySubjectField.objects.create(study=self.study, name='HW', type=CUSTOM_FIELD_TYPE_TEXT) + response = self.client.post( + reverse('web.views.custom_study_subject_field_delete', + kwargs={'study_id': self.study.id, 'field_id': field.id})) + self.assertEqual(response.status_code, 302) + count = CustomStudySubjectField.objects.filter(id=field.id).count() + self.assertEqual(0, count) + + @staticmethod + def create_edit_form_data_for_study(field: CustomStudySubjectField): + study_form = CustomStudySubjectFieldEditForm(instance=field) + + form_data = {} + for key, value in list(study_form.initial.items()): + form_data[key] = format_form_field(value) + return form_data diff --git a/smash/web/tests/view/test_export.py b/smash/web/tests/view/test_export.py index 3f6a5c99b7fc12060eb0aad8ca95a92460d424de..c367eb1d2df89ee577b1d2eeb8ef302093e6d2ac 100644 --- a/smash/web/tests/view/test_export.py +++ b/smash/web/tests/view/test_export.py @@ -2,50 +2,59 @@ from django.urls import reverse from web.models import Appointment, AppointmentTypeLink +from web.models.constants import CUSTOM_FIELD_TYPE_TEXT +from web.models.custom_data import CustomStudySubjectField +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id from web.tests import LoggedInTestCase -from web.tests.functions import create_study_subject, create_appointment, create_visit, create_appointment_type -from web.views.export import subject_to_row_for_fields, DROP_OUT_FIELD +from web.tests.functions import create_study_subject, create_appointment, create_visit, create_appointment_type, \ + get_test_study +from web.views.export import subject_to_row_for_fields, DROP_OUT_FIELD, get_subjects_as_array class TestExportView(LoggedInTestCase): def test_export_subjects_to_csv(self): self.login_as_admin() create_study_subject() - response = self.client.get(reverse('web.views.export_to_csv', kwargs={'data_type': "subjects"})) + response = self.client.get( + reverse('web.views.export_to_csv', kwargs={'study_id': get_test_study().id, 'data_type': "subjects"})) self.assertEqual(response.status_code, 200) def test_export_subjects_to_csv_without_permission(self): - response = self.client.get(reverse("web.views.mail_templates")) + self.client.get(reverse("web.views.mail_templates")) create_study_subject() - response = self.client.get(reverse('web.views.export_to_csv', kwargs={'data_type': "subjects"})) + response = self.client.get( + reverse('web.views.export_to_csv', kwargs={'study_id': get_test_study().id, 'data_type': "subjects"})) self.assertEqual(response.status_code, 302) def test_render_export(self): self.login_as_admin() create_study_subject() - response = self.client.get(reverse('web.views.export')) + response = self.client.get(reverse('web.views.export', kwargs={'study_id': get_test_study().id})) self.assertEqual(response.status_code, 200) def test_render_export_without_permission(self): create_study_subject() - response = self.client.get(reverse('web.views.export')) + response = self.client.get(reverse('web.views.export', kwargs={'study_id': get_test_study().id})) self.assertEqual(response.status_code, 302) def test_export_appointments_to_csv(self): self.login_as_admin() create_appointment() - response = self.client.get(reverse('web.views.export_to_csv', kwargs={'data_type': "appointments"})) + response = self.client.get( + reverse('web.views.export_to_csv', kwargs={'study_id': get_test_study().id, 'data_type': "appointments"})) self.assertEqual(response.status_code, 200) def test_export_subjects_to_excel(self): self.login_as_admin() create_study_subject() - response = self.client.get(reverse('web.views.export_to_excel', kwargs={'data_type': "subjects"})) + response = self.client.get( + reverse('web.views.export_to_excel', kwargs={'study_id': get_test_study().id, 'data_type': "subjects"})) self.assertEqual(response.status_code, 200) def test_export_subjects_to_excel_without_permission(self): create_study_subject() - response = self.client.get(reverse('web.views.export_to_excel', kwargs={'data_type': "subjects"})) + response = self.client.get( + reverse('web.views.export_to_excel', kwargs={'study_id': get_test_study().id, 'data_type': "subjects"})) self.assertEqual(response.status_code, 302) def test_export_appointments_to_excel(self): @@ -55,7 +64,8 @@ class TestExportView(LoggedInTestCase): appointment.save() AppointmentTypeLink.objects.create(appointment=appointment, appointment_type=create_appointment_type()) - response = self.client.get(reverse('web.views.export_to_excel', kwargs={'data_type': "appointments"})) + response = self.client.get( + reverse('web.views.export_to_excel', kwargs={'study_id': get_test_study().id, 'data_type': "appointments"})) self.assertEqual(response.status_code, 200) def test_subject_to_row_for_fields_when_not_resigned(self): @@ -86,3 +96,11 @@ class TestExportView(LoggedInTestCase): result = subject_to_row_for_fields(subject, [DROP_OUT_FIELD]) self.assertTrue(result[0]) + + def test_subject_with_custom_field(self): + subject = create_study_subject() + field = CustomStudySubjectField.objects.create(study=subject.study, name='HW', type=CUSTOM_FIELD_TYPE_TEXT) + subject.set_custom_data_value(field, 'bbb') + + subjects = get_subjects_as_array(get_test_study(), get_study_subject_field_id(field)) + self.assertEqual('bbb', subjects[1][0]) diff --git a/smash/web/tests/view/test_study.py b/smash/web/tests/view/test_study.py index e1965334f0a2cbdebd6cfeeb339927da3a317029..d71abed6d62d3720cb0811246620b26279e4a1ee 100644 --- a/smash/web/tests/view/test_study.py +++ b/smash/web/tests/view/test_study.py @@ -52,7 +52,7 @@ class StudyViewTests(LoggedInWithWorkerTestCase): self.assertEqual(response.status_code, 302) - def test_save_study_without_changing_page(self): + def test_save_study_and_continue_without_changing_page(self): self.login_as_admin() form_data = self.create_edit_form_data_for_study() form_data['_continue'] = True @@ -63,6 +63,16 @@ class StudyViewTests(LoggedInWithWorkerTestCase): self.assertEqual(response.status_code, 302) self.assertTrue("edit" in response['Location']) + def test_save_study_without_changing_page(self): + self.login_as_admin() + form_data = self.create_edit_form_data_for_study() + + response = self.client.post( + reverse('web.views.edit_study', kwargs={'study_id': GLOBAL_STUDY_ID}), data=form_data) + + self.assertEqual(response.status_code, 302) + self.assertTrue("appointments" in response['Location']) + def create_edit_form_data_for_study(self): study_form = StudyEditForm(instance=self.study, prefix="study") notifications_form = StudyNotificationParametersEditForm(instance=self.study.notification_parameters, diff --git a/smash/web/tests/view/test_subjects.py b/smash/web/tests/view/test_subjects.py index a6332603515f633cafd66bedd86bf6a7b6a9b905..c0fb25330b8b12e4b1fff4cb35f176a1d3ac2a4a 100644 --- a/smash/web/tests/view/test_subjects.py +++ b/smash/web/tests/view/test_subjects.py @@ -6,9 +6,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from web.forms import SubjectAddForm, SubjectEditForm, StudySubjectAddForm, StudySubjectEditForm -from web.models import MailTemplate, StudySubject, StudyColumns, Visit +from web.models import MailTemplate, StudySubject, StudyColumns, Visit, Provenance from web.models.constants import SEX_CHOICES_MALE, SUBJECT_TYPE_CHOICES_CONTROL, SUBJECT_TYPE_CHOICES_PATIENT, \ - COUNTRY_AFGHANISTAN_ID, COUNTRY_OTHER_ID, MAIL_TEMPLATE_CONTEXT_SUBJECT + COUNTRY_AFGHANISTAN_ID, COUNTRY_OTHER_ID, MAIL_TEMPLATE_CONTEXT_SUBJECT, CUSTOM_FIELD_TYPE_FILE +from web.models.custom_data import CustomStudySubjectField +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id from web.tests import LoggedInWithWorkerTestCase from web.tests.functions import create_study_subject, create_visit, create_appointment, get_test_location, \ create_language, get_resource_path, get_test_study, format_form_field @@ -27,7 +29,7 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): self.worker.roles.all()[0].permissions.add(Permission.objects.get(codename="add_subject")) self.worker.save() - response = self.client.get(reverse('web.views.subject_add')) + response = self.client.get(reverse('web.views.subject_add', kwargs={'study_id': self.study.id})) self.assertEqual(response.status_code, 200) def test_render_subject_edit(self): @@ -138,7 +140,7 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): self.assertEqual(response.status_code, 302) self.assertTrue("edit" in response.url) - def create_edit_form_data_for_study_subject(self, instance=None): + def create_edit_form_data_for_study_subject(self, instance: StudySubject = None): if instance is None: instance = self.study_subject form_study_subject = StudySubjectEditForm(instance=instance, prefix="study_subject") @@ -160,6 +162,7 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): for key, value in list(form_subject.initial.items()): form_data['subject-{}'.format(key)] = format_form_field(value) self.add_valid_form_data_for_subject_add(form_data) + form_data["study_subject-default_location"] = get_test_location().id return form_data def test_subjects_add_2(self): @@ -168,8 +171,8 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): form_data = self.create_add_form_data_for_study_subject() form_data["study_subject-type"] = SUBJECT_TYPE_CHOICES_CONTROL - form_data["study_subject-default_location"] = get_test_location().id - response = self.client.post(reverse('web.views.subject_add'), data=form_data) + response = self.client.post(reverse('web.views.subject_add', kwargs={'study_id': self.study.id}), + data=form_data) self.assertEqual(response.status_code, 302) response = self.client.get(response.url) self.assertContains(response, "Subject created") @@ -187,9 +190,9 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): form_data = self.create_add_form_data_for_study_subject() form_data["study_subject-type"] = SUBJECT_TYPE_CHOICES_CONTROL - form_data["study_subject-default_location"] = get_test_location().id form_data["study_subject-referral_letter"] = SimpleUploadedFile("file.txt", b"file_content") - response = self.client.post(reverse('web.views.subject_add'), data=form_data) + response = self.client.post(reverse('web.views.subject_add', kwargs={'study_id': self.study.id}), + data=form_data) self.assertEqual(response.status_code, 302) response = self.client.get(response.url) self.assertContains(response, "Subject created") @@ -223,8 +226,8 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): self.worker.save() form_data = self.create_add_form_data_for_study_subject() - form_data["study_subject-default_location"] = get_test_location().id - response = self.client.post(reverse('web.views.subject_add'), data=form_data) + response = self.client.post(reverse('web.views.subject_add', kwargs={'study_id': self.study.id}), + data=form_data) self.assertEqual(response.status_code, 302) response = self.client.get(response.url) self.assertContains(response, "Subject created") @@ -234,15 +237,31 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): "prefix should start by P" + " as default location prefix is not defined and subject type is patient") + def test_subjects_add_subject_with_custom_file_field(self): + field = CustomStudySubjectField.objects.create(study=self.study, type=CUSTOM_FIELD_TYPE_FILE) + self.worker.roles.all()[0].permissions.add(Permission.objects.get(codename="add_subject")) + self.worker.save() + form_data = self.create_add_form_data_for_study_subject() + + form_data["study_subject-" + get_study_subject_field_id(field)] = SimpleUploadedFile("my-custom-file-name.txt", + b"file_content") + response = self.client.post(reverse('web.views.subject_add', kwargs={'study_id': self.study.id}), + data=form_data) + + self.assertEqual(response.status_code, 302) + + subject = StudySubject.objects.all().order_by("-id")[0] + self.assertTrue('my-custom-file-name' in subject.get_custom_data_value(field).value) + def test_subjects_add_invalid(self): self.worker.roles.all()[0].permissions.add(Permission.objects.get(codename="add_subject")) self.worker.save() form_data = self.create_add_form_data_for_study_subject() form_data["study_subject-type"] = SUBJECT_TYPE_CHOICES_CONTROL - form_data["study_subject-default_location"] = get_test_location().id form_data["subject-country"] = COUNTRY_OTHER_ID - response = self.client.post(reverse('web.views.subject_add'), data=form_data) + response = self.client.post(reverse('web.views.subject_add', kwargs={'study_id': self.study.id}), + data=form_data) self.assertTrue("Invalid data".encode('utf8') in response.content) @@ -257,7 +276,8 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): location.prefix = 'X' location.save() form_data["study_subject-default_location"] = get_test_location().id - response = self.client.post(reverse('web.views.subject_add'), data=form_data) + response = self.client.post(reverse('web.views.subject_add', kwargs={'study_id': self.study.id}), + data=form_data) self.assertEqual(response.status_code, 302) response = self.client.get(response.url) self.assertContains(response, "Subject created") @@ -283,3 +303,15 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): response = self.client.get(reverse('web.views.subject_require_contact')) self.assertEqual(response.status_code, 200) + + def test_save_subject_edit_when_type_changed(self): + form_data = self.create_edit_form_data_for_study_subject(self.study_subject) + + count = Provenance.objects.all().count() + form_data["study_subject-type"] = SUBJECT_TYPE_CHOICES_PATIENT + response = self.client.post( + reverse('web.views.subject_edit', kwargs={'id': self.study_subject.id}), data=form_data) + + self.assertEqual(response.status_code, 302) + + self.assertEqual(count + 1, Provenance.objects.all().count()) diff --git a/smash/web/urls.py b/smash/web/urls.py index 05889ef12e873bbdeb356aaecf299862dbd9ae4a..1bf145b3ba0de4ca48b4b0279be0c5b829a034d6 100644 --- a/smash/web/urls.py +++ b/smash/web/urls.py @@ -18,7 +18,6 @@ from django.conf.urls import include from django.conf.urls import url from django.contrib.auth.views import logout from django.views.defaults import page_not_found -from django.views.generic import TemplateView from web import views from web.views.daily_planning import TemplateDailyPlannerView @@ -35,10 +34,14 @@ urlpatterns = [ url(r'^change_password/$', views.password.change_password, name='change_password'), - url(r'^appointment_types/$', views.appointment_type.AppointmentTypeListView.as_view(), name='web.views.appointment_types'), - url(r'^appointment_types/add$', views.appointment_type.AppointmentTypeCreateView.as_view(), name='web.views.appointment_type_add'), - url(r'^appointment_types/(?P<pk>\d+)/edit$', views.appointment_type.AppointmentTypeEditView.as_view(), name='web.views.appointment_type_edit'), - url(r'^appointment_types/(?P<pk>\d+)/delete$', views.appointment_type.AppointmentTypeDeleteView.as_view(), name='web.views.appointment_type_delete'), + url(r'^appointment_types/$', views.appointment_type.AppointmentTypeListView.as_view(), + name='web.views.appointment_types'), + url(r'^appointment_types/add$', views.appointment_type.AppointmentTypeCreateView.as_view(), + name='web.views.appointment_type_add'), + url(r'^appointment_types/(?P<pk>\d+)/edit$', views.appointment_type.AppointmentTypeEditView.as_view(), + name='web.views.appointment_type_edit'), + url(r'^appointment_types/(?P<pk>\d+)/delete$', views.appointment_type.AppointmentTypeDeleteView.as_view(), + name='web.views.appointment_type_delete'), #################### # APPOINTMENTS # @@ -83,7 +86,7 @@ urlpatterns = [ url(r'^subjects/require_contact$', views.subject.subject_require_contact, name='web.views.subject_require_contact'), url(r'^subjects/voucher_expiry', views.subject.subject_voucher_expiry, name='web.views.subject_voucher_expiry'), - url(r'^subjects/add$', views.subject.subject_add, name='web.views.subject_add'), + url(r'^study/(?P<study_id>\d+)/subjects/add$', views.subject.subject_add, name='web.views.subject_add'), url(r'^subjects/subject_visit_details/(?P<id>\d+)$', views.subject.subject_visit_details, name='web.views.subject_visit_details'), url(r'^subjects/edit/(?P<id>\d+)$', views.subject.subject_edit, name='web.views.subject_edit'), @@ -181,7 +184,8 @@ urlpatterns = [ url(r'^mail_templates/add$', views.mails.mail_template_add, name='web.views.mail_template_add'), url(r'^mail_templates/(?P<pk>\d+)/edit$', views.mails.mail_template_edit, name='web.views.mail_template_edit'), - url(r'^mail_templates/(?P<pk>\d+)/delete$', views.mails.MailTemplatesDeleteView.as_view(), name='web.views.mail_template_delete'), + url(r'^mail_templates/(?P<pk>\d+)/delete$', views.mails.MailTemplatesDeleteView.as_view(), + name='web.views.mail_template_delete'), url(r'^mail_templates/(?P<mail_template_id>\d+)/generate/(?P<instance_id>\d+)$', views.mails.generate, name="web.views.mail_template_generate"), url(r'^mail_templates/print_vouchers$', views.mails.generate_for_vouchers, @@ -253,13 +257,20 @@ urlpatterns = [ url(r'^study/(?P<study_id>\d+)/edit', views.study.study_edit, name='web.views.edit_study'), + url(r'^study/(?P<study_id>\d+)/custom_study_subject_field/add', views.study.custom_study_subject_field_add, + name='web.views.custom_study_subject_field_add'), + url(r'^study/(?P<study_id>\d+)/custom_study_subject_field/(?P<field_id>\d+)/edit', + views.study.custom_study_subject_field_edit, name='web.views.custom_study_subject_field_edit'), + url(r'^study/(?P<study_id>\d+)/custom_study_subject_field/(?P<field_id>\d+)/delete', + views.study.custom_study_subject_field_delete, name='web.views.custom_study_subject_field_delete'), + #################### # EXPORT # #################### - url(r'^export$', views.export.export, name='web.views.export'), - url(r'^export/csv/(?P<data_type>[A-z]+)$', views.export.export_to_csv, name='web.views.export_to_csv'), - url(r'^export/xls/(?P<data_type>[A-z]+)$', views.export.export_to_excel, name='web.views.export_to_excel'), + url(r'^study/(?P<study_id>\d+)export$', views.export.export, name='web.views.export'), + url(r'^study/(?P<study_id>\d+)export/csv/(?P<data_type>[A-z]+)$', views.export.export_to_csv, name='web.views.export_to_csv'), + url(r'^study/(?P<study_id>\d+)export/xls/(?P<data_type>[A-z]+)$', views.export.export_to_excel, name='web.views.export_to_excel'), #################### # CONFIGURATION # diff --git a/smash/web/views/export.py b/smash/web/views/export.py index f6cd6b738cf9de931e7bf59acee72a5ddf1526d0..8d69c70ada59bbc7fe88fac8e546a19afe98a2bb 100644 --- a/smash/web/views/export.py +++ b/smash/web/views/export.py @@ -1,36 +1,42 @@ # coding=utf-8 import csv +import re +from distutils.util import strtobool import django_excel as excel from django.http import HttpResponse +from django.shortcuts import get_object_or_404 + from ..utils import get_client_ip from .notifications import get_today_midnight_date from web.decorators import PermissionDecorator -from . import e500_error, wrap_response -from ..models import Subject, StudySubject, Appointment, ConfigurationItem, Worker, Provenance, Visit +from web.models import Subject, StudySubject, Appointment, ConfigurationItem, Study, Worker, Provenance, Visit from web.models.constants import VISIT_SHOW_VISIT_NUMBER_FROM_ZERO -from distutils.util import strtobool +from web.models.custom_data import CustomStudySubjectField +from web.models.custom_data.custom_study_subject_field import get_study_subject_field_id from web.templatetags.filters import display_visit_number -import re +from . import e500_error, wrap_response +from .notifications import get_today_midnight_date @PermissionDecorator('export_subjects', 'subject') -def export_to_csv(request, data_type="subjects"): +def export_to_csv(request, study_id, data_type="subjects"): + study = get_object_or_404(Study, id=study_id) # Create the HttpResponse object with the appropriate CSV header. selected_fields = request.GET.get('fields', None) - response = HttpResponse(content_type='text/csv') + response = HttpResponse(content_type='text/csv; charset=utf-8') response['Content-Disposition'] = 'attachment; filename="' + data_type + '-' + get_today_midnight_date().strftime( "%Y-%m-%d") + '.csv"' if data_type == "subjects": - data = get_subjects_as_array(selected_fields=selected_fields) + data = get_subjects_as_array(study, selected_fields=selected_fields) elif data_type == "appointments": data = get_appointments_as_array(selected_fields=selected_fields) else: return e500_error(request) writer = csv.writer(response, quotechar=str('"'), quoting=csv.QUOTE_ALL) for row in data: - writer.writerow([s.encode("utf-8") for s in row]) + writer.writerow(row) worker = Worker.get_by_user(request.user) ip = get_client_ip(request) @@ -46,11 +52,12 @@ def export_to_csv(request, data_type="subjects"): @PermissionDecorator('export_subjects', 'subject') -def export_to_excel(request, data_type="subjects"): +def export_to_excel(request, study_id, data_type="subjects"): + study = get_object_or_404(Study, id=study_id) selected_fields = request.GET.get('fields', None) filename = data_type + '-' + get_today_midnight_date().strftime("%Y-%m-%d") + ".xls" if data_type == "subjects": - data = get_subjects_as_array(selected_fields=selected_fields) + data = get_subjects_as_array(study, selected_fields=selected_fields) elif data_type == "appointments": data = get_appointments_as_array(selected_fields=selected_fields) else: @@ -73,6 +80,9 @@ def export_to_excel(request, data_type="subjects"): class CustomField: + name = '' + verbose_name = '' + def __init__(self, dictionary): for k, v in list(dictionary.items()): setattr(self, k, v) @@ -109,7 +119,7 @@ def filter_fields_from_selected_fields(fields, selected_fields): return [field for field in fields if field.name in selected_fields] -def get_default_subject_fields(): +def get_default_subject_fields(study: Study): visit_from_zero = ConfigurationItem.objects.get(type=VISIT_SHOW_VISIT_NUMBER_FROM_ZERO).value visit_from_zero = strtobool(visit_from_zero) subject_fields = [] @@ -129,12 +139,15 @@ def get_default_subject_fields(): field.verbose_name = 'Virus {} RT-PCR date'.format(display_visit_number(number)) subject_fields.append(field) subject_fields.append(DROP_OUT_FIELD) + for custom_field in CustomStudySubjectField.objects.filter(study=study).all(): + subject_fields.append( + CustomField({'verbose_name': custom_field.name, 'name': get_study_subject_field_id(custom_field)})) return subject_fields -def get_subjects_as_array(selected_fields=None): +def get_subjects_as_array(study: Study, selected_fields: str = None): result = [] - subject_fields = get_default_subject_fields() + subject_fields = get_default_subject_fields(study) subject_fields = filter_fields_from_selected_fields(subject_fields, selected_fields) field_names = [field.verbose_name for field in subject_fields] # faster than loop @@ -147,9 +160,18 @@ def get_subjects_as_array(selected_fields=None): return result -def subject_to_row_for_fields(study_subject, subject_fields): +def subject_to_row_for_fields(study_subject: StudySubject, subject_fields): row = [] + custom_fields = CustomStudySubjectField.objects.filter(study=study_subject.study).all() + for field in subject_fields: + cell = None + for custom_field in custom_fields: + if get_study_subject_field_id(custom_field) == field.name: + val = study_subject.get_custom_data_value(custom_field) + if val is not None: + cell = val.value + if field == DROP_OUT_FIELD: if not study_subject.resigned: cell = False @@ -227,8 +249,10 @@ def get_appointments_as_array(selected_fields=None): @PermissionDecorator('export_subjects', 'subject') -def export(request): +def export(request, study_id): + study = get_object_or_404(Study, id=study_id) return wrap_response(request, 'export/index.html', { - 'subject_fields': get_default_subject_fields(), - 'appointment_fields': get_appointment_fields()[0] + 'subject_fields': get_default_subject_fields(study), + 'appointment_fields': get_appointment_fields()[0], + 'study_id': study_id }) diff --git a/smash/web/views/study.py b/smash/web/views/study.py index 7edc1d43b5fd319a3cd29046e8c5320c6a60ea20..7060a98284ba20e9b8f99b9795c0a31591d2311f 100644 --- a/smash/web/views/study.py +++ b/smash/web/views/study.py @@ -4,13 +4,17 @@ import logging from django.contrib import messages from django.shortcuts import redirect, get_object_or_404 -from web.forms import StudyColumnsEditForm, StudyEditForm, StudyNotificationParametersEditForm, StudyRedCapColumnsEditForm +from web.decorators import PermissionDecorator +from web.forms import StudyColumnsEditForm, StudyEditForm, StudyNotificationParametersEditForm, \ + StudyRedCapColumnsEditForm +from web.forms.custom_study_subject_field_forms import CustomStudySubjectFieldAddForm, CustomStudySubjectFieldEditForm from web.models import Study +from web.models.custom_data import CustomStudySubjectField from web.views import wrap_response -from web.decorators import PermissionDecorator logger = logging.getLogger(__name__) + @PermissionDecorator('change_study', 'configuration') def study_edit(request, study_id): study = get_object_or_404(Study, id=study_id) @@ -22,8 +26,11 @@ def study_edit(request, study_id): study_columns_form = StudyColumnsEditForm(request.POST, request.FILES, instance=study.columns, prefix="columns") redcap_columns_form = StudyRedCapColumnsEditForm(request.POST, request.FILES, instance=study.redcap_columns, - prefix="redcap") - if study_form.is_valid() and notifications_form.is_valid() and study_columns_form.is_valid() and redcap_columns_form.is_valid(): + prefix="redcap") + if study_form.is_valid() \ + and notifications_form.is_valid() \ + and study_columns_form.is_valid() \ + and redcap_columns_form.is_valid(): study_form.save() notifications_form.save() study_columns_form.save() @@ -42,7 +49,7 @@ def study_edit(request, study_id): prefix="columns") redcap_columns_form = StudyRedCapColumnsEditForm(instance=study.redcap_columns, - prefix="redcap") + prefix="redcap") return wrap_response(request, 'study/edit.html', { 'study_form': study_form, @@ -51,3 +58,48 @@ def study_edit(request, study_id): 'study_columns_form': study_columns_form, 'redcap_columns_form': redcap_columns_form }) + + +@PermissionDecorator('change_study', 'configuration') +def custom_study_subject_field_add(request, study_id): + study = get_object_or_404(Study, id=study_id) + if request.method == 'POST': + field_form = CustomStudySubjectFieldAddForm(request.POST, study=study) + field_form.instance.study = study + if field_form.is_valid(): + field_form.save() + return redirect('web.views.edit_study', study_id=study.id) + else: + field_form = CustomStudySubjectFieldAddForm(study=study) + + return wrap_response(request, 'custom_study_subject_field/add.html', { + 'form': field_form, + 'study_id': study.id + }) + + +@PermissionDecorator('change_study', 'configuration') +def custom_study_subject_field_edit(request, study_id, field_id): + study = get_object_or_404(Study, id=study_id) + field = get_object_or_404(CustomStudySubjectField, id=field_id) + if request.method == 'POST': + field_form = CustomStudySubjectFieldEditForm(request.POST, instance=field) + field_form.instance.study = study + if field_form.is_valid(): + field_form.save() + return redirect('web.views.edit_study', study_id=study.id) + else: + field_form = CustomStudySubjectFieldEditForm(instance=field) + + return wrap_response(request, 'custom_study_subject_field/edit.html', { + 'form': field_form, + 'study_id': study.id + }) + + +@PermissionDecorator('change_study', 'configuration') +def custom_study_subject_field_delete(request, study_id, field_id): + study = get_object_or_404(Study, id=study_id) + field = get_object_or_404(CustomStudySubjectField, id=field_id) + field.delete() + return redirect('web.views.edit_study', study_id=study.id) diff --git a/smash/web/views/subject.py b/smash/web/views/subject.py index 6922008ba35fd2140b4f9444f4c07146f4807cb5..2b0ddce56b84551a39bbfca87ad2d4bcc067c5e3 100644 --- a/smash/web/views/subject.py +++ b/smash/web/views/subject.py @@ -5,10 +5,11 @@ from django.contrib import messages from django.shortcuts import redirect, get_object_or_404 from ..utils import get_client_ip from web.decorators import PermissionDecorator +from web.models.custom_data import CustomStudySubjectField from . import wrap_response from ..forms import VisitDetailForm, SubjectAddForm, SubjectEditForm, StudySubjectAddForm, StudySubjectEditForm from ..models import StudySubject, MailTemplate, Worker, Study, Provenance, Subject -from ..models.constants import GLOBAL_STUDY_ID, SUBJECT_TYPE_CHOICES +from ..models.constants import GLOBAL_STUDY_ID, SUBJECT_TYPE_CHOICES, FILE_STORAGE from ..models.study_subject_list import SUBJECT_LIST_GENERIC, SUBJECT_LIST_NO_VISIT, SUBJECT_LIST_REQUIRE_CONTACT, \ SUBJECT_LIST_VOUCHER_EXPIRY, SUBJECT_LIST_CHOICES @@ -20,6 +21,7 @@ def subject_list(request, subject_list_type): 'list_type': subject_list_type, 'worker': Worker.get_by_user(request.user), 'list_description': SUBJECT_LIST_CHOICES[subject_list_type], + 'study_id': GLOBAL_STUDY_ID, } return wrap_response(request, 'subjects/index.html', context) @@ -29,17 +31,17 @@ def subjects(request): @PermissionDecorator('add_subject', 'subject') -def subject_add(request): - study = Study.objects.filter(id=GLOBAL_STUDY_ID)[0] +def subject_add(request, study_id): + study = get_object_or_404(Study, id=study_id) if request.method == 'POST': study_subject_form = StudySubjectAddForm(request.POST, request.FILES, prefix="study_subject", user=request.user, study=study) - subject_form = SubjectAddForm(request.POST, request.FILES, prefix="subject") if study_subject_form.is_valid() and subject_form.is_valid(): subject = subject_form.save() study_subject_form.instance.subject_id = subject.id - study_subject_form.save() + study_subject = study_subject_form.save() + persist_custom_file_fields(request, study_subject) messages.add_message(request, messages.SUCCESS, 'Subject created') return redirect('web.views.subject_edit', id=study_subject_form.instance.id) else: @@ -75,7 +77,7 @@ def subject_edit(request, id): ip = get_client_ip(request) if request.method == 'POST': study_subject_form = StudySubjectEditForm(request.POST, request.FILES, instance=study_subject, - was_resigned=was_resigned, prefix="study_subject", + was_resigned=was_resigned, prefix="study_subject", endpoint_was_reached=endpoint_was_reached) subject_form = SubjectEditForm(request.POST, request.FILES, instance=study_subject.subject, was_dead=was_dead, prefix="subject" @@ -83,6 +85,9 @@ def subject_edit(request, id): if study_subject_form.is_valid() and subject_form.is_valid(): study_subject_form.save() subject_form.save() + + persist_custom_file_fields(request, study_subject) + # check if subject was marked as dead or resigned if 'type' in study_subject_form.changed_data and old_type != study_subject_form.cleaned_data['type']: worker = Worker.get_by_user(request.user) @@ -153,6 +158,16 @@ def subject_edit(request, id): }) +def persist_custom_file_fields(request, study_subject): + for key in request.FILES: + if key.startswith('study_subject-custom_field-'): + file_folder = key.replace('study_subject-', '') + field_id = int(file_folder.replace("custom_field-", "")) + file = FILE_STORAGE.save(name=file_folder + "/" + request.FILES[key].name, + content=request.FILES[key]) + study_subject.set_custom_data_value(CustomStudySubjectField.objects.get(pk=field_id), file) + + def subject_visit_details(request, id): study_subject_to_be_viewed = get_object_or_404(StudySubject, id=id) visits = study_subject_to_be_viewed.visit_set.order_by("-visit_number").all() diff --git a/smash/web/views/uploaded_files.py b/smash/web/views/uploaded_files.py index f7f5f3dfa6936bac2592fdf16829947be3297eaa..de6f7c6b1e2901385f832f13ed014c687ba983ed 100644 --- a/smash/web/views/uploaded_files.py +++ b/smash/web/views/uploaded_files.py @@ -18,6 +18,6 @@ def path_to_filename(path): def download(request): if request.GET and request.GET.get('file'): path = FILE_STORAGE.location + "/" + request.GET.get('file') - response = HttpResponse(FileWrapper(open(path, 'r')), content_type='application/force-download') + response = HttpResponse(FileWrapper(open(path, 'rb')), content_type='application/force-download') response['Content-Disposition'] = 'attachment; filename=%s' % path_to_filename(path) return response