diff --git a/castellum/recruitment/admin.py b/castellum/recruitment/admin.py index b33d6b4938496b3f5bf1cb1ec90ce3e0a7a83858..feefb9135260dd9aa0a0b0c576e6e5f1d788f26d 100644 --- a/castellum/recruitment/admin.py +++ b/castellum/recruitment/admin.py @@ -37,7 +37,6 @@ from .forms import DescriptionImportForm from .models import AttributeCategory from .models import AttributeChoice from .models import AttributeDescription -from .models import AttributeSet from .models import ParticipationRequest from .models import ParticipationRequestRevision from .models import SubjectFilter @@ -149,7 +148,6 @@ class AttributeDescriptionAdmin(TranslatableAdmin): admin.site.register(AttributeCategory, AttributeCategoryAdmin) admin.site.register(AttributeDescription, AttributeDescriptionAdmin) -admin.site.register(AttributeSet) admin.site.register(ParticipationRequest) admin.site.register(ParticipationRequestRevision) admin.site.register(SubjectFilter) diff --git a/castellum/recruitment/attribute_fields.py b/castellum/recruitment/attribute_fields.py index fe5e1ec8e98815ab525f7bdab041dc869d1d9e4c..7436981ebe2581dc433a2f1070059f1fd8c90623 100644 --- a/castellum/recruitment/attribute_fields.py +++ b/castellum/recruitment/attribute_fields.py @@ -59,12 +59,12 @@ class BaseAttributeField: return self.formfield(self.filter_form_class, **kwargs) def _filter_to_q(self, operator, value): - field_name = 'data__' + self.description.json_key + field_name = 'attributes__' + self.description.json_key key = '{}__{}'.format(field_name, operator) return models.Q(**{key: value}) def filter_to_q(self, operator, value, include_unknown=True): - field_name = 'data__' + self.description.json_key + field_name = 'attributes__' + self.description.json_key inverse = operator.startswith('!') if inverse: operator = operator[1:] @@ -216,7 +216,7 @@ class OrderedChoiceAttributeField(ChoiceAttributeField): # JSONField cannot use `in` lookups q = models.Q() for choice in allowed_choices: - q |= models.Q(**{'data__' + self.description.json_key: choice.pk}) + q |= models.Q(**{'attributes__' + self.description.json_key: choice.pk}) return q return super()._filter_to_q(operator, value) diff --git a/castellum/recruitment/filter_queries.py b/castellum/recruitment/filter_queries.py index 19585d1cba17088565d3ef39f147867881fa553c..241c930194d3af6320b62e4a213cbe4290fbb2be 100644 --- a/castellum/recruitment/filter_queries.py +++ b/castellum/recruitment/filter_queries.py @@ -20,13 +20,13 @@ # . -"""Filter queries for AttributeSets. +"""Filter queries for Subjects. -There are many complicted filters for AttributeSets. +There are many complicted filters for Subjects. In order to avoid losing track these are collected in this file. All of the functions return instances of ``django.db.models.Q``, -which can be passed to ``AttributeSet.objects.filter()``. +which can be passed to ``Subjects.objects.filter()``. """ @@ -74,7 +74,7 @@ def study_exclusion(study): prs = ParticipationRequest._base_manager.filter(pr_q) - return ~models.Q(subject_id__in=prs.values('subject_id')) + return ~models.Q(id__in=prs.values('subject_id')) def study_disinterest(study): @@ -97,17 +97,17 @@ def subjectfilters(subjectfiltergroup, include_unknown=None): def no_recruitment(): """Exclude subjects who do not want to be contacted for recruitment.""" - return models.Q(subject__no_recruitment=False) + return models.Q(no_recruitment=False) def to_be_deleted(): """Exclude subjects who want to be deleted.""" - return models.Q(subject__to_be_deleted__isnull=True) + return models.Q(to_be_deleted__isnull=True) def already_in_study(study): """Exclude subjects if they already have a participationrequest in this study.""" - return ~models.Q(subject__participationrequest__study=study) + return ~models.Q(participationrequest__study=study) def study_filters(study, include_unknown=None): @@ -125,14 +125,14 @@ def study_filters(study, include_unknown=None): return q -def completeness_expr(prefix=''): +def completeness_expr(): """Expression that can be used to annotate a queryset.""" # Needs to match ``AttributeSet.get_completeness()``. total = 0 completeness = models.Value(0, output_field=models.IntegerField()) for description in AttributeDescription.objects.all(): total += 1 - field_name = prefix + 'data__' + description.json_key + field_name = 'attributes__' + description.json_key completeness += models.Case( models.When(( models.Q(**{field_name: ''}) | diff --git a/castellum/recruitment/forms.py b/castellum/recruitment/forms.py index ee7bb2e2cc0a45a0626cfc851d53d0dfb2dfe4aa..b9eae7115b70f60ae6f67b6b87234c3fe2568d95 100644 --- a/castellum/recruitment/forms.py +++ b/castellum/recruitment/forms.py @@ -27,12 +27,12 @@ from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ from castellum.studies.models import Study +from castellum.subjects.models import Subject from castellum.utils.forms import BaseImportForm from castellum.utils.forms import DisabledChoiceField from castellum.utils.forms import DisabledModelChoiceField from .models import AttributeDescription -from .models import AttributeSet from .models import ParticipationRequest from .models import SubjectFilter from .models.attributesets import ANSWER_DECLINED @@ -136,8 +136,8 @@ class SubjectFilterFormSet(forms.BaseModelFormSet): class AttributeSetForm(forms.ModelForm): class Meta: - model = AttributeSet - exclude = ('data',) + model = Subject + fields = ('study_type_disinterest',) widgets = { 'study_type_disinterest': forms.CheckboxSelectMultiple(), } @@ -157,11 +157,11 @@ class AttributeSetForm(forms.ModelForm): study_type_disinterest = cleaned_data.pop('study_type_disinterest') return { 'study_type_disinterest': study_type_disinterest, - 'data': cleaned_data, + 'attributes': cleaned_data, } def save(self): - self.instance.data.update(self.cleaned_data['data']) + self.instance.attributes.update(self.cleaned_data['attributes']) return super().save() @classmethod diff --git a/castellum/recruitment/management/commands/create_demo_content.py b/castellum/recruitment/management/commands/create_demo_content.py index 453b753fc34e03bd953dc1b0bf158aa3cdeae424..22f81efbf360e26fcc7bbd5fc5c5a6a15d35190f 100644 --- a/castellum/recruitment/management/commands/create_demo_content.py +++ b/castellum/recruitment/management/commands/create_demo_content.py @@ -32,7 +32,6 @@ from castellum.contacts.models import Address from castellum.contacts.models import Contact from castellum.contacts.models import phonetic from castellum.recruitment.models import AttributeChoice -from castellum.recruitment.models import AttributeSet from castellum.studies.models import Study from castellum.studies.models import StudySession from castellum.studies.models import StudyType @@ -110,7 +109,7 @@ def fake_language(): def fake_subject(): - return Subject(privacy_level=fake.random.randint(0, 2)) + return Subject(privacy_level=fake.random.randint(0, 2), attributes=fake_attributes()) def fake_studysession(study): @@ -173,17 +172,17 @@ def fake_contact(subject, address): return contact -def fake_attributeset(subject, contact): +def fake_attributes(): data = {} if fake.random.random() < 0.9: data['d1'] = 2 if fake.random.random() < 0.15 else 1 if fake.random.random() < 0.9: data['d2'] = fake_language() if fake.random.random() < 0.9: - data['d3'] = contact.date_of_birth + data['d3'] = fake_date_of_birth() if fake.random.random() < 0.9: data['d4'] = fake.random.choice(get_highest_degree_choices()) - return AttributeSet(subject=subject, data=data) + return data def generate_studies(count): @@ -227,10 +226,6 @@ def generate_subjects(count): Contact.objects.bulk_create(contacts) - print('Generating attributesets') - attributesets = [fake_attributeset(subjects[i], contacts[i]) for i in range(count)] - AttributeSet.objects.bulk_create(attributesets) - class Command(BaseCommand): help = 'Populate database with demo data.' diff --git a/castellum/recruitment/migrations/0008_delete_attributeset.py b/castellum/recruitment/migrations/0008_delete_attributeset.py new file mode 100644 index 0000000000000000000000000000000000000000..6634bbdb82ea47215b4a2462aa7976a525fe3cbf --- /dev/null +++ b/castellum/recruitment/migrations/0008_delete_attributeset.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-03-10 13:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0007_perm_search_execution'), + ('subjects', '0008_merge_attributeset'), + ] + + operations = [ + migrations.DeleteModel( + name='AttributeSet', + ), + ] diff --git a/castellum/recruitment/mixins.py b/castellum/recruitment/mixins.py index 187dd34f57168c624ca2193de0342ce78375c0bc..c6ea2fd18dfa9175d6d87f5fb3cec2a347ee2acc 100644 --- a/castellum/recruitment/mixins.py +++ b/castellum/recruitment/mixins.py @@ -25,10 +25,10 @@ from django.views.generic import UpdateView from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.contacts.mixins import SubjectMixin +from castellum.subjects.models import Subject from .forms import AttributeSetForm from .models import AttributeDescription -from .models import AttributeSet from .models.attributesets import ANSWER_DECLINED from .models.attributesets import UNCATEGORIZED @@ -36,8 +36,9 @@ monitoring_logger = logging.getLogger('monitoring.recruitment') class BaseAttributeSetUpdateView(SubjectMixin, PermissionRequiredMixin, UpdateView): - model = AttributeSet + model = Subject permission_required = 'recruitment.change_attributeset' + template_name = 'recruitment/attributeset_form.html' def get_form_class(self): return AttributeSetForm.factory(self.request.user) @@ -45,7 +46,7 @@ class BaseAttributeSetUpdateView(SubjectMixin, PermissionRequiredMixin, UpdateVi def form_valid(self, form): result = super().form_valid(form) monitoring_logger.info('AttributeSet update: {} by {}'.format( - self.object.subject.pk, + self.object.pk, self.request.user.pk)) return result @@ -55,7 +56,7 @@ class BaseAttributeSetUpdateView(SubjectMixin, PermissionRequiredMixin, UpdateVi def get_bound_fields(descriptions): for description in descriptions: - answer_declined = self.object.data.get(description.json_key) == ANSWER_DECLINED + answer_declined = self.object.attributes.get(description.json_key) == ANSWER_DECLINED try: yield form['d%i' % description.pk], answer_declined except KeyError: diff --git a/castellum/recruitment/models/__init__.py b/castellum/recruitment/models/__init__.py index 93b2bb79d7fd85db5ee9aad908aadc7e0d5d060c..d5ec1eec18fb72bac9e3133246403347b13bd6dd 100644 --- a/castellum/recruitment/models/__init__.py +++ b/castellum/recruitment/models/__init__.py @@ -24,7 +24,6 @@ from .attributesets import AttributeCategory from .attributesets import AttributeChoice from .attributesets import AttributeDescription -from .attributesets import AttributeSet from .recruitment import MailBatch from .recruitment import ParticipationRequest from .recruitment import ParticipationRequestRevision diff --git a/castellum/recruitment/models/attributesets.py b/castellum/recruitment/models/attributesets.py index 8383230418f27518def9aef2fffbc21368d85991..126c319799081cec859b61725b265bb2f290e9a4 100644 --- a/castellum/recruitment/models/attributesets.py +++ b/castellum/recruitment/models/attributesets.py @@ -79,57 +79,6 @@ class ImportMixin: return obj -class AttributeSet(TimeStampedModel): - subject = models.OneToOneField(Subject, on_delete=models.CASCADE, editable=False) - data = JSONField(encoder=DjangoJSONEncoder) - study_type_disinterest = models.ManyToManyField( - StudyType, - verbose_name=_('Does not want to participate in the following study types:'), - blank=True, - related_name='+', - ) - - class Meta: - verbose_name = _('Attribute set') - verbose_name_plural = _('Attribute sets') - - def get_absolute_url(self): - return reverse('subjects:detail', args=[self.subject.pk]) - - def get_data(self): - qs = AttributeDescription.objects.all() - return {desc.json_key: desc.field.from_json(self.data.get(desc.json_key)) for desc in qs} - - def get_verbose_name(self, pk): - description = AttributeDescription.objects.get(pk=pk) - return description.label - - def get_display(self, pk): - description = AttributeDescription.objects.get(pk=pk) - value = self.data.get(description.json_key) - return description.field.get_display(value) - - def get_field_names(self): - return AttributeDescription.objects.values_list('pk', flat=True) - - def get_statistics_bucket(self, rank): - description = get_description_by_statistics_rank(rank) - if not description: - return None - value = description.field.from_json(self.data.get(description.json_key)) - - if not value or value == ANSWER_DECLINED: - return None - - return description.field.get_statistics_bucket(value) - - def get_completeness(self): - # Needs to match ``filter_queries.completeness_expr()`` - completed = len([k for k, v in self.data.items() if v not in [None, '']]) - total = AttributeDescription.objects.count() - return completed, total - - class AttributeCategory(ImportMixin, TranslatableModel): import_fields = ['order'] import_translated_fields = ['label'] diff --git a/castellum/recruitment/models/recruitment.py b/castellum/recruitment/models/recruitment.py index a575f59a5300d6332b69094f203f410900865323..d094c9f51813c51f2201842ce06f1b9bf36ddd1c 100644 --- a/castellum/recruitment/models/recruitment.py +++ b/castellum/recruitment/models/recruitment.py @@ -32,8 +32,6 @@ from castellum.subjects.models import Subject from castellum.utils.fields import DateField from castellum.utils.fields import DateTimeField -from .attributesets import AttributeSet - class ParticipationRequestManager(models.Manager): def get_queryset(self): @@ -131,14 +129,14 @@ class ParticipationRequest(models.Model): @cached_property def match(self): from castellum.recruitment import filter_queries - if AttributeSet.objects.filter( + if Subject.objects.filter( filter_queries.study(self.study, include_unknown=False), - subject=self.subject, + pk=self.subject.pk, ).exists(): return 'complete' - elif AttributeSet.objects.filter( + elif Subject.objects.filter( filter_queries.study(self.study, include_unknown=True), - subject=self.subject, + pk=self.subject.pk, ).exists(): return 'incomplete' else: diff --git a/castellum/recruitment/models/subjectfilters.py b/castellum/recruitment/models/subjectfilters.py index 9bc66da142053e61ed83a48a2291f91c8a6727e3..a3cf7ebc3c49eb20be473c7c75681197d34e583c 100644 --- a/castellum/recruitment/models/subjectfilters.py +++ b/castellum/recruitment/models/subjectfilters.py @@ -25,9 +25,9 @@ from django.urls import reverse from castellum.recruitment import filter_queries from castellum.studies.models import Study +from castellum.subjects.models import Subject from ..models import AttributeDescription -from ..models import AttributeSet class SubjectFilterGroupManager(models.Manager): @@ -52,7 +52,7 @@ class SubjectFilterGroup(models.Model): return reverse('studies:filtergroup-update', args=[self.study.pk, self.pk]) def get_matches(self): - return AttributeSet.objects.filter( + return Subject.objects.filter( filter_queries.study_exclusion(self.study), filter_queries.study_disinterest(self.study), filter_queries.subjectfilters(self), diff --git a/castellum/recruitment/views.py b/castellum/recruitment/views.py index 536391cef34587680224a27510af476c77837ac3..02d336c3ad0f027f2c4d61fa6e94720f8b3ce483 100644 --- a/castellum/recruitment/views.py +++ b/castellum/recruitment/views.py @@ -52,19 +52,19 @@ from castellum.studies.mixins import StudyMixin from castellum.studies.models import Study from castellum.subjects.mixins import BaseSubjectUpdateView from castellum.subjects.mixins import SubjectMixin +from castellum.subjects.models import Subject from castellum.utils.views import GetFormView from .forms import ContactForm from .forms import SendMailForm from .models import AttributeDescription -from .models import AttributeSet from .models import MailBatch from .models import ParticipationRequest def get_recruitable(study): """Get all subjects who are recruitable for this study, i.e. apply all filters.""" - qs = AttributeSet.objects.filter( + qs = Subject.objects.filter( filter_queries.study(study), filter_queries.no_recruitment(), filter_queries.already_in_study(study), @@ -120,8 +120,8 @@ class RecruitmentView(StudyMixin, PermissionRequiredMixin, ListView): desc1 = get_description_by_statistics_rank('primary') desc2 = get_description_by_statistics_rank('secondary') order_by = [ - '-subject__attributeset__data__' + desc1.json_key, - '-subject__attributeset__data__' + desc2.json_key, + '-subject__attributes__' + desc1.json_key, + '-subject__attributes__' + desc2.json_key, '-updated_at', ] else: @@ -130,14 +130,14 @@ class RecruitmentView(StudyMixin, PermissionRequiredMixin, ListView): ] return ParticipationRequest.objects\ - .prefetch_related('subject__attributeset')\ + .prefetch_related('subject')\ .filter(status__in=self.shown_status, study=self.study)\ .order_by(*order_by) def get_statistics(self): participation_requests = ParticipationRequest.objects.filter( study=self.study, status=ParticipationRequest.INVITED - ).prefetch_related('subject__attributeset') + ).prefetch_related('subject') buckets1 = AttributeDescription.get_statistics_buckets('primary') buckets2 = AttributeDescription.get_statistics_buckets('secondary') @@ -147,13 +147,10 @@ class RecruitmentView(StudyMixin, PermissionRequiredMixin, ListView): statistics = defaultdict(lambda: defaultdict(lambda: 0)) for participation_request in participation_requests: - try: - attributeset = participation_request.subject.attributeset - key1 = attributeset.get_statistics_bucket('primary') - key2 = attributeset.get_statistics_bucket('secondary') - statistics[key1][key2] += 1 - except AttributeSet.DoesNotExist: - pass + subject = participation_request.subject + key1 = subject.get_statistics_bucket('primary') + key2 = subject.get_statistics_bucket('secondary') + statistics[key1][key2] += 1 data = { 'rows': [{ @@ -176,13 +173,10 @@ class RecruitmentView(StudyMixin, PermissionRequiredMixin, ListView): participants = [] for participation_request in self.get_queryset(): - try: - attributeset = participation_request.subject.attributeset - bucket1 = buckets1[attributeset.get_statistics_bucket('primary')] - bucket2 = buckets2[attributeset.get_statistics_bucket('secondary')] - buckets = ', '.join(str(bucket) for bucket in [bucket1, bucket2] if bucket) - except AttributeSet.DoesNotExist: - buckets = '' + subject = participation_request.subject + bucket1 = buckets1[subject.get_statistics_bucket('primary')] + bucket2 = buckets2[subject.get_statistics_bucket('secondary')] + buckets = ', '.join(str(bucket) for bucket in [bucket1, bucket2] if bucket) can_access = self.request.user.has_privacy_level( participation_request.subject.privacy_level @@ -246,11 +240,11 @@ class RecruitmentViewOpen(RecruitmentView): subtab = 'open' def create_participation_requests(self, batch_size): - attributesets = get_recruitable(self.study) + subjects = get_recruitable(self.study) - added = min(batch_size, len(attributesets)) - for attributeset in random.sample(attributesets, k=added): - ParticipationRequest.objects.create(study=self.study, subject=attributeset.subject) + added = min(batch_size, len(subjects)) + for subject in random.sample(subjects, k=added): + ParticipationRequest.objects.create(study=self.study, subject=subject) return added @@ -345,17 +339,17 @@ class MailRecruitmentView(StudyMixin, PermissionRequiredMixin, FormView): return data['email'] def create_participation_requests(self, batch_size): - attributesets = get_recruitable(self.study) + subjects = get_recruitable(self.study) from_email, connection = self.get_mail_settings() counter = 0 - while counter < batch_size and attributesets: - pick = attributesets.pop(random.randrange(0, len(attributesets))) - email = self.get_contact_email(pick.subject.contact) + while counter < batch_size and subjects: + pick = subjects.pop(random.randrange(0, len(subjects))) + email = self.get_contact_email(pick.contact) if email: mail = EmailMessage( self.study.mail_subject, - self.personalize_mail_body(self.study.mail_body, pick.subject.contact), + self.personalize_mail_body(self.study.mail_body, pick.contact), from_email, [email], reply_to=[self.study.mail_reply_address], @@ -365,7 +359,7 @@ class MailRecruitmentView(StudyMixin, PermissionRequiredMixin, FormView): if success: ParticipationRequest.objects.create( study=self.study, - subject=pick.subject, + subject=pick, status=ParticipationRequest.AWAITING_RESPONSE, ) counter += 1 @@ -494,7 +488,7 @@ class AttributeSetUpdateView(StudyMixin, BaseAttributeSetUpdateView): participation_request = get_object_or_404( ParticipationRequest, study=self.study, pk=self.kwargs['pk'] ) - return participation_request.subject.attributeset + return participation_request.subject def get_success_url(self): return reverse('recruitment:contact', args=[self.kwargs['study_pk'], self.kwargs['pk']]) diff --git a/castellum/studies/views/recruitment.py b/castellum/studies/views/recruitment.py index a392df42575f446e307e1645ea6ac8b959ccc417..118d92b0aee77fad19a9b231e822f07efb5940d7 100644 --- a/castellum/studies/views/recruitment.py +++ b/castellum/studies/views/recruitment.py @@ -30,9 +30,9 @@ from dateutil.relativedelta import relativedelta from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.recruitment import filter_queries -from castellum.recruitment.models import AttributeSet from castellum.recruitment.models import ParticipationRequest from castellum.recruitment.models import SubjectFilter +from castellum.subjects.models import Subject from castellum.utils.views import ReadonlyMixin from ..forms import ExcludedStudiesForm @@ -49,7 +49,7 @@ def get_related_studies(study): related_prs = ParticipationRequest.objects.filter( updated_at__gte=dt, status=ParticipationRequest.INVITED, - subject__attributeset__in=AttributeSet.objects.filter(filter_queries.study(study)) + subject__in=Subject.objects.filter(filter_queries.study(study)) ) return Study.objects\ .filter(participationrequest__in=related_prs)\ @@ -98,12 +98,12 @@ class StudyExcludedStudiesView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['count'] = ( - AttributeSet.objects.filter( + Subject.objects.filter( filter_queries.study(self.object), filter_queries.no_recruitment() ).count() ) - context['total_count'] = AttributeSet.objects.count() + context['total_count'] = Subject.objects.count() context['related_studies'] = list(get_related_studies(self.object)) return context diff --git a/castellum/studies/views/studies.py b/castellum/studies/views/studies.py index 3a13f000f44959ac62b1b02bd1f8b2b57c5c6bc4..486efea591051df44a97d2dbd126390406b20f33 100644 --- a/castellum/studies/views/studies.py +++ b/castellum/studies/views/studies.py @@ -40,8 +40,8 @@ from django.views.generic import UpdateView from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.recruitment import filter_queries -from castellum.recruitment.models import AttributeSet from castellum.recruitment.models import ParticipationRequest +from castellum.subjects.models import Subject from castellum.utils.forms import ReadonlyModelForm from castellum.utils.views import ReadonlyMixin from castellum.utils.views import get_next_url @@ -95,7 +95,7 @@ class StudyDetailView(PermissionRequiredMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['count'] = ( - AttributeSet.objects.filter( + Subject.objects.filter( filter_queries.study(self.object), filter_queries.no_recruitment() ).count() diff --git a/castellum/studies/views/subjectfilters.py b/castellum/studies/views/subjectfilters.py index f775f75e18a855b93173cf1d43ef27750879835f..52a02fd7f504b32016838fc42f3aaeb2ef37d8a4 100644 --- a/castellum/studies/views/subjectfilters.py +++ b/castellum/studies/views/subjectfilters.py @@ -39,9 +39,9 @@ from castellum.recruitment.forms import SubjectFilterAddForm from castellum.recruitment.forms import SubjectFilterForm from castellum.recruitment.forms import SubjectFilterFormSet from castellum.recruitment.models import AttributeDescription -from castellum.recruitment.models import AttributeSet from castellum.recruitment.models import SubjectFilter from castellum.recruitment.models import SubjectFilterGroup +from castellum.subjects.models import Subject from castellum.utils.views import ReadonlyMixin from ..mixins import StudyMixin @@ -58,9 +58,9 @@ class CustomFilterMixin: template='studies/filtergroup_blocked.html', context={ 'study': self.study, - 'total_count': AttributeSet.objects.count(), + 'total_count': Subject.objects.count(), 'count': ( - AttributeSet.objects.filter( + Subject.objects.filter( filter_queries.study(self.object), filter_queries.no_recruitment(), ).count() @@ -96,9 +96,9 @@ class FilterGroupListView(FilterMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['total_count'] = AttributeSet.objects.count() + context['total_count'] = Subject.objects.count() context['count'] = ( - AttributeSet.objects.filter( + Subject.objects.filter( filter_queries.study(self.study), filter_queries.no_recruitment() ).count() @@ -190,7 +190,7 @@ class FilterGroupUpdateView(FilterMixin, ReadonlyMixin, UpdateView): context['count'] = self.object.get_matches().count() - context['total_count'] = AttributeSet.objects.count() + context['total_count'] = Subject.objects.count() context['expected_subject_factor'] = settings.CASTELLUM_EXPECTED_SUBJECT_FACTOR context['expected_subject_count'] = ( self.study.min_subject_count * settings.CASTELLUM_EXPECTED_SUBJECT_FACTOR diff --git a/castellum/subjects/migrations/0008_merge_attributeset.py b/castellum/subjects/migrations/0008_merge_attributeset.py new file mode 100644 index 0000000000000000000000000000000000000000..6ef06b2f41f5186beea9f9f9285c4f8f98ac1fc7 --- /dev/null +++ b/castellum/subjects/migrations/0008_merge_attributeset.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.3 on 2020-03-10 11:49 + +import django.contrib.postgres.fields.jsonb +import django.core.serializers.json +from django.db import migrations, models + + +def migrate_data(apps, schema_editor): + AttributeSet = apps.get_model('recruitment', 'AttributeSet') + + for attributeset in AttributeSet.objects.all(): + attributeset.subject.attributes = attributeset.data + attributeset.subject.study_type_disinterest.set(attributeset.study_type_disinterest.all()) + attributeset.subject.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('studies', '0005_study_to_be_deleted_notified'), + ('subjects', '0007_consent_is_current'), + ] + + operations = [ + migrations.AddField( + model_name='subject', + name='attributes', + field=django.contrib.postgres.fields.jsonb.JSONField(default={}, encoder=django.core.serializers.json.DjangoJSONEncoder), + preserve_default=False, + ), + migrations.AddField( + model_name='subject', + name='study_type_disinterest', + field=models.ManyToManyField(blank=True, related_name='_subject_study_type_disinterest_+', to='studies.StudyType', verbose_name='Does not want to participate in the following study types:'), + ), + migrations.RunPython(migrate_data), + ] diff --git a/castellum/subjects/models.py b/castellum/subjects/models.py index b45bdb1323ea914c9bbea1a9ece821da58573d92..5584edb45c47e05b2c26ad1c39090d0df6858172 100644 --- a/castellum/subjects/models.py +++ b/castellum/subjects/models.py @@ -20,6 +20,8 @@ # . from django.conf import settings +from django.contrib.postgres.fields import JSONField +from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.forms import ValidationError from django.utils import timezone @@ -42,6 +44,14 @@ class TimeSlot(models.Model): class Subject(TimeStampedModel): + attributes = JSONField(encoder=DjangoJSONEncoder) + study_type_disinterest = models.ManyToManyField( + 'studies.StudyType', + verbose_name=_('Does not want to participate in the following study types:'), + blank=True, + related_name='+', + ) + privacy_level = models.PositiveIntegerField( _('Privacy level'), default=0, @@ -205,6 +215,46 @@ class Subject(TimeStampedModel): else: return self.get_next_available_datetime(now) + def get_data(self): + from castellum.recruitment.models import AttributeDescription + qs = AttributeDescription.objects.all() + return { + desc.json_key: desc.field.from_json(self.attributes.get(desc.json_key)) + for desc in qs + } + + # def get_verbose_name(self, pk): + # description = AttributeDescription.objects.get(pk=pk) + # return description.label + + # def get_display(self, pk): + # description = AttributeDescription.objects.get(pk=pk) + # value = self.attributes.get(description.json_key) + # return description.field.get_display(value) + + # def get_field_names(self): + # return AttributeDescription.objects.values_list('pk', flat=True) + + def get_statistics_bucket(self, rank): + from castellum.recruitment.models.attributesets import get_description_by_statistics_rank + from castellum.recruitment.models.attributesets import ANSWER_DECLINED + description = get_description_by_statistics_rank(rank) + if not description: + return None + value = description.field.from_json(self.attributes.get(description.json_key)) + + if not value or value == ANSWER_DECLINED: + return None + + return description.field.get_statistics_bucket(value) + + def get_completeness(self): + from castellum.recruitment.models import AttributeDescription + # Needs to match ``filter_queries.completeness_expr()`` + completed = len([k for k, v in self.attributes.items() if v not in [None, '']]) + total = AttributeDescription.objects.count() + return completed, total + class SubjectNote(models.Model): subject = models.ForeignKey(Subject, on_delete=models.CASCADE) diff --git a/castellum/subjects/templates/subjects/maintenance_attributes.html b/castellum/subjects/templates/subjects/maintenance_attributes.html index 4c05e74df985aaf84fcaa5e8669ae7f090acba14..aacba7967081343d5cf0726e51db03f8cf84fa0a 100644 --- a/castellum/subjects/templates/subjects/maintenance_attributes.html +++ b/castellum/subjects/templates/subjects/maintenance_attributes.html @@ -4,7 +4,7 @@ {% block title %}{% trans "Attributes" %} · {{ block.super }}{% endblock %} {% block content %} -

{% trans 'The following subjects have incomplete attributesets:' %}

+

{% trans 'The following subjects have incomplete attributes:' %}