From 7cdfe2fb54a6360377fb1a4d98d907b71771b21d Mon Sep 17 00:00:00 2001 From: Tobias Bengfort Date: Tue, 10 Mar 2020 14:30:32 +0100 Subject: [PATCH 1/2] apply filter queries to Subject instead of AttributeSet --- castellum/recruitment/attribute_fields.py | 6 ++--- castellum/recruitment/filter_queries.py | 20 +++++++------- castellum/recruitment/models/recruitment.py | 10 +++---- .../recruitment/models/subjectfilters.py | 4 +-- castellum/recruitment/views.py | 27 ++++++++++--------- castellum/studies/views/recruitment.py | 8 +++--- castellum/studies/views/studies.py | 4 +-- castellum/studies/views/subjectfilters.py | 12 ++++----- castellum/subjects/views.py | 18 +++++-------- .../recruitment/models/test_subjectfilters.py | 9 ++++--- 10 files changed, 57 insertions(+), 61 deletions(-) diff --git a/castellum/recruitment/attribute_fields.py b/castellum/recruitment/attribute_fields.py index 2bb89cf55..226620fe7 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 = 'attributeset__data__' + 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 = 'attributeset__data__' + 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(**{'attributeset__data__' + 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 d1f256703..4c2bd360d 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,12 +74,12 @@ 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): """Exclude subjects who are disinterested in this study's study type.""" - return ~models.Q(study_type_disinterest__in=study.study_type.all()) + return ~models.Q(attributeset__study_type_disinterest__in=study.study_type.all()) def subjectfilters(subjectfiltergroup, include_unknown=None): @@ -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 = 'attributeset__data__' + description.json_key completeness += models.Case( models.When(( models.Q(**{field_name: ''}) | diff --git a/castellum/recruitment/models/recruitment.py b/castellum/recruitment/models/recruitment.py index a4905beb2..266b0a9a2 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 96b5e7b6b..511a843c6 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 421016072..65228fe9c 100644 --- a/castellum/recruitment/views.py +++ b/castellum/recruitment/views.py @@ -53,6 +53,7 @@ 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 @@ -67,7 +68,7 @@ logger = logging.getLogger(__name__) 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), @@ -75,9 +76,9 @@ def get_recruitable(study): last_contacted = timezone.now() - settings.CASTELLUM_PERIOD_BETWEEN_CONTACT_ATTEMPTS qs = qs.annotate(last_contacted=models.Max( - 'subject__participationrequest__revisions__created_at', + 'participationrequest__revisions__created_at', filter=~models.Q( - subject__participationrequest__revisions__status=ParticipationRequest.NOT_CONTACTED + participationrequest__revisions__status=ParticipationRequest.NOT_CONTACTED ), )).filter( models.Q(last_contacted__lte=last_contacted) | @@ -249,11 +250,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 @@ -348,17 +349,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], @@ -372,7 +373,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 diff --git a/castellum/studies/views/recruitment.py b/castellum/studies/views/recruitment.py index 1f72a00a7..fd3b70f62 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 662b45281..89ad5c604 100644 --- a/castellum/studies/views/studies.py +++ b/castellum/studies/views/studies.py @@ -42,8 +42,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 @@ -97,7 +97,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 789f84728..448740c62 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/views.py b/castellum/subjects/views.py index 8a45b4b25..3ae583f2f 100644 --- a/castellum/subjects/views.py +++ b/castellum/subjects/views.py @@ -84,7 +84,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() @@ -352,20 +352,17 @@ class AddToStudyView(SubjectMixin, PermissionRequiredMixin, DetailView): permission_required = 'contacts.view_contact' tab = 'participations' - 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 @@ -374,7 +371,7 @@ class AddToStudyView(SubjectMixin, PermissionRequiredMixin, DetailView): try: context['attributeset'] = self.object.attributeset - context['studies'] = list(self.get_studies(self.object.attributeset)) + context['studies'] = list(self.get_studies(self.object)) except AttributeSet.DoesNotExist: pass @@ -444,13 +441,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/recruitment/models/test_subjectfilters.py b/tests/recruitment/models/test_subjectfilters.py index 668feb4c8..a99ae21d8 100644 --- a/tests/recruitment/models/test_subjectfilters.py +++ b/tests/recruitment/models/test_subjectfilters.py @@ -8,6 +8,7 @@ from castellum.recruitment.models import SubjectFilter from castellum.recruitment.models import SubjectFilterGroup from castellum.studies.models import Study from castellum.studies.models import StudyType +from castellum.subjects.models import Subject def create_participation_request( @@ -21,7 +22,7 @@ def create_participation_request( def has_match(is_exclusive=False, excluded_studies=[]): study = baker.make(Study, is_exclusive=is_exclusive, excluded_studies=excluded_studies) baker.make(SubjectFilterGroup, study=study) - return AttributeSet.objects.filter(filter_queries.study(study)).exists() + return Subject.objects.filter(filter_queries.study(study)).exists() def test_to_q(attributeset): @@ -48,7 +49,7 @@ def test_to_q_reverse_excluded_study(attributeset): pr = create_participation_request(attributeset.subject) study = baker.make(Study) pr.study.excluded_studies.add(study) - assert not AttributeSet.objects.filter(filter_queries.study(study)).exists() + assert not Subject.objects.filter(filter_queries.study(study)).exists() def test_to_q_exclusive_study(attributeset): @@ -69,7 +70,7 @@ def test_to_q_exclusive_study_in_same_study(attributeset): study=study, ) q = filter_queries.study_exclusion(study) - assert AttributeSet.objects.filter(q).exists() + assert Subject.objects.filter(q).exists() def test_to_q_exclusive_study_and_unfinished(attributeset): @@ -103,7 +104,7 @@ def test_disinterest_in_study_type(db): study = baker.make(Study) study.study_type.add(study_type) baker.make(AttributeSet, study_type_disinterest=[study_type]) - assert not AttributeSet.objects.filter(filter_queries.study(study)).exists() + assert not Subject.objects.filter(filter_queries.study(study)).exists() @pytest.mark.django_db -- GitLab From c80ed97d5259ffbf1a9b68367e44a5b7ea571dba Mon Sep 17 00:00:00 2001 From: Tobias Bengfort Date: Mon, 16 Mar 2020 15:34:19 +0100 Subject: [PATCH 2/2] fix test: no attributeset, but recruitable --- tests/recruitment/views/test_mail_recruitment_view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/recruitment/views/test_mail_recruitment_view.py b/tests/recruitment/views/test_mail_recruitment_view.py index e007ba71a..576698b47 100644 --- a/tests/recruitment/views/test_mail_recruitment_view.py +++ b/tests/recruitment/views/test_mail_recruitment_view.py @@ -44,6 +44,8 @@ def test_guardian_mail_add_to_study(client, user): contact1 = baker.make(Contact, email="example1@example.com") contact2 = baker.make(Contact, guardians=[contact1]) baker.make(AttributeSet, subject=contact2.subject) + contact1.subject.no_recruitment = True + contact1.subject.save() study.members.add(user) view = MailRecruitmentView() -- GitLab