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:' %}
{% for subject in object_list %}
@@ -12,12 +12,12 @@
-
- {% with completeness=subject.attributeset.get_completeness %}
- {% trans 'Attributeset completeness' %}: {{ completeness.0 }}/{{ completeness.1 }}
+ {% with completeness=subject.get_completeness %}
+ {% trans 'Attribute completeness' %}: {{ completeness.0 }}/{{ completeness.1 }}
{% endwith %}
- {% trans 'Last updated on' %}: {{ subject.attributeset.updated_at|date }}
+ {% trans 'Last updated on' %}: {{ subject.updated_at|date }}
diff --git a/castellum/subjects/templates/subjects/subject_add_to_study.html b/castellum/subjects/templates/subjects/subject_add_to_study.html
index 9c613132fb80f1fa87fb7485a1a2ca381ff62bfc..cf9fbc0b8afe66e4f8746ac631d264ffd490c58f 100644
--- a/castellum/subjects/templates/subjects/subject_add_to_study.html
+++ b/castellum/subjects/templates/subjects/subject_add_to_study.html
@@ -4,30 +4,23 @@
{% block title %}{% trans "Add study participation" %} · {{ block.super }}{% endblock %}
{% block content %}
- {% if not attributeset %}
-
-
{% trans 'No attributes provided' %}
-
{% trans 'Please first provide attributes so we can check whether this person meets the study filters.' %}
-
- {% else %}
-
- {% endif %}
+
{% trans "Back" %}
diff --git a/castellum/subjects/templates/subjects/subject_detail.html b/castellum/subjects/templates/subjects/subject_detail.html
index 110d363e5fcd59dbc36111eaf4735eea1fcfd45b..ef306b99f6aef07bb20b1e7c750a5ec5e0bb0492 100644
--- a/castellum/subjects/templates/subjects/subject_detail.html
+++ b/castellum/subjects/templates/subjects/subject_detail.html
@@ -59,8 +59,8 @@
{% endif %}
-
- {% trans 'Attribute set completeness' %}
-
- {{ attributeset_completeness|default:'—' }}
+
- {% trans 'Attribute completeness' %}
+
- {{ attribute_completeness|default:'—' }}
{% if coverletter_exists %}
diff --git a/castellum/subjects/views.py b/castellum/subjects/views.py
index bb150286f0b786e0ce3ebc6308eac44185860fd5..8cf4fb779697832befe4be2f10dfebc55d8239a6 100644
--- a/castellum/subjects/views.py
+++ b/castellum/subjects/views.py
@@ -25,7 +25,6 @@ import zipfile
from django.conf import settings
from django.contrib import messages
-from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import PermissionDenied
from django.db import models
from django.http import Http404
@@ -51,7 +50,6 @@ from castellum.contacts.mixins import BaseContactUpdateView
from castellum.contacts.models import Contact
from castellum.recruitment import filter_queries
from castellum.recruitment.mixins import BaseAttributeSetUpdateView
-from castellum.recruitment.models import AttributeSet
from castellum.recruitment.models import ParticipationRequest
from castellum.studies.models import Study
from castellum.utils.views import GetFormView
@@ -84,7 +82,7 @@ class SubjectSearchView(PermissionRequiredMixin, GetFormView):
).count()
completeness, total = filter_queries.completeness_expr()
- context['count_complete'] = AttributeSet.objects\
+ context['count_complete'] = Subject.objects\
.annotate(completeness=completeness)\
.filter(completeness=total)\
.count()
@@ -151,13 +149,8 @@ class SubjectDetailView(SubjectMixin, PermissionRequiredMixin, DetailView):
context['updated_at'] = max(self.object.updated_at, self.object.contact.updated_at)
- try:
- context['updated_at'] = max(context['updated_at'], self.object.attributeset.updated_at)
-
- completed, total = self.object.attributeset.get_completeness()
- context['attributeset_completeness'] = '%i%%' % (100 * completed / (total or 1))
- except AttributeSet.DoesNotExist:
- pass
+ completed, total = self.object.get_completeness()
+ context['attribute_completeness'] = '%i%%' % (100 * completed / (total or 1))
context['coverletter_exists'] = bool(settings.CASTELLUM_COVERLETTER_TEMPLATE)
@@ -211,11 +204,6 @@ class SubjectExportView(SubjectMixin, PermissionRequiredMixin, DetailView):
context['dataset_list'] = [self.object, self.object.contact]
- try:
- context['dataset_list'].append(self.object.attributeset)
- except ObjectDoesNotExist:
- pass
-
for participation_request in self.object.participationrequest_set.all():
context['dataset_list'].append(participation_request)
@@ -277,14 +265,8 @@ class ContactUpdateView(BaseContactUpdateView):
class AttributeSetUpdateView(BaseAttributeSetUpdateView):
tab = 'attributeset'
- def get_object(self):
- subject = get_object_or_404(Subject, pk=self.kwargs['pk'])
- attributeset, __ = AttributeSet.objects.get_or_create(
- subject=subject, defaults={'data': {}})
- return attributeset
-
def get_success_url(self):
- return reverse('subjects:attributeset', args=[self.object.subject.pk])
+ return reverse('subjects:attributeset', args=[self.object.pk])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -349,32 +331,23 @@ class AddToStudyView(SubjectMixin, PermissionRequiredMixin, DetailView):
permission_required = 'contacts.view_contact'
tab = 'studyparticipation'
- def get_studies(self, attributeset):
+ def get_studies(self, subject):
perms = ('recruitment.add_participationrequest', 'studies.access_study')
studies = (
Study.objects
.filter(status=Study.EXECUTION)
- .exclude(participationrequest__subject=attributeset.subject)
+ .exclude(participationrequest__subject=subject)
)
for study in studies:
if (
self.request.user.has_perms(perms, obj=study) and
- AttributeSet.objects.filter(
- filter_queries.study(study),
- pk=attributeset.pk,
- ).exists()
+ Subject.objects.filter(filter_queries.study(study), pk=subject.pk).exists()
):
yield study
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
-
- try:
- context['attributeset'] = self.object.attributeset
- context['studies'] = list(self.get_studies(self.object.attributeset))
- except AttributeSet.DoesNotExist:
- pass
-
+ context['studies'] = list(self.get_studies(self.object))
return context
def post(self, request, *args, **kwargs):
@@ -441,13 +414,12 @@ class MaintenanceAttributesView(PermissionRequiredMixin, ListView):
tab = 'attributes'
def get_queryset(self):
- completeness, total = filter_queries.completeness_expr('attributeset__')
+ completeness, total = filter_queries.completeness_expr()
return super().get_queryset()\
.exclude(attributeset=None)\
.annotate(completeness=completeness)\
.filter(completeness__lt=total, no_recruitment=False)\
- .order_by('completeness')\
- .select_related('attributeset')
+ .order_by('completeness')
class MaintenanceContactView(PermissionRequiredMixin, ListView):
diff --git a/tests/conftest.py b/tests/conftest.py
index 488679ec01502facfabb402fe7b6374746584d1f..9ba9f1063d1f52353ac58a7c8e281949cc3e501d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,7 +15,6 @@ from castellum.castellum_auth.models import User
from castellum.contacts.models import Contact
from castellum.recruitment.management.commands import create_attribute_descriptions
from castellum.recruitment.models import AttributeDescription
-from castellum.recruitment.models import AttributeSet
from castellum.recruitment.models import ParticipationRequest
from castellum.recruitment.models import SubjectFilter
from castellum.recruitment.models import SubjectFilterGroup
@@ -142,11 +141,6 @@ def contact(db):
return baker.make(Contact)
-@pytest.fixture
-def attributeset(contact):
- return baker.make(AttributeSet, subject=contact.subject)
-
-
@pytest.fixture
def participation_request(contact, study_in_execution_status):
return baker.make(