diff --git a/README.rst b/README.rst index eacee69..f91fea1 100644 --- a/README.rst +++ b/README.rst @@ -8,8 +8,41 @@ Add classes for the Informed Consent form and process. Installation ============ -Register your consent model, its version and period of validity, with ``site_consents``. ``site_consents`` will ``autodiscover`` ``consents.py`` in any app listed in ``INSTALLED_APPS``. For now we just create a version 1 consent. In ``consents.py`` add something like this: +Declare the consent model: +.. code-block:: python + + class SubjectConsent( + ConsentModelMixin, + SiteModelMixin, + UpdatesOrCreatesRegistrationModelMixin, + NonUniqueSubjectIdentifierModelMixin, + IdentityFieldsMixin, + PersonalFieldsMixin, + SampleCollectionFieldsMixin, + ReviewFieldsMixin, + VulnerabilityFieldsMixin, + SearchSlugModelMixin, + BaseUuidModel, + ): + + """A model completed by the user that captures the ICF.""" + + subject_identifier_cls = SubjectIdentifier + + subject_screening_model = "effect_screening.subjectscreening" + + objects = SubjectConsentManager() + on_site = CurrentSiteManager() + consent = ConsentManager() + history = HistoricalRecords() + + class Meta(ConsentModelMixin.Meta, BaseUuidModel.Meta): + pass + +Declare at least one ``ConsentDefinition`` that references your consent model. + +``ConsentDefinitions`` are declared in the root of your app in module ``consents.py``. A typical declaration looks something like this: .. code-block:: python @@ -20,17 +53,18 @@ Register your consent model, its version and period of validity, with ``site_con from edc_consent.site_consents import site_consents from edc_constants.constants import MALE, FEMALE - subjectconsent_v1 = ConsentDefinition( + consent_v1 = ConsentDefinition( 'edc_example.subjectconsent', version='1', start=datetime(2013, 10, 15, tzinfo=ZoneInfo("UTC")), - end=datetime(2016, 10, 15, tzinfo=ZoneInfo("UTC")), + end=datetime(2016, 10, 15, 23, 59, 999999, tzinfo=ZoneInfo("UTC")), age_min=16, age_is_adult=18, age_max=64, gender=[MALE, FEMALE]) - site_consents.register(subjectconsent_v1) + site_consents.register(consent_v1) + add to settings: @@ -42,8 +76,124 @@ add to settings: ... ] +On bootup ``site_consents`` will ``autodiscover`` the ``consents.py`` and register the ``ConsentDefinition``. + +Now create an instance of the ``SubjectConsent`` model, ``subject_consent``. When the instance is saved, the model will find the ``ConsentDefinition`` with a validity period that includes ``subject_consent.consent_datetime`` and update ``subject_consent.version`` with the value of ``consent_definition.version``. +In this case, for ``consent_datetime`` equal to ``datetime(2013, 10, 16, tzinfo=ZoneInfo("UTC"))``, the model will find ``consent_v1``. +If the ``consent_datetime`` is outside of the date boundary, for example datetime(2017, 1, 1, tzinfo=ZoneInfo("UTC")), the model will not find a +``ConsentDefinition`` and an exception will be raised (``ConsentDefinitionNotFound``). + +Add a second ``ConsentDefinition`` to ``your consents.py`` for version 2: + +.. code-block:: python + + consent_v1 = ConsentDefinition(...) + + consent_v2 = ConsentDefinition( + 'edc_example.subjectconsent', + version='2', + start=datetime(2016, 10, 16, 0,0,0, tzinfo=ZoneInfo("UTC")), + end=datetime(2020, 10, 15, 23, 59, 999999, tzinfo=ZoneInfo("UTC")), + age_min=16, + age_is_adult=18, + age_max=64, + gender=[MALE, FEMALE]) + + site_consents.register(consent_v1) + site_consents.register(consent_v2) + + +Now resave the instance from above with ``consent_datetime = datetime(2017, 1, 1, tzinfo=ZoneInfo("UTC"))``. The model will find +``consent_v2`` and update ``subject_consent.version = consent_v2.version`` which in this case is "2". + +``edc_consent`` is coupled with ``edc_visit_schedule``. In fact, a data collection schedule is declared with one or more ``ConsentDefinitions``. CRFs and Requisitions listed in a schedule may only be submitted if the subject has consented. + +.. code-block:: python + + schedule = Schedule( + name=SCHEDULE, + verbose_name="Day 1 to Month 6 Follow-up", + onschedule_model="effect_prn.onschedule", + offschedule_model="effect_prn.endofstudy", + consent_definitions=[consent_v1, consent_v2], + ) + +When a CRF is saved, the CRF model will check the ``schedule`` to find the ``ConsentDefinition`` with a validity period that contains the ``crf.report_datetime``. Using the located ``ConsentDefinitions``, the CRF model will confirm the subject has a saved ``subject_consent`` with this ``consent_definition.version``. + +When there is more than one ``ConsentDefinition`` but still just one ``SubjectConsent`` model, declaring proxy models +provides some clarity and allows the ``ModelForm`` and ``ModelAdmin`` classes to be customized. + +.. code-block:: python + + class SubjectConsentV1(SubjectConsent): + + class Meta: + proxy = True + verbose_name = "Consent V1" + verbose_name_plural = "Consent V1" + + + class SubjectConsentV2(SubjectConsent): + + class Meta: + proxy = True + verbose_name = "Consent V2" + verbose_name_plural = "Consent V2" + + +.. code-block:: python + + consent_v1 = ConsentDefinition( + 'edc_example.subjectconsentv1', + version='1', ...) + + consent_v2 = ConsentDefinition( + 'edc_example.subjectconsentv2', + version='2', ...) + + site_consents.register(consent_v1) + site_consents.register(consent_v2) + +Now each model can use a custom ``ModelAdmin`` class. + +The ConsentDefinitions above assume that consent version 1 is completed for a subject +consenting on or before 2016/10/15 and version 2 for those consenting after 2016/10/15. + +Sometimes when version 2 is introduced, those subjects who consented for version 1 need +to update their version 1 consent to version 2. For example, a question may have been added +in version 2 to allow a subject to opt-out of having their specimens put into longterm +storage. The subjects who are already consented under version 1 need to indicate their +preference as well by submitting a version 2 consent. (To make things simple, we would +programatically carry-over and validate duplicate data from the subject's version 1 consent.) + +To allow this, we would add ``update_versions`` to the version 2 ``ConsentDefinition``. + +.. code-block:: python + + consent_v1 = ConsentDefinition( + 'edc_example.subjectconsentv1', + version='1', ...) + + consent_v2 = ConsentDefinition( + 'edc_example.subjectconsentv2', + version='2', + update_versions=[UpdateVersion(consent_v1.version, consent_v1.end)], + + site_consents.register(consent_v1) + site_consents.register(consent_v2) + +As the trial continues past 2016/10/15, there will three categories of subjects: + +* Subjects who completed version 1 only +* Subjects who completed version 1 and version 2 +* Subjects who completed version 2 only + +If the report date is after 2016/10/15, data entry for "Subjects who completed version 1 only" +will be blocked until the version 2 consent is submitted. + + + - Below needs to be updated Features ======== @@ -65,56 +215,24 @@ TODO Usage ===== -Then declare the consent model: -.. code-block:: python - - class SubjectConsent( - ConsentModelMixin, - SiteModelMixin, - UpdatesOrCreatesRegistrationModelMixin, - NonUniqueSubjectIdentifierModelMixin, - IdentityFieldsMixin, - PersonalFieldsMixin, - SampleCollectionFieldsMixin, - ReviewFieldsMixin, - VulnerabilityFieldsMixin, - SearchSlugModelMixin, - BaseUuidModel, - ): - - """A model completed by the user that captures the ICF.""" - - subject_identifier_cls = SubjectIdentifier - - subject_screening_model = "effect_screening.subjectscreening" - - objects = SubjectConsentManager() - on_site = CurrentSiteManager() - consent = ConsentManager() - history = HistoricalRecords() - - class Meta(ConsentModelMixin.Meta, BaseUuidModel.Meta): - pass Declare the ModelForm: .. code-block:: python - class MyConsentForm(BaseConsentForm): + class SubjectConsentForm(BaseConsentForm): class Meta: - model = MyConsent + model = SubjectConsent -Now that you have a consent model class, identify and declare the models that will require this consent: +Now that you have a consent model class, declare the models that will require this consent: .. code-block:: python class Questionnaire(RequiresConsentMixin, models.Model): - consent_model = MyConsent # or tuple (app_label, model_name) - report_datetime = models.DateTimeField(default=timezone.now) question1 = models.CharField(max_length=10) @@ -132,18 +250,13 @@ Now that you have a consent model class, identify and declare the models that wi app_label = 'my_app' verbose_name = 'My Questionnaire' -Notice above the first two class attributes, namely: - -* consent_model: this is the consent model class that was declared above; -* report_datetime: a required field used to lookup the correct consent version from ConsentType and to find, together with ``subject_identifier``, a valid instance of ``MyConsent``; - -Also note the property ``subject_identifier``. -* subject_identifier: a required property that knows how to find the ``subject_identifier`` for the instance of ``Questionnaire``. +* report_datetime: a required field used to lookup the correct ``ConsentDefinition`` and to find, together with ``subject_identifier``, a valid instance of ``SubjectConsent``; +* subject_identifier: a required field or may be a property that knows how to find the ``subject_identifier`` for the instance of ``Questionnaire``. Once all is declared you need to: -* define the consent version and validity period for the consent version in ``ConsentType``; +* define the consent version and validity period for the consent version in ``ConsentDefinition``; * add a Quota for the consent model. As subjects are identified: @@ -151,9 +264,9 @@ As subjects are identified: * add a consent * add the models (e.g. ``Questionnaire``) -If a consent version cannot be found given the consent model class and report_datetime a ``ConsentTypeError`` is raised. +If a consent version cannot be found given the consent model class and report_datetime a ``ConsentDefinitionError`` is raised. -If a consent for this subject_identifier cannot be found that matches the ``ConsentType`` a ``NotConsentedError`` is raised. +If a consent for this subject_identifier cannot be found that matches the ``ConsentDefinition`` a ``NotConsentedError`` is raised. Specimen Consent ================ @@ -227,12 +340,12 @@ Common senarios Tracking the consent version with collected data ++++++++++++++++++++++++++++++++++++++++++++++++ -All model data is tagged with the consent version identified in ``ConsentType`` for the consent model class and report_datetime. +All model data is tagged with the consent version identified in ``ConsentDefinition`` for the consent model class and report_datetime. Reconsenting consented subjects when the consent changes ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -The consent model is unique on subject_identifier, identity and version. If a new consent version is added to ``ConsentType``, a new consent will be required for each subject as data is reported within the validity period of the new consent. +The consent model is unique on subject_identifier, identity and version. If a new consent version is added to ``ConsentDefinition``, a new consent will be required for each subject as data is reported within the validity period of the new consent. Some care must be taken to ensure that the consent model is queried with an understanding of the unique constraint. diff --git a/consent_app/__init__.py b/consent_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/consent_app/apps.py b/consent_app/apps.py new file mode 100644 index 0000000..7832e64 --- /dev/null +++ b/consent_app/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig as DjangoAppConfig + + +class AppConfig(DjangoAppConfig): + name = "consent_app" + verbose_name = "Edc Consent test app" + include_in_administration_section = False diff --git a/edc_consent/baker_recipes.py b/consent_app/baker_recipes.py similarity index 55% rename from edc_consent/baker_recipes.py rename to consent_app/baker_recipes.py index 7f33cbf..de93f85 100644 --- a/edc_consent/baker_recipes.py +++ b/consent_app/baker_recipes.py @@ -5,7 +5,7 @@ from faker import Faker from model_bakery.recipe import Recipe, seq -from .tests.models import SubjectConsent +from .models import SubjectConsent, SubjectConsentV3 fake = Faker() @@ -34,3 +34,29 @@ consent_signature=YES, site=Site.objects.get_current(), ) + +subjectconsentv3 = Recipe( + SubjectConsentV3, + consent_datetime=get_utcnow, + dob=get_utcnow() - relativedelta(years=25), + first_name=fake.first_name, + last_name=fake.last_name, + # note, passes for model but won't pass validation in modelform clean() + initials="AA", + gender=MALE, + # will raise IntegrityError if multiple made without _quantity + identity=seq("12315678"), + # will raise IntegrityError if multiple made without _quantity + confirm_identity=seq("12315678"), + identity_type="passport", + is_dob_estimated="-", + language="en", + is_literate=YES, + is_incarcerated=NO, + study_questions=YES, + consent_reviewed=YES, + consent_copy=YES, + assessment_score=YES, + consent_signature=YES, + site=Site.objects.get_current(), +) diff --git a/edc_consent/tests/models.py b/consent_app/models.py similarity index 89% rename from edc_consent/tests/models.py rename to consent_app/models.py index a681981..798b959 100644 --- a/edc_consent/tests/models.py +++ b/consent_app/models.py @@ -17,15 +17,10 @@ ReviewFieldsMixin, VulnerabilityFieldsMixin, ) -from edc_consent.model_mixins import ( - ConsentDefinitionModelMixin, - ConsentModelMixin, - RequiresConsentFieldsModelMixin, -) +from edc_consent.model_mixins import ConsentModelMixin, RequiresConsentFieldsModelMixin -class SubjectScreening(SiteModelMixin, ConsentDefinitionModelMixin, BaseUuidModel): - consent_definition = None +class SubjectScreening(SiteModelMixin, BaseUuidModel): screening_identifier = models.CharField(max_length=25, unique=True) @@ -74,6 +69,26 @@ class Meta(ConsentModelMixin.Meta): pass +class SubjectConsentV1(SubjectConsent): + class Meta: + proxy = True + + +class SubjectConsentV2(SubjectConsent): + class Meta: + proxy = True + + +class SubjectConsentV3(SubjectConsent): + class Meta: + proxy = True + + +class SubjectConsentUpdateToV3(SubjectConsent): + class Meta: + proxy = True + + class SubjectReconsent( ConsentModelMixin, SiteModelMixin, diff --git a/edc_consent/tests/visit_schedules.py b/consent_app/visit_schedules.py similarity index 95% rename from edc_consent/tests/visit_schedules.py rename to consent_app/visit_schedules.py index 885bd30..9842b97 100644 --- a/edc_consent/tests/visit_schedules.py +++ b/consent_app/visit_schedules.py @@ -14,7 +14,7 @@ def get_visit_schedule( consent_definition: ConsentDefinition | list[ConsentDefinition], ) -> VisitSchedule: - crfs = CrfCollection(Crf(show_order=1, model="edc_consent.crfone", required=True)) + crfs = CrfCollection(Crf(show_order=1, model="consent_app.crfone", required=True)) visit = Visit( code="1000", diff --git a/edc_consent/consent_definition.py b/edc_consent/consent_definition.py index cc7a0cb..7486749 100644 --- a/edc_consent/consent_definition.py +++ b/edc_consent/consent_definition.py @@ -5,9 +5,10 @@ from typing import TYPE_CHECKING, Type from django.apps import apps as django_apps -from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist from edc_constants.constants import FEMALE, MALE from edc_protocol.research_protocol_config import ResearchProtocolConfig +from edc_screening.utils import get_subject_screening_model from edc_sites import site_sites from edc_utils import floor_secs, formatted_date, formatted_datetime from edc_utils.date import ceil_datetime, floor_datetime, to_local, to_utc @@ -15,17 +16,20 @@ from .exceptions import ( ConsentDefinitionError, ConsentDefinitionValidityPeriodError, - ConsentVersionSequenceError, NotConsentedError, ) if TYPE_CHECKING: from edc_identifier.model_mixins import NonUniqueSubjectIdentifierModelMixin + from edc_model.models import BaseUuidModel + from edc_screening.model_mixins import EligibilityModelMixin, ScreeningModelMixin from .model_mixins import ConsentModelMixin class ConsentLikeModel(NonUniqueSubjectIdentifierModelMixin, ConsentModelMixin): ... + class SubjectScreening(ScreeningModelMixin, EligibilityModelMixin, BaseUuidModel): ... + @dataclass(order=True) class ConsentDefinition: @@ -35,26 +39,35 @@ class ConsentDefinition: model: str = field(compare=False) _ = KW_ONLY - start: datetime = field( - default=ResearchProtocolConfig().study_open_datetime, compare=False - ) + start: datetime = field(default=ResearchProtocolConfig().study_open_datetime, compare=True) end: datetime = field(default=ResearchProtocolConfig().study_close_datetime, compare=False) + version: str = field(default="1", compare=False) + updates: tuple[ConsentDefinition, str] = field(default=tuple, compare=False) + updated_by: str = field(default=None, compare=False) + screening_model: str = field(default=None, compare=False) age_min: int = field(default=18, compare=False) age_max: int = field(default=110, compare=False) age_is_adult: int = field(default=18, compare=False) - version: str = field(default="1", compare=False) gender: list[str] | None = field(default_factory=list, compare=False) - updates_versions: list[str] | None = field(default_factory=list, compare=False) - subject_type: str = field(default="subject", compare=False) site_ids: list[int] = field(default_factory=list, compare=False) country: str | None = field(default=None, compare=False) - name: str = field(init=False, compare=True) + subject_type: str = field(default="subject", compare=False) + name: str = field(init=False, compare=False) + update_cdef: ConsentDefinition = field(default=None, init=False, compare=False) + update_model: str = field(default=None, init=False, compare=False) + update_version: str = field(default=None, init=False, compare=False) sort_index: str = field(init=False) def __post_init__(self): self.name = f"{self.model}-{self.version}" self.sort_index = self.name self.gender = [MALE, FEMALE] if not self.gender else self.gender + try: + self.update_cdef, self.update_model = self.updates + except (ValueError, TypeError): + pass + if not self.screening_model: + self.screening_model = get_subject_screening_model() if MALE not in self.gender and FEMALE not in self.gender: raise ConsentDefinitionError(f"Invalid gender. Got {self.gender}.") if not self.start.tzinfo: @@ -83,29 +96,43 @@ def get_consent_for( self, subject_identifier: str = None, report_datetime: datetime | None = None, - raise_if_does_not_exist: bool | None = None, + raise_if_not_consented: bool | None = None, ) -> ConsentLikeModel | None: - consent = None - raise_if_does_not_exist = ( - True if raise_if_does_not_exist is None else raise_if_does_not_exist + """Returns a subject consent using this consent_definition's + model_cls and version. + + If it does not exist and this consent_definition updates a + previous (update_cdef), will try again with the update_cdef's + model_cls and version. + + Finally, if the subject cosent does not exist raises a + NotConsentedError. + """ + consent_obj = None + raise_if_not_consented = ( + True if raise_if_not_consented is None else raise_if_not_consented ) opts: dict[str, str | datetime] = dict( - subject_identifier=subject_identifier, - version=self.version, + subject_identifier=subject_identifier, version=self.version ) if report_datetime: opts.update(consent_datetime__lte=to_utc(report_datetime)) try: - consent = self.model_cls.objects.get(**opts) + consent_obj = self.model_cls.objects.get(**opts) except ObjectDoesNotExist: - if raise_if_does_not_exist: - dte = formatted_date(report_datetime) - raise NotConsentedError( - f"Consent not found. Has subject '{subject_identifier}' " - f"completed version '{self.version}' of consent " - f"'{self.model_cls._meta.verbose_name}' on or after '{dte}'?" - ) - return consent + if self.update_cdef: + opts.update(version=self.update_cdef.version) + try: + consent_obj = self.update_cdef.model_cls.objects.get(**opts) + except ObjectDoesNotExist: + pass + if not consent_obj and raise_if_not_consented: + dte = formatted_date(report_datetime) + raise NotConsentedError( + f"Consent not found. Has subject '{subject_identifier}' " + f"completed version '{self.version}' of consent on or after '{dte}'?" + ) + return consent_obj @property def model_cls(self) -> Type[ConsentLikeModel]: @@ -119,6 +146,10 @@ def display_name(self) -> str: f"{formatted_date(to_local(self.end))}" ) + @property + def verbose_name(self) -> str: + return self.model_cls._meta.verbose_name + def valid_for_datetime_or_raise(self, report_datetime: datetime) -> None: if not ( floor_secs(floor_datetime(self.start)) @@ -150,45 +181,15 @@ def check_date_within_study_period(self) -> None: f"See {self}. Got {date_string}." ) - def update_previous_consent(self, obj: ConsentLikeModel) -> None: - if self.updates_versions: - previous_consent = self.get_previous_consent( - subject_identifier=obj.subject_identifier, - ) - previous_consent.subject_identifier_as_pk = obj.subject_identifier_as_pk - previous_consent.subject_identifier_aka = obj.subject_identifier_aka - previous_consent.save( - update_fields=["subject_identifier_as_pk", "subject_identifier_aka"] - ) - def get_previous_consent( - self, subject_identifier: str, version: str = None - ) -> ConsentLikeModel | None: - """Returns the previous consent or raises if it does - not exist or is out of sequence with the current. - """ - if version in self.updates_versions: - raise ConsentVersionSequenceError(f"Invalid consent version. Got {version}.") - opts = dict( - subject_identifier=subject_identifier, - model_name=self.model, - version__in=self.updates_versions, + self, subject_identifier: str, exclude_id=None + ) -> ConsentLikeModel: + previous_consent = ( + self.model_cls.objects.filter(subject_identifier=subject_identifier) + .exclude(id=exclude_id) + .order_by("consent_datetime") ) - opts = {k: v for k, v in opts.items() if v is not None} - try: - previous_consent = self.model_cls.objects.get(**opts) - except ObjectDoesNotExist: - if not self.updates_versions: - previous_consent = None - else: - updates_versions = ", ".join(self.updates_versions) - raise ConsentVersionSequenceError( - f"Failed to update previous version. A previous consent " - f"with version in {updates_versions} for {subject_identifier} " - f"was not found. Consent version '{self.version}' is " - f"configured to update a previous version. " - f"See consent definition `{self.name}`." - ) - except MultipleObjectsReturned: - previous_consent = self.model_cls.objects.filter(**opts).order_by("-version")[0] - return previous_consent + if previous_consent.count() > 0: + return previous_consent.last() + else: + raise ObjectDoesNotExist("Previous consent does not exist") diff --git a/edc_consent/exceptions.py b/edc_consent/exceptions.py index 5e643d6..ac27884 100644 --- a/edc_consent/exceptions.py +++ b/edc_consent/exceptions.py @@ -14,6 +14,10 @@ class ConsentDefinitionError(Exception): pass +class ConsentDefinitionModelError(Exception): + pass + + class ConsentDefinitionDoesNotExist(Exception): pass diff --git a/edc_consent/field_mixins/review_fields_mixin.py b/edc_consent/field_mixins/review_fields_mixin.py index 5317424..82945b5 100644 --- a/edc_consent/field_mixins/review_fields_mixin.py +++ b/edc_consent/field_mixins/review_fields_mixin.py @@ -39,7 +39,7 @@ class ReviewFieldsMixin(models.Model): ) consent_signature = models.CharField( - verbose_name=("I have verified that the participant has signed the consent form"), + verbose_name="I have verified that the participant has signed the consent form", max_length=3, choices=YES_NO, validators=[eligible_if_yes], diff --git a/edc_consent/field_mixins/sample_collection_fields_mixin.py b/edc_consent/field_mixins/sample_collection_fields_mixin.py index 84df5a5..3f318e7 100644 --- a/edc_consent/field_mixins/sample_collection_fields_mixin.py +++ b/edc_consent/field_mixins/sample_collection_fields_mixin.py @@ -15,7 +15,7 @@ class SampleCollectionFieldsMixin(models.Model): may_store_samples = models.CharField( verbose_name=( - "Does the participant agree to have samples " "stored after the study has ended" + "Does the participant agree to have samples stored after the study has ended" ), max_length=3, choices=YES_NO, diff --git a/edc_consent/field_mixins/scored_review_fields_mixin.py b/edc_consent/field_mixins/scored_review_fields_mixin.py index e96a79d..bfe8fb0 100644 --- a/edc_consent/field_mixins/scored_review_fields_mixin.py +++ b/edc_consent/field_mixins/scored_review_fields_mixin.py @@ -41,7 +41,7 @@ class ScoredReviewFieldsMixin(models.Model): consent_copy = models.CharField( verbose_name=( - "I have provided the client with a copy of their signed informed" " edc_consent" + "I have provided the client with a copy of their signed informed consent" ), max_length=3, choices=YES_NO_DECLINED, @@ -49,7 +49,7 @@ class ScoredReviewFieldsMixin(models.Model): null=True, blank=False, help_text=( - "If no, INELIGIBLE. If declined, return copy to the " "clinic with the edc_consent" + "If no, INELIGIBLE. If declined, return copy to the clinic with the consent" ), ) diff --git a/edc_consent/fieldsets.py b/edc_consent/fieldsets.py new file mode 100644 index 0000000..74a8e0f --- /dev/null +++ b/edc_consent/fieldsets.py @@ -0,0 +1,10 @@ +from django.utils.translation import gettext as _ + +REQUIRES_CONSENT_FIELDS = ( + "consent_model", + "consent_version", +) +requires_consent_fieldset_tuple = ( + _("Consent"), + {"classes": ("collapse",), "fields": REQUIRES_CONSENT_FIELDS}, +) diff --git a/edc_consent/form_validators/__init__.py b/edc_consent/form_validators/__init__.py index 8a8a953..1e36663 100644 --- a/edc_consent/form_validators/__init__.py +++ b/edc_consent/form_validators/__init__.py @@ -1,3 +1,4 @@ +from .consent_definition_form_validator_mixin import ConsentDefinitionFormValidatorMixin from .subject_consent_form_validator import SubjectConsentFormValidatorMixin -__all__ = ["SubjectConsentFormValidatorMixin"] +__all__ = ["SubjectConsentFormValidatorMixin", "ConsentDefinitionFormValidatorMixin"] diff --git a/edc_consent/form_validators/consent_definition_form_validator_mixin.py b/edc_consent/form_validators/consent_definition_form_validator_mixin.py new file mode 100644 index 0000000..3d6c8a4 --- /dev/null +++ b/edc_consent/form_validators/consent_definition_form_validator_mixin.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime + +from django.utils.translation import gettext as _ +from edc_form_validators import INVALID_ERROR +from edc_sites.site import sites as site_sites + +from edc_consent import ConsentDefinitionDoesNotExist, site_consents +from edc_consent.consent_definition import ConsentDefinition +from edc_consent.exceptions import NotConsentedError, SiteConsentError + + +class ConsentDefinitionFormValidatorMixin: + + def get_consent_datetime_or_raise( + self, report_datetime: datetime = None, fldname: str = None, error_code: str = None + ) -> datetime: + """Returns the consent_datetime of this subject""" + consent_obj = self.get_consent_or_raise( + report_datetime=report_datetime, fldname=fldname, error_code=error_code + ) + return consent_obj.consent_datetime + + def get_consent_or_raise( + self, + report_datetime: datetime = None, + fldname: str | None = None, + error_code: str | None = None, + ) -> datetime: + """Returns the consent_datetime of this subject. + + Wraps func `consent_datetime_or_raise` to re-raise exceptions + as ValidationError. + """ + fldname = fldname or "report_datetime" + error_code = error_code or INVALID_ERROR + consent_definition = self.get_consent_definition( + report_datetime=report_datetime, fldname=fldname, error_code=error_code + ) + # use the consent_definition to get the subject consent model instance + try: + consent_obj = consent_definition.get_consent_for( + subject_identifier=self.subject_identifier, + report_datetime=report_datetime, + ) + except NotConsentedError as e: + self.raise_validation_error({fldname: str(e)}, error_code) + return consent_obj + + def get_consent_definition( + self, report_datetime: datetime = None, fldname: str = None, error_code: str = None + ) -> ConsentDefinition: + # get the consent definition (must be from this schedule) + schedule = getattr(self, "related_visit", self.instance).schedule + site = getattr(self, "related_visit", self.instance).site + try: + consent_definition = schedule.get_consent_definition( + site=site_sites.get(site.id), + report_datetime=report_datetime, + ) + except ConsentDefinitionDoesNotExist as e: + self.raise_validation_error({fldname: str(e)}, error_code) + except SiteConsentError: + possible_consents = "', '".join( + [cdef.display_name for cdef in site_consents.consent_definitions] + ) + self.raise_validation_error( + { + fldname: _( + "Date does not fall within a valid consent period. " + "Possible consents are '%(possible_consents)s'. " + % {"possible_consents": possible_consents} + ) + }, + error_code, + ) + return consent_definition diff --git a/edc_consent/model_mixins/__init__.py b/edc_consent/model_mixins/__init__.py index e347ffb..1ec38c1 100644 --- a/edc_consent/model_mixins/__init__.py +++ b/edc_consent/model_mixins/__init__.py @@ -1,4 +1,3 @@ -from .consent_definition_model_mixin import ConsentDefinitionModelMixin from .consent_model_mixin import ConsentModelMixin from .consent_version_model_mixin import ConsentVersionModelMixin from .requires_consent_fields_model_mixin import RequiresConsentFieldsModelMixin @@ -7,5 +6,4 @@ "ConsentModelMixin", "RequiresConsentFieldsModelMixin", "ConsentVersionModelMixin", - "ConsentDefinitionModelMixin", ] diff --git a/edc_consent/model_mixins/consent_definition_model_mixin.py b/edc_consent/model_mixins/consent_definition_model_mixin.py deleted file mode 100644 index 7ab1768..0000000 --- a/edc_consent/model_mixins/consent_definition_model_mixin.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from django.core.exceptions import ImproperlyConfigured -from django.db import models - -from edc_consent.site_consents import site_consents - -if TYPE_CHECKING: - from edc_consent.consent_definition import ConsentDefinition - - -class ConsentDefinitionModelMixin(models.Model): - consent_definition: ConsentDefinition = None - - def save(self, *args, **kwargs): - self.get_consent_definition() - super().save(*args, **kwargs) - - def get_consent_definition(self) -> ConsentDefinition: - """Verify the consent definition is registered with - site_consent. - """ - if self.consent_definition is None: - raise ImproperlyConfigured( - f"ConsentDefinition is required for screening model. See {self.__class__}." - ) - else: - consent_definition = site_consents.get(self.consent_definition.name) - return consent_definition - - class Meta: - abstract = True diff --git a/edc_consent/model_mixins/consent_model_mixin.py b/edc_consent/model_mixins/consent_model_mixin.py index f53fc8c..179f456 100644 --- a/edc_consent/model_mixins/consent_model_mixin.py +++ b/edc_consent/model_mixins/consent_model_mixin.py @@ -21,6 +21,12 @@ class ConsentModelMixin(ConsentVersionModelMixin, VerificationFieldsMixin, model Declare with edc_identifier's NonUniqueSubjectIdentifierModelMixin """ + screening_identifier = models.CharField(verbose_name="Screening identifier", max_length=50) + + screening_datetime = models.DateTimeField( + verbose_name="Screening datetime", null=True, editable=False + ) + model_name = models.CharField( verbose_name="model", max_length=50, @@ -126,6 +132,7 @@ class Meta: UniqueConstraint( fields=[ "version", + "screening_identifier", "subject_identifier", ], name="%(app_label)s_%(class)s_version_uniq", diff --git a/edc_consent/model_mixins/consent_version_model_mixin.py b/edc_consent/model_mixins/consent_version_model_mixin.py index fbfd478..f09e356 100644 --- a/edc_consent/model_mixins/consent_version_model_mixin.py +++ b/edc_consent/model_mixins/consent_version_model_mixin.py @@ -1,8 +1,9 @@ +from django.core.exceptions import ObjectDoesNotExist from django.db import models, transaction from edc_sites import site_sites from edc_consent import site_consents -from edc_consent.exceptions import SiteConsentError +from edc_consent.exceptions import ConsentDefinitionModelError class ConsentVersionModelMixin(models.Model): @@ -16,44 +17,58 @@ class ConsentVersionModelMixin(models.Model): version = models.CharField( verbose_name="Consent version", max_length=10, - help_text="See 'Consent Type' for consent versions by period.", + help_text="See 'consent definition' for consent versions by period.", editable=False, ) - updates_versions = models.BooleanField(default=False) + update_versions = models.BooleanField(default=False) + + consent_definition_name = models.CharField( + verbose_name="Consent definition", max_length=50, null=True, editable=False + ) def __str__(self): return f"{self.get_subject_identifier()} v{self.version}" def save(self, *args, **kwargs): - consent_definition = self.get_consent_definition() - self.version = consent_definition.version - self.updates_versions = True if consent_definition.updates_versions else False - if self.updates_versions: - with transaction.atomic(): - consent_definition.get_previous_consent( - subject_identifier=self.get_subject_identifier(), - version=self.version, - ) + cdef = self.consent_definition + self.version = cdef.version + self.consent_definition_name = cdef.name + if not self.id and self.subject_identifier: + try: + with transaction.atomic(): + previous_consent = cdef.get_previous_consent( + subject_identifier=self.subject_identifier, exclude_id=self.id + ) + except ObjectDoesNotExist: + pass + else: + self.first_name = previous_consent.first_name + self.dob = previous_consent.dob + self.initials = previous_consent.initials + self.identity = previous_consent.identity + self.confirm_identity = previous_consent.confirm_identity super().save(*args, **kwargs) - def get_consent_definition(self): + @property + def consent_definition(self): """Allow the consent to save as long as there is a consent definition for this report_date and site. """ site = self.site if not self.id and not site: site = site_sites.get_current_site_obj() - consent_definition = site_consents.get_consent_definition( - model=self._meta.label_lower, + cdef = site_consents.get_consent_definition( report_datetime=self.consent_datetime, site=site_sites.get(site.id), ) - if consent_definition.model != self._meta.label_lower: - raise SiteConsentError( - f"No consent definitions exist for this consent model. Got {self}." + if self._meta.label_lower not in [cdef.model, cdef.update_model]: + raise ConsentDefinitionModelError( + f"Incorrect model for consent_definition. This model cannot be used " + f"to create or update consent version '{cdef.version}'. Expected " + f"'{cdef.model}' or '{cdef.update_model}'. Got '{self._meta.label_lower}'." ) - return consent_definition + return cdef class Meta: abstract = True diff --git a/edc_consent/model_mixins/requires_consent_fields_model_mixin.py b/edc_consent/model_mixins/requires_consent_fields_model_mixin.py index 23f829e..d691631 100644 --- a/edc_consent/model_mixins/requires_consent_fields_model_mixin.py +++ b/edc_consent/model_mixins/requires_consent_fields_model_mixin.py @@ -4,9 +4,9 @@ class RequiresConsentFieldsModelMixin(models.Model): """See pre-save signal that checks if subject is consented""" - consent_model = models.CharField(max_length=50, null=True, editable=False) + consent_model = models.CharField(max_length=50, null=True, blank=True) - consent_version = models.CharField(max_length=10, null=True, editable=False) + consent_version = models.CharField(max_length=10, null=True, blank=True) class Meta: abstract = True diff --git a/edc_consent/modeladmin_mixins/__init__.py b/edc_consent/modeladmin_mixins/__init__.py index 48ec8f1..080107b 100644 --- a/edc_consent/modeladmin_mixins/__init__.py +++ b/edc_consent/modeladmin_mixins/__init__.py @@ -1,3 +1,8 @@ from .consent_model_admin_mixin import ConsentModelAdminMixin, ModelAdminConsentMixin +from .requires_consent_model_admin_mixin import RequiresConsentModelAdminMixin -__all__ = ["ConsentModelAdminMixin", "ModelAdminConsentMixin"] +__all__ = [ + "ConsentModelAdminMixin", + "ModelAdminConsentMixin", + "RequiresConsentModelAdminMixin", +] diff --git a/edc_consent/modeladmin_mixins/requires_consent_model_admin_mixin.py b/edc_consent/modeladmin_mixins/requires_consent_model_admin_mixin.py new file mode 100644 index 0000000..a5ebf01 --- /dev/null +++ b/edc_consent/modeladmin_mixins/requires_consent_model_admin_mixin.py @@ -0,0 +1,19 @@ +from django_audit_fields import audit_fieldset_tuple + +from ..fieldsets import REQUIRES_CONSENT_FIELDS, requires_consent_fieldset_tuple + + +class RequiresConsentModelAdminMixin: + + def get_fieldsets(self, request, obj=None): + fieldsets = super().get_fieldsets(request, obj=obj) + fieldsets = list(fieldsets) + for index, fieldset in enumerate(fieldsets): + if fieldset == audit_fieldset_tuple: + fieldsets.insert(index, requires_consent_fieldset_tuple) + break + return tuple(fieldsets) + + def get_readonly_fields(self, request, obj=None) -> tuple[str, ...]: + readonly_fields = super().get_readonly_fields(request, obj=obj) + return tuple(set(readonly_fields + REQUIRES_CONSENT_FIELDS)) diff --git a/edc_consent/modelform_mixins/consent_modelform_mixin/consent_modelform_validation_mixin.py b/edc_consent/modelform_mixins/consent_modelform_mixin/consent_modelform_validation_mixin.py index e047c9c..1c83caa 100644 --- a/edc_consent/modelform_mixins/consent_modelform_mixin/consent_modelform_validation_mixin.py +++ b/edc_consent/modelform_mixins/consent_modelform_mixin/consent_modelform_validation_mixin.py @@ -9,6 +9,7 @@ from edc_model_form.utils import get_field_or_raise from edc_utils import AgeValueError, age, formatted_age +from ... import site_consents from ...exceptions import ConsentDefinitionValidityPeriodError from ...utils import InvalidInitials, verify_initials_against_full_name @@ -26,18 +27,21 @@ class ConsentModelFormValidationMixin: """ @property - def consent_definition(self) -> ConsentDefinition: + def consent_definition(self) -> ConsentDefinition | None: """Returns a ConsentDefinition instance or raises if consent date not within consent definition validity period. """ - cdef: ConsentDefinition = self.subject_screening.consent_definition + consent_definition = None if self.consent_datetime: + consent_definition = site_consents.get_consent_definition( + model=self._meta.model._meta.label_lower, report_datetime=self.consent_datetime + ) try: - cdef.valid_for_datetime_or_raise(self.consent_datetime) + consent_definition.valid_for_datetime_or_raise(self.consent_datetime) except ConsentDefinitionValidityPeriodError as e: raise forms.ValidationError({"consent_datetime": str(e)}) - return cdef + return consent_definition def get_field_or_raise(self, name: str, msg: str) -> Any: return get_field_or_raise( diff --git a/edc_consent/modelform_mixins/requires_consent_modelform_mixin.py b/edc_consent/modelform_mixins/requires_consent_modelform_mixin.py index b9ef74a..ab9d67d 100644 --- a/edc_consent/modelform_mixins/requires_consent_modelform_mixin.py +++ b/edc_consent/modelform_mixins/requires_consent_modelform_mixin.py @@ -1,20 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from django import forms -from django.core.exceptions import ObjectDoesNotExist from edc_sites import site_sites from edc_utils import floor_secs, formatted_date, formatted_datetime from edc_utils.date import to_local, to_utc -from edc_visit_schedule.site_visit_schedules import site_visit_schedules from .. import NotConsentedError +from ..consent_definition import ConsentDefinition from ..exceptions import ConsentDefinitionDoesNotExist -if TYPE_CHECKING: - from ..model_mixins import ConsentModelMixin - __all__ = ["RequiresConsentModelFormMixin"] @@ -35,53 +29,34 @@ def validate_against_consent(self) -> None: """ if self.report_datetime: try: - model_obj = self.get_consent_or_raise() - except ConsentDefinitionDoesNotExist as e: - raise forms.ValidationError(e) + model_obj = self.consent_definition.get_consent_for( + subject_identifier=self.get_subject_identifier(), + report_datetime=self.report_datetime, + ) except NotConsentedError as e: - raise forms.ValidationError(e) - else: - if floor_secs(to_utc(self.report_datetime)) < floor_secs( - model_obj.consent_datetime - ): - dte_str = formatted_datetime(to_local(model_obj.consent_datetime)) - raise forms.ValidationError( - f"Report datetime cannot be before consent datetime. Got {dte_str}." - ) - if to_utc(self.report_datetime).date() < model_obj.dob: - dte_str = formatted_date(model_obj.dob) - raise forms.ValidationError( - f"Report datetime cannot be before DOB. Got {dte_str}" - ) + raise forms.ValidationError({"__all__": str(e)}) + if floor_secs(to_utc(self.report_datetime)) < floor_secs( + model_obj.consent_datetime + ): + dte_str = formatted_datetime(to_local(model_obj.consent_datetime)) + raise forms.ValidationError( + f"Report datetime cannot be before consent datetime. Got {dte_str}." + ) + if to_utc(self.report_datetime).date() < model_obj.dob: + dte_str = formatted_date(model_obj.dob) + raise forms.ValidationError( + f"Report datetime cannot be before DOB. Got {dte_str}" + ) @property - def consent_model(self) -> str: - return site_visit_schedules.get_consent_model( - visit_schedule_name=self.visit_schedule_name, - schedule_name=self.schedule_name, - site=site_sites.get(self.site.id), - ) - - def get_consent_or_raise(self) -> ConsentModelMixin: - """Return an instance of the consent model""" - if getattr(self, "related_visit", None): - cdef = self.related_visit.schedule.get_consent_definition( - site=site_sites.get(self.site.id), - report_datetime=self.report_datetime, - ) - else: - cdef = self.schedule.get_consent_definition( - site=site_sites.get(self.site.id), - report_datetime=self.report_datetime, - ) + def consent_definition(self) -> ConsentDefinition: + """Returns a consent_definition from the schedule""" + schedule = getattr(self, "related_visit", self).schedule try: - obj = cdef.get_consent_for( - subject_identifier=self.get_subject_identifier(), + cdef = schedule.get_consent_definition( + site=site_sites.get(self.site.id), report_datetime=self.report_datetime, ) - except ObjectDoesNotExist: - raise forms.ValidationError( - f"`{cdef.model_cls._meta.verbose_name}` does not exist " - f"to cover this subject on {formatted_datetime(self.report_datetime)}" - ) - return obj + except ConsentDefinitionDoesNotExist as e: + raise forms.ValidationError(e) + return cdef diff --git a/edc_consent/models/signals.py b/edc_consent/models/signals.py index 7e3c2ad..0fa289a 100644 --- a/edc_consent/models/signals.py +++ b/edc_consent/models/signals.py @@ -1,13 +1,9 @@ -from django.core.exceptions import ObjectDoesNotExist from django.db.models.signals import pre_save from django.dispatch import receiver -from edc_registration.models import RegisteredSubject -from edc_screening.utils import get_subject_screening_model_cls from edc_sites import site_sites -from ..exceptions import NotConsentedError from ..model_mixins import RequiresConsentFieldsModelMixin -from ..utils import get_consent_or_raise +from ..site_consents import site_consents @receiver(pre_save, weak=False, dispatch_uid="requires_consent_on_pre_save") @@ -20,23 +16,6 @@ def requires_consent_on_pre_save(instance, raw, using, update_fields, **kwargs): ): subject_identifier = getattr(instance, "related_visit", instance).subject_identifier site = getattr(instance, "related_visit", instance).site - # is the subject registered? - try: - RegisteredSubject.objects.get( - subject_identifier=subject_identifier, - consent_datetime__lte=instance.report_datetime, - ) - except ObjectDoesNotExist: - raise NotConsentedError( - f"Subject is not registered or was not registered by this date. " - f"Unable to save {instance._meta.label_lower}. " - f"Got {subject_identifier} on " - f"{instance.report_datetime}." - ) - - # get the consent definition valid for this report_datetime. - # Schedule may have more than one consent definition but only one - # is returned try: schedule = getattr(instance, "related_visit", instance).schedule except AttributeError: @@ -46,16 +25,13 @@ def requires_consent_on_pre_save(instance, raw, using, update_fields, **kwargs): site=site_sites.get(site.id), report_datetime=instance.report_datetime ) else: - # this is a model like SubjectLocator which has no visit_schedule - # fields. Assume the cdef from SubjectScreening - subject_screening = get_subject_screening_model_cls().objects.get( - subject_identifier=subject_identifier + # this is a PRN model, like SubjectLocator, with no visit_schedule + consent_definition = site_consents.get_consent_definition( + site=site_sites.get(site.id), report_datetime=instance.report_datetime ) - consent_definition = subject_screening.consent_definition - get_consent_or_raise( - model=instance._meta.label_lower, + consent_definition.get_consent_for( subject_identifier=subject_identifier, report_datetime=instance.report_datetime, - consent_definition=consent_definition, ) instance.consent_version = consent_definition.version + instance.consent_model = consent_definition.model diff --git a/edc_consent/site_consents.py b/edc_consent/site_consents.py index 2fa93b8..e261e2d 100644 --- a/edc_consent/site_consents.py +++ b/edc_consent/site_consents.py @@ -19,9 +19,13 @@ ) if TYPE_CHECKING: + from edc_model.models import BaseUuidModel from edc_sites.single_site import SingleSite from .consent_definition import ConsentDefinition + from .model_mixins import ConsentModelMixin + + class ConsentModel(ConsentModelMixin, BaseUuidModel): ... __all__ = ["site_consents"] @@ -34,16 +38,7 @@ def __init__(self): def register(self, cdef: ConsentDefinition) -> None: if cdef.name in self.registry: - raise AlreadyRegistered(f"Consent definition already registered. Got {cdef}.") - - for version in cdef.updates_versions: - try: - self.get_consent_definition(model=cdef.model, version=version) - except ConsentDefinitionDoesNotExist: - raise ConsentDefinitionError( - f"Consent definition is configured to update a version that has " - f"not been registered. See {cdef.display_name}. Got {version}." - ) + raise AlreadyRegistered(f"Consent definition already registered. Got {cdef.name}.") for registered_cdef in self.registry.values(): if registered_cdef.model == cdef.model: if ( @@ -52,9 +47,23 @@ def register(self, cdef: ConsentDefinition) -> None: ): raise ConsentDefinitionError( f"Consent period overlaps with an already registered consent " - f"definition. See already registered consent {registered_cdef}. " - f"Got {cdef}." + f"definition. See already registered consent {registered_cdef.name}. " + f"Got {cdef.name}." ) + if cdef.update_cdef: + if cdef.update_cdef not in self.registry.values(): + raise ConsentDefinitionError( + f"Updates unregistered consent definition. See {cdef.name}. " + f"Got {cdef.update_cdef.name}" + ) + elif cdef.update_cdef.updated_by and cdef.update_cdef.updated_by != cdef.version: + raise ConsentDefinitionError( + f"Version mismatch with consent definition configured to update another. " + f"'{cdef.name}' is configured to update " + f"'{cdef.update_cdef.name}' but '{cdef.update_cdef.name}' " + f"updated_by='{cdef.update_cdef.version}' not '{cdef.version}'. " + ) + self.registry.update({cdef.name: cdef}) self.loaded = True @@ -66,12 +75,16 @@ def get_registry_display(self): def get(self, name) -> ConsentDefinition: return self.registry.get(name) + def all(self) -> list[ConsentDefinition]: + return sorted(list(self.registry.values())) + def get_consent_definition( self, model: str = None, report_datetime: datetime | None = None, version: str | None = None, site: SingleSite | None = None, + screening_model: str | None = None, **kwargs, ) -> ConsentDefinition: """Returns a single consent definition valid for the given criteria. @@ -83,6 +96,7 @@ def get_consent_definition( report_datetime=report_datetime, version=version, site=site, + screening_model=screening_model, **kwargs, ) if len(cdefs) > 1: @@ -96,6 +110,7 @@ def get_consent_definitions( report_datetime: datetime | None = None, version: str | None = None, site: SingleSite | None = None, + screening_model: str | None = None, **kwargs, ) -> list[ConsentDefinition]: """Return a list of consent definitions valid for the given @@ -121,25 +136,31 @@ def get_consent_definitions( version, cdefs, error_messages ) cdefs = self.filter_cdefs_by_site_or_raise(site, cdefs, error_messages) + + cdefs, error_msg = self._filter_cdefs_by_model_or_raise( + screening_model, cdefs, error_messages, attrname="screening_model" + ) + # apply additional criteria for k, v in kwargs.items(): if v is not None: cdefs = [cdef for cdef in cdefs if getattr(cdef, k) == v] - return cdefs + return sorted(cdefs) @staticmethod def _filter_cdefs_by_model_or_raise( model: str | None, consent_definitions: list[ConsentDefinition], errror_messages: list[str] = None, + attrname: str | None = None, ) -> tuple[list[ConsentDefinition], list[str]]: + attrname = attrname or "model" cdefs = consent_definitions if model: - cdefs = [cdef for cdef in cdefs if model == cdef.model] + cdefs = [cdef for cdef in cdefs if model == getattr(cdef, attrname)] if not cdefs: raise ConsentDefinitionDoesNotExist( - "There are no consent definitions using this model. " - f"Got {model._meta.verbose_name}." + f"There are no consent definitions using this model. Got {model}." ) else: errror_messages.append(f"model={model}") @@ -214,6 +235,9 @@ def filter_cdefs_by_site_or_raise( ) return cdefs + def versions(self): + return [cdef.version for cdef in self.registry.values()] + def autodiscover(self, module_name=None, verbose=True): """Autodiscovers consent classes in the consents.py file of any INSTALLED_APP. diff --git a/edc_consent/templates/edc_consent/bootstrap3/home.html b/edc_consent/templates/edc_consent/bootstrap3/home.html index f5d39c6..73e826c 100644 --- a/edc_consent/templates/edc_consent/bootstrap3/home.html +++ b/edc_consent/templates/edc_consent/bootstrap3/home.html @@ -25,7 +25,7 @@ {{consent.version}} {{consent.start_datetime|date:"Y-M-d"}} {{consent.start_datetime|date:"Y-M-d"}} - {{consent.updates_versions}} + {{consent.update_model}} {% endfor %} @@ -48,4 +48,4 @@ -{% endblock main %} \ No newline at end of file +{% endblock main %} diff --git a/edc_consent/tests/consent_test_utils.py b/edc_consent/tests/consent_test_utils.py index 8cd8cb5..4565dec 100644 --- a/edc_consent/tests/consent_test_utils.py +++ b/edc_consent/tests/consent_test_utils.py @@ -13,7 +13,8 @@ def consent_definition_factory( start: datetime = None, end: datetime = None, gender: list[str] | None = None, - updates_versions: list[str] = None, + updates: tuple[str, str] = None, + updated_by: str | None = None, version: str | None = None, age_min: int | None = None, age_max: int | None = None, @@ -23,13 +24,14 @@ def consent_definition_factory( start=start or ResearchProtocolConfig().study_open_datetime, end=end or ResearchProtocolConfig().study_close_datetime, gender=gender or ["M", "F"], - updates_versions=updates_versions or [], + updates=updates or None, + updated_by=updated_by or None, version=version or "1", age_min=age_min or 16, age_max=age_max or 64, age_is_adult=age_is_adult or 18, ) - model = model or "edc_consent.subjectconsent" + model = model or "consent_app.subjectconsent" consent_definition = ConsentDefinition(model, **options) site_consents.register(consent_definition) return consent_definition @@ -40,13 +42,14 @@ def consent_factory(model=None, **kwargs): start=kwargs.get("start"), end=kwargs.get("end"), gender=kwargs.get("gender", ["M", "F"]), - updates_versions=kwargs.get("updates_versions", []), + updates=kwargs.get("updates", None), + updated_by=kwargs.get("updated_by", None), version=kwargs.get("version", "1"), age_min=kwargs.get("age_min", 16), age_max=kwargs.get("age_max", 64), age_is_adult=kwargs.get("age_is_adult", 18), ) - model = kwargs.get("model", model or "edc_consent.subjectconsent") + model = kwargs.get("model", model or "consent_app.subjectconsent") consent_definition = ConsentDefinition(model, **options) site_consents.register(consent_definition) return consent_definition diff --git a/edc_consent/tests/tests/test_actions.py b/edc_consent/tests/tests/test_actions.py index 1b925a5..a949b3f 100644 --- a/edc_consent/tests/tests/test_actions.py +++ b/edc_consent/tests/tests/test_actions.py @@ -10,11 +10,11 @@ from faker import Faker from model_bakery import baker +from consent_app.models import SubjectConsent from edc_consent.actions import unverify_consent, verify_consent from edc_consent.site_consents import site_consents from ..consent_test_utils import consent_definition_factory -from ..models import SubjectConsent fake = Faker() @@ -42,7 +42,7 @@ def setUp(self): last_name = fake.last_name() initials = first_name[0] + choice(string.ascii_uppercase) + last_name[0] baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", consent_datetime=self.study_open_datetime + relativedelta(days=1), initials=initials.upper(), ) diff --git a/edc_consent/tests/tests/test_consent.py b/edc_consent/tests/tests/test_consent.py index cec25f3..fbb7f47 100644 --- a/edc_consent/tests/tests/test_consent.py +++ b/edc_consent/tests/tests/test_consent.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from unittest import skip from dateutil.relativedelta import relativedelta from django.test import TestCase, override_settings @@ -8,6 +9,8 @@ from edc_visit_schedule.site_visit_schedules import site_visit_schedules from model_bakery import baker +from consent_app.models import CrfOne, SubjectVisit +from consent_app.visit_schedules import get_visit_schedule from edc_consent.exceptions import ( ConsentDefinitionDoesNotExist, ConsentDefinitionError, @@ -18,8 +21,6 @@ from edc_consent.site_consents import site_consents from ..consent_test_utils import consent_definition_factory -from ..models import CrfOne, SubjectVisit -from ..visit_schedules import get_visit_schedule @override_settings( @@ -46,7 +47,7 @@ def test_raises_error_if_no_consent(self): self.assertRaises( SiteConsentError, baker.make_recipe, - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_identifier, consent_datetime=self.study_open_datetime, ) @@ -86,7 +87,7 @@ def test_allows_create_if_consent(self): site_visit_schedules._registry = {} site_visit_schedules.register(get_visit_schedule(consent_definition)) subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=self.subject_identifier, consent_datetime=self.study_open_datetime, dob=self.study_open_datetime - relativedelta(years=25), @@ -115,7 +116,7 @@ def test_cannot_create_consent_without_consent_by_datetime(self): self.assertRaises( ConsentDefinitionDoesNotExist, baker.make_recipe, - "edc_consent.subjectconsent", + "consent_app.subjectconsent", dob=self.study_open_datetime - relativedelta(years=25), consent_datetime=self.study_open_datetime, ) @@ -127,7 +128,7 @@ def test_consent_gets_version(self): version="1.0", ) consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", consent_datetime=self.study_open_datetime, dob=self.study_open_datetime - relativedelta(years=25), ) @@ -140,7 +141,7 @@ def test_model_gets_version(self): version="1.0", ) subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=self.subject_identifier, consent_datetime=self.study_open_datetime, dob=self.study_open_datetime - relativedelta(years=25), @@ -172,7 +173,7 @@ def test_model_consent_version_no_change(self): version="1.2", ) baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=self.subject_identifier, consent_datetime=self.study_open_datetime, dob=self.study_open_datetime - relativedelta(years=25), @@ -212,7 +213,7 @@ def test_model_consent_version_changes_with_report_datetime(self): consent_datetime = self.study_open_datetime + timedelta(days=10) subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=self.subject_identifier, consent_datetime=consent_datetime, dob=self.study_open_datetime - relativedelta(years=25), @@ -242,7 +243,7 @@ def test_model_consent_version_changes_with_report_datetime(self): self.assertEqual(crf_one.consent_version, "1.0") consent_datetime = self.study_open_datetime + timedelta(days=60) subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=self.subject_identifier, consent_datetime=consent_datetime, dob=self.study_open_datetime - relativedelta(years=25), @@ -258,6 +259,94 @@ def test_model_consent_version_changes_with_report_datetime(self): # Mcrf_one.save() self.assertEqual(crf_one.consent_version, "1.1") + def test_consent_periods_cannot_overlap(self): + consent_definition_factory( + start=self.study_open_datetime, + end=self.study_open_datetime + timedelta(days=50), + version="1.0", + ) + self.assertRaises( + ConsentDefinitionError, + consent_definition_factory, + start=self.study_open_datetime + timedelta(days=25), + end=self.study_open_datetime + timedelta(days=100), + version="1.1", + ) + + def test_consent_periods_cannot_overlap2(self): + consent_definition_factory( + model="consent_app.subjectconsent", + start=self.study_open_datetime, + end=self.study_open_datetime + timedelta(days=50), + version="1.0", + ) + self.assertRaises( + ConsentDefinitionError, + consent_definition_factory, + model="consent_app.subjectconsent", + start=self.study_open_datetime, + end=self.study_open_datetime + timedelta(days=50), + version="1.1", + ) + + def test_consent_periods_can_overlap_if_different_model(self): + consent_definition_factory( + model="consent_app.subjectconsent", + start=self.study_open_datetime, + end=self.study_open_datetime + timedelta(days=50), + version="1.0", + ) + try: + consent_definition_factory( + model="consent_app.subjectconsent2", + start=self.study_open_datetime, + end=self.study_open_datetime + timedelta(days=50), + version="1.0", + ) + except ConsentDefinitionError: + self.fail("ConsentPeriodOverlapError unexpectedly raised") + + def test_consent_before_open(self): + """Asserts cannot register a consent with a start date + before the study open date. + """ + self.assertRaises( + ConsentDefinitionError, + consent_definition_factory, + start=self.study_open_datetime - relativedelta(days=1), + end=self.study_close_datetime + relativedelta(days=1), + version="1.0", + ) + + def test_consent_definition_naive_datetime_start(self): + """Asserts cannot register a consent with a start date + before the study open date. + """ + d = self.study_open_datetime + dte = datetime(d.year, d.month, d.day, 0, 0, 0, 0) + self.assertRaises( + ConsentDefinitionError, + consent_definition_factory, + start=dte, + end=self.study_close_datetime + relativedelta(days=1), + version="1.0", + ) + + def test_consent_definition_naive_datetime_end(self): + """Asserts cannot register a consent with a start date + before the study open date. + """ + d = self.study_close_datetime + dte = datetime(d.year, d.month, d.day, 0, 0, 0, 0) + self.assertRaises( + ConsentDefinitionError, + consent_definition_factory, + start=self.study_open_datetime, + end=dte, + version="1.0", + ) + + @skip def test_consent_update_needs_previous_version(self): """Asserts that a consent type updates a previous consent.""" consent_definition_factory( @@ -272,16 +361,17 @@ def test_consent_update_needs_previous_version(self): start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), version="1.1", - updates_versions=["1.2"], + update_versions=["1.2"], ) # specify updates version that exists, ok consent_definition_factory( start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), version="1.1", - updates_versions=["1.0"], + update_versions=["1.0"], ) + @skip def test_consent_model_needs_previous_version(self): """Asserts that a consent updates a previous consent but cannot be entered without an existing instance for the previous @@ -295,16 +385,17 @@ def test_consent_model_needs_previous_version(self): start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), version="1.1", - updates_versions=["1.0"], + update_versions=["1.0"], ) self.assertRaises( ConsentVersionSequenceError, baker.make_recipe, - "edc_consent.subjectconsent", + "consent_app.subjectconsent", dob=self.study_open_datetime - relativedelta(years=25), consent_datetime=self.study_open_datetime + timedelta(days=60), ) + @skip def test_consent_needs_previous_version2(self): """Asserts that a consent model updates its previous consent.""" consent_definition_factory( @@ -316,16 +407,16 @@ def test_consent_needs_previous_version2(self): start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), version="1.1", - updates_versions=["1.0"], + update_versions=["1.0"], ) subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", consent_datetime=self.study_open_datetime + timedelta(days=5), dob=self.study_open_datetime - relativedelta(years=25), ) self.assertEqual(subject_consent.version, "1.0") subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_consent.subject_identifier, consent_datetime=self.study_open_datetime + timedelta(days=60), first_name=subject_consent.first_name, @@ -337,6 +428,7 @@ def test_consent_needs_previous_version2(self): ) self.assertEqual(subject_consent.version, "1.1") + @skip def test_consent_needs_previous_version3(self): """Asserts that a consent updates a previous consent raises if a version is skipped. @@ -350,16 +442,16 @@ def test_consent_needs_previous_version3(self): start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), version="1.1", - updates_versions=["1.0"], + update_versions=["1.0"], ) consent_definition_factory( start=self.study_open_datetime + timedelta(days=101), end=self.study_open_datetime + timedelta(days=150), version="1.2", - updates_versions=["1.1"], + update_versions=["1.1"], ) subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", consent_datetime=self.study_open_datetime, dob=self.study_open_datetime - relativedelta(years=25), ) @@ -368,7 +460,7 @@ def test_consent_needs_previous_version3(self): self.assertRaises( ConsentVersionSequenceError, baker.make_recipe, - "edc_consent.subjectconsent", + "consent_app.subjectconsent", consent_datetime=self.study_open_datetime + timedelta(days=125), subject_identifier=subject_consent.subject_identifier, first_name=subject_consent.first_name, @@ -379,66 +471,7 @@ def test_consent_needs_previous_version3(self): dob=subject_consent.dob, ) - def test_consent_periods_cannot_overlap(self): - consent_definition_factory( - start=self.study_open_datetime, - end=self.study_open_datetime + timedelta(days=50), - version="1.0", - ) - self.assertRaises( - ConsentDefinitionError, - consent_definition_factory, - start=self.study_open_datetime + timedelta(days=25), - end=self.study_open_datetime + timedelta(days=100), - version="1.1", - updates_versions=["1.0"], - ) - - def test_consent_periods_cannot_overlap2(self): - consent_definition_factory( - model="edc_consent.subjectconsent", - start=self.study_open_datetime, - end=self.study_open_datetime + timedelta(days=50), - version="1.0", - ) - self.assertRaises( - ConsentDefinitionError, - consent_definition_factory, - model="edc_consent.subjectconsent", - start=self.study_open_datetime, - end=self.study_open_datetime + timedelta(days=50), - version="1.1", - ) - - def test_consent_periods_can_overlap_if_different_model(self): - consent_definition_factory( - model="edc_consent.subjectconsent", - start=self.study_open_datetime, - end=self.study_open_datetime + timedelta(days=50), - version="1.0", - ) - try: - consent_definition_factory( - model="edc_consent.subjectconsent2", - start=self.study_open_datetime, - end=self.study_open_datetime + timedelta(days=50), - version="1.0", - ) - except ConsentDefinitionError: - self.fail("ConsentPeriodOverlapError unexpectedly raised") - - def test_consent_before_open(self): - """Asserts cannot register a consent with a start date - before the study open date. - """ - self.assertRaises( - ConsentDefinitionError, - consent_definition_factory, - start=self.study_open_datetime - relativedelta(days=1), - end=self.study_close_datetime + relativedelta(days=1), - version="1.0", - ) - + @skip def test_consent_may_update_more_than_one_version(self): consent_definition_factory( start=self.study_open_datetime, @@ -454,33 +487,5 @@ def test_consent_may_update_more_than_one_version(self): start=self.study_open_datetime + timedelta(days=101), end=self.study_open_datetime + timedelta(days=150), version="3.0", - updates_versions=["1.0", "2.0"], - ) - - def test_consent_definition_naive_datetime_start(self): - """Asserts cannot register a consent with a start date - before the study open date. - """ - d = self.study_open_datetime - dte = datetime(d.year, d.month, d.day, 0, 0, 0, 0) - self.assertRaises( - ConsentDefinitionError, - consent_definition_factory, - start=dte, - end=self.study_close_datetime + relativedelta(days=1), - version="1.0", - ) - - def test_consent_definition_naive_datetime_end(self): - """Asserts cannot register a consent with a start date - before the study open date. - """ - d = self.study_close_datetime - dte = datetime(d.year, d.month, d.day, 0, 0, 0, 0) - self.assertRaises( - ConsentDefinitionError, - consent_definition_factory, - start=self.study_open_datetime, - end=dte, - version="1.0", + update_versions=["1.0", "2.0"], ) diff --git a/edc_consent/tests/tests/test_consent_definition.py b/edc_consent/tests/tests/test_consent_definition.py index af7cc97..8b6998f 100644 --- a/edc_consent/tests/tests/test_consent_definition.py +++ b/edc_consent/tests/tests/test_consent_definition.py @@ -3,11 +3,10 @@ from edc_protocol.research_protocol_config import ResearchProtocolConfig from edc_utils import get_utcnow +from edc_consent.consent_definition import ConsentDefinition from edc_consent.exceptions import SiteConsentError from edc_consent.site_consents import site_consents -from ...consent_definition import ConsentDefinition - @override_settings( EDC_PROTOCOL_STUDY_OPEN_DATETIME=get_utcnow() - relativedelta(years=5), @@ -26,7 +25,6 @@ def default_options(self, **kwargs): start=self.study_open_datetime, end=self.study_close_datetime, gender=["M", "F"], - updates_versions=[], version="1", age_min=16, age_max=64, @@ -36,22 +34,22 @@ def default_options(self, **kwargs): return options def test_ok(self): - ConsentDefinition("edc_consent.subjectconsent", **self.default_options()) + ConsentDefinition("consent_app.subjectconsent", **self.default_options()) def test_cdef_name(self): - cdef1 = ConsentDefinition("edc_consent.subjectconsent", **self.default_options()) - self.assertEqual(cdef1.name, "edc_consent.subjectconsent-1") + cdef1 = ConsentDefinition("consent_app.subjectconsent", **self.default_options()) + self.assertEqual(cdef1.name, "consent_app.subjectconsent-1") site_consents.register(cdef1) - site_consents.get_consent_definition("edc_consent.subjectconsent") - site_consents.get_consent_definition(model="edc_consent.subjectconsent") + site_consents.get_consent_definition("consent_app.subjectconsent") + site_consents.get_consent_definition(model="consent_app.subjectconsent") site_consents.get_consent_definition(version="1") # add country site_consents.registry = {} cdef1 = ConsentDefinition( - "edc_consent.subjectconsentug", **self.default_options(country="uganda") + "consent_app.subjectconsentug", **self.default_options(country="uganda") ) - self.assertEqual(cdef1.name, "edc_consent.subjectconsentug-1") + self.assertEqual(cdef1.name, "consent_app.subjectconsentug-1") site_consents.register(cdef1) cdef2 = site_consents.get_consent_definition(country="uganda") self.assertEqual(cdef1, cdef2) @@ -59,7 +57,7 @@ def test_cdef_name(self): def test_with_country(self): site_consents.registry = {} cdef1 = ConsentDefinition( - "edc_consent.subjectconsent", country="uganda", **self.default_options() + "consent_app.subjectconsent", country="uganda", **self.default_options() ) site_consents.register(cdef1) cdef2 = site_consents.get_consent_definition(country="uganda") @@ -68,10 +66,24 @@ def test_with_country(self): def test_with_country_raises_on_potential_duplicate(self): site_consents.registry = {} cdef1 = ConsentDefinition( - "edc_consent.subjectconsent", country="uganda", **self.default_options() + "consent_app.subjectconsent", country="uganda", **self.default_options() + ) + cdef2 = ConsentDefinition( + "consent_app.subjectconsentug", country="uganda", **self.default_options() + ) + site_consents.register(cdef1) + site_consents.register(cdef2) + self.assertRaises( + SiteConsentError, site_consents.get_consent_definition, country="uganda" + ) + + def test_duplicate_version(self): + site_consents.registry = {} + cdef1 = ConsentDefinition( + "consent_app.subjectconsent", country="uganda", **self.default_options() ) cdef2 = ConsentDefinition( - "edc_consent.subjectconsentug", country="uganda", **self.default_options() + "consent_app.subjectconsentug", country="uganda", **self.default_options() ) site_consents.register(cdef1) site_consents.register(cdef2) diff --git a/edc_consent/tests/tests/test_consent_form.py b/edc_consent/tests/tests/test_consent_form.py index fc1c813..0fc0daf 100644 --- a/edc_consent/tests/tests/test_consent_form.py +++ b/edc_consent/tests/tests/test_consent_form.py @@ -14,13 +14,12 @@ from faker import Faker from model_bakery import baker +from consent_app.models import SubjectConsent, SubjectScreening from edc_consent.consent_definition import ConsentDefinition from edc_consent.form_validators import SubjectConsentFormValidatorMixin from edc_consent.modelform_mixins import ConsentModelFormMixin from edc_consent.site_consents import site_consents -from ..models import SubjectConsent, SubjectScreening - fake = Faker() @@ -58,20 +57,22 @@ def setUp(self): self.convent_v1 = self.consent_factory( start=self.study_open_datetime, end=self.study_open_datetime + timedelta(days=50), + model="consent_app.subjectconsent", version="1.0", ) - SubjectScreening.consent_definition = self.convent_v1 self.convent_v2 = self.consent_factory( start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), + model="consent_app.subjectconsentv2", version="2.0", ) self.convent_v3 = self.consent_factory( start=self.study_open_datetime + timedelta(days=101), end=self.study_open_datetime + timedelta(days=150), version="3.0", - updates_versions=["1.0", "2.0"], + model="consent_app.subjectconsentv3", + updates=(self.convent_v2, "consent_app.subjectconsentupdatetov3"), ) self.dob = self.study_open_datetime - relativedelta(years=25) @@ -81,13 +82,13 @@ def consent_factory(**kwargs): start=kwargs.get("start"), end=kwargs.get("end"), gender=kwargs.get("gender", ["M", "F"]), - updates_versions=kwargs.get("updates_versions", []), + updates=kwargs.get("updates", None), version=kwargs.get("version", "1"), age_min=kwargs.get("age_min", 16), age_max=kwargs.get("age_max", 64), age_is_adult=kwargs.get("age_is_adult", 18), ) - model = kwargs.get("model", "edc_consent.subjectconsent") + model = kwargs.get("model", "consent_app.subjectconsent") consent_definition = ConsentDefinition(model, **options) site_consents.register(consent_definition) return consent_definition @@ -158,7 +159,7 @@ def prepare_subject_consent( eligibility_datetime=consent_datetime, ) subject_consent = baker.prepare_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", dob=dob, consent_datetime=consent_datetime, first_name=first_name or "XXXXXX", diff --git a/edc_consent/tests/tests/test_consent_model.py b/edc_consent/tests/tests/test_consent_model.py index 9bbc98b..52028dd 100644 --- a/edc_consent/tests/tests/test_consent_model.py +++ b/edc_consent/tests/tests/test_consent_model.py @@ -1,22 +1,27 @@ -from datetime import timedelta +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import time_machine from dateutil.relativedelta import relativedelta from django.contrib.sites.models import Site -from django.test import TestCase, override_settings +from django.test import TestCase, override_settings, tag from edc_protocol.research_protocol_config import ResearchProtocolConfig +from edc_sites.site import sites as site_sites from edc_utils import get_utcnow from faker import Faker from model_bakery import baker +from consent_app.models import SubjectConsent from edc_consent.field_mixins import IdentityFieldsMixinError from edc_consent.site_consents import site_consents +from ...exceptions import ConsentDefinitionDoesNotExist, ConsentDefinitionModelError from ..consent_test_utils import consent_factory -from ..models import SubjectConsent fake = Faker() +@time_machine.travel(datetime(2019, 4, 1, 8, 00, tzinfo=ZoneInfo("UTC"))) @override_settings( EDC_PROTOCOL_STUDY_OPEN_DATETIME=get_utcnow() - relativedelta(years=5), EDC_PROTOCOL_STUDY_CLOSE_DATETIME=get_utcnow() + relativedelta(years=1), @@ -28,30 +33,32 @@ def setUp(self): self.study_open_datetime = ResearchProtocolConfig().study_open_datetime self.study_close_datetime = ResearchProtocolConfig().study_close_datetime site_consents.registry = {} - consent_factory( + self.consent_v1 = consent_factory( start=self.study_open_datetime, end=self.study_open_datetime + timedelta(days=50), version="1.0", ) - consent_factory( + self.consent_v2 = consent_factory( start=self.study_open_datetime + timedelta(days=51), end=self.study_open_datetime + timedelta(days=100), version="2.0", + updated_by="3.0", ) - consent_factory( + self.consent_v3 = consent_factory( + model="consent_app.subjectconsentv3", start=self.study_open_datetime + timedelta(days=101), end=self.study_open_datetime + timedelta(days=150), version="3.0", - updates_versions=["1.0", "2.0"], + updates=(self.consent_v2, "consent_app.subjectconsentupdatev3"), ) self.dob = self.study_open_datetime - relativedelta(years=25) def test_encryption(self): subject_consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", first_name="ERIK", consent_datetime=self.study_open_datetime, - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) self.assertEqual(subject_consent.first_name, "ERIK") @@ -60,10 +67,10 @@ def test_gets_subject_identifier(self): subject_identifier_as_pk. """ consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=None, consent_datetime=self.study_open_datetime, - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), site=Site.objects.get_current(), ) self.assertIsNotNone(consent.subject_identifier) @@ -76,15 +83,15 @@ def test_subject_has_current_consent(self): subject_identifier = "123456789" identity = "987654321" baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime + timedelta(days=1), - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) cdef = site_consents.get_consent_definition( - model="edc_consent.subjectconsent", version="1.0" + model="consent_app.subjectconsent", version="1.0" ) subject_consent = cdef.get_consent_for( subject_identifier="123456789", @@ -92,15 +99,15 @@ def test_subject_has_current_consent(self): ) self.assertEqual(subject_consent.version, "1.0") baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime + timedelta(days=60), - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) cdef = site_consents.get_consent_definition( - model="edc_consent.subjectconsent", version="2.0" + model="consent_app.subjectconsent", version="2.0" ) subject_consent = cdef.get_consent_for( subject_identifier="123456789", @@ -112,30 +119,30 @@ def test_model_updates(self): subject_identifier = "123456789" identity = "987654321" consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime, - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) self.assertEqual(consent.version, "1.0") consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime + timedelta(days=51), - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) self.assertEqual(consent.version, "2.0") consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsentv3", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime + timedelta(days=101), - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) self.assertEqual(consent.version, "3.0") @@ -143,31 +150,227 @@ def test_model_updates2(self): subject_identifier = "123456789" identity = "987654321" consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime, - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) self.assertEqual(consent.version, "1.0") consent = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsentv3", subject_identifier=subject_identifier, identity=identity, confirm_identity=identity, consent_datetime=self.study_open_datetime + timedelta(days=101), - dob=get_utcnow() + relativedelta(years=25), + dob=get_utcnow() - relativedelta(years=25), ) self.assertEqual(consent.version, "3.0") + def test_model_updates_or_first_based_on_date(self): + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=110)) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + consent = baker.make_recipe( + "consent_app.subjectconsentv3", + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(consent.version, "3.0") + + def test_model_updates_from_v1_to_v2(self): + traveller = time_machine.travel(self.study_open_datetime) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.subject_identifier, subject_identifier) + self.assertEqual(subject_consent.identity, identity) + self.assertEqual(subject_consent.confirm_identity, identity) + self.assertEqual(subject_consent.version, cdef.version) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + traveller.stop() + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=51)) + traveller.start() + + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.subject_identifier, subject_identifier) + self.assertEqual(subject_consent.identity, identity) + self.assertEqual(subject_consent.confirm_identity, identity) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + + @tag("1") + def test_v3_extends_v2_end_date_up_to_v3_consent_datetime(self): + traveller = time_machine.travel(self.study_open_datetime) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + + # consent version 1 + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + self.assertEqual(subject_consent.version, "1.0") + traveller.stop() + + # consent version 2 + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=51)) + traveller.start() + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + self.assertEqual(subject_consent.version, "2.0") + traveller.stop() + + # consent version 3.0 + traveller = time_machine.travel(cdef.end + relativedelta(days=5)) + traveller.start() + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + self.assertEqual(subject_consent.version, "3.0") + self.assertEqual(cdef.version, "3.0") + + # get cdef for 3.0 + cdef = site_consents.get_consent_definition( + report_datetime=get_utcnow(), site=site_sites.get(subject_consent.site.id) + ) + self.assertEqual(cdef.version, "3.0") + + # use cdef-3.0 to get subject_consent 3.0 + subject_consent = cdef.get_consent_for( + subject_identifier=subject_identifier, report_datetime=get_utcnow() + ) + self.assertEqual(subject_consent.version, "3.0") + + # use cdef-3.0 to get subject_consent 2.0 showing that the lower bound + # of a cdef that updates is extended to return a 2.0 consent + subject_consent = cdef.get_consent_for( + subject_identifier=subject_identifier, + report_datetime=cdef.start - relativedelta(days=1), + ) + self.assertEqual(subject_consent.version, "2.0") + + def test_first_consent_is_v2(self): + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=51)) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + self.assertEqual(cdef.version, "2.0") + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.subject_identifier, subject_identifier) + self.assertEqual(subject_consent.identity, identity) + self.assertEqual(subject_consent.confirm_identity, identity) + self.assertEqual(subject_consent.version, cdef.version) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + + def test_first_consent_is_v3(self): + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=101)) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + + cdef = site_consents.get_consent_definition(report_datetime=get_utcnow()) + self.assertEqual(cdef.version, "3.0") + subject_consent = baker.make_recipe( + cdef.model, + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + self.assertEqual(subject_consent.subject_identifier, subject_identifier) + self.assertEqual(subject_consent.identity, identity) + self.assertEqual(subject_consent.confirm_identity, identity) + self.assertEqual(subject_consent.version, cdef.version) + self.assertEqual(subject_consent.consent_definition_name, cdef.name) + + def test_raise_with_date_past_any_consent_period(self): + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=200)) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + self.assertRaises( + ConsentDefinitionDoesNotExist, + baker.make_recipe, + "consent_app.subjectconsent", + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + + def test_raise_with_incorrect_model_for_cdef(self): + traveller = time_machine.travel(self.study_open_datetime + timedelta(days=120)) + traveller.start() + subject_identifier = "123456789" + identity = "987654321" + self.assertRaises( + ConsentDefinitionModelError, + baker.make_recipe, + "consent_app.subjectconsent", + subject_identifier=subject_identifier, + identity=identity, + confirm_identity=identity, + consent_datetime=get_utcnow(), + dob=get_utcnow() - relativedelta(years=25), + ) + def test_manager(self): for i in range(1, 3): first_name = fake.first_name() last_name = fake.last_name() initials = f"{first_name[0]}{last_name[0]}" baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier=str(i), consent_datetime=self.study_open_datetime + relativedelta(days=i), initials=initials, @@ -180,20 +383,23 @@ def test_manager(self): def test_model_str_repr_etc(self): obj = baker.make_recipe( - "edc_consent.subjectconsent", + "consent_app.subjectconsent", + screening_identifier="ABCDEF", subject_identifier="12345", consent_datetime=self.study_open_datetime + relativedelta(days=1), ) + self.assertTrue(str(obj)) self.assertTrue(repr(obj)) self.assertTrue(obj.age_at_consent) self.assertTrue(obj.formatted_age_at_consent) self.assertEqual(obj.report_datetime, obj.consent_datetime) + def test_checks_identity_fields_match_or_raises(self): self.assertRaises( IdentityFieldsMixinError, baker.make_recipe, - "edc_consent.subjectconsent", + "consent_app.subjectconsent", subject_identifier="12345", consent_datetime=self.study_open_datetime + relativedelta(days=1), identity="123456789", diff --git a/edc_consent/tests/tests/test_consent_update.py b/edc_consent/tests/tests/test_consent_update.py new file mode 100644 index 0000000..e69de29 diff --git a/edc_consent/utils.py b/edc_consent/utils.py index ece53de..02908bb 100644 --- a/edc_consent/utils.py +++ b/edc_consent/utils.py @@ -1,28 +1,18 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from django import forms from django.apps import apps as django_apps from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist from django.db import models -from django.utils.translation import gettext as _ -from edc_appointment.constants import INVALID_APPT_DATE from edc_sites import site_sites -from edc_utils import formatted_datetime -from edc_utils.date import to_local - -from .exceptions import ( - ConsentDefinitionDoesNotExist, - NotConsentedError, - SiteConsentError, -) + +from .exceptions import ConsentDefinitionDoesNotExist from .site_consents import site_consents if TYPE_CHECKING: - from edc_appointment.models import Appointment from edc_model.models import BaseUuidModel from edc_consent.consent_definition import ConsentDefinition @@ -112,71 +102,3 @@ def values_as_string(*values) -> str | None: def get_remove_patient_names_from_countries() -> list[str]: """Returns a list of country names.""" return getattr(settings, "EDC_CONSENT_REMOVE_PATIENT_NAMES_FROM_COUNTRIES", []) - - -def consent_datetime_or_raise( - report_datetime: datetime = None, - appointment: Appointment = None, - raise_validation_error: Callable = None, -) -> datetime: - try: - consent_definition = appointment.schedule.get_consent_definition( - site=site_sites.get(appointment.site.id), - report_datetime=report_datetime, - ) - except SiteConsentError: - if raise_validation_error: - possible_consents = "', '".join( - [cdef.display_name for cdef in site_consents.consent_definitions] - ) - raise_validation_error( - { - "appt_datetime": _( - "Date does not fall within a valid consent period. " - "Possible consents are '%(possible_consents)s'. " - % {"possible_consents": possible_consents} - ) - }, - INVALID_APPT_DATE, - ) - else: - raise - except NotConsentedError as e: - if raise_validation_error: - raise_validation_error( - {"appt_datetime": str(e)}, - INVALID_APPT_DATE, - ) - else: - raise - - consent_obj = get_consent_or_raise( - model=appointment._meta.label_lower, - subject_identifier=appointment.subject_identifier, - report_datetime=report_datetime, - consent_definition=consent_definition, - ) - return consent_obj.consent_datetime - - -def get_consent_or_raise( - model: str, - subject_identifier: str, - report_datetime: datetime, - consent_definition: ConsentDefinition, -) -> ConsentModel: - try: - instance = consent_definition.model_cls.objects.get( - subject_identifier=subject_identifier, - consent_datetime__lte=report_datetime, - version=consent_definition.version, - ) - except ObjectDoesNotExist: - date_string = formatted_datetime(to_local(report_datetime)) - raise NotConsentedError( - f"Consent is required. Could not find a valid consent when saving model " - f"'{model}' for subject '{subject_identifier}' using " - f"date '{date_string}'. On which date was the subject consented? " - f"See consent definition `{consent_definition.display_name}`." - ) - return instance diff --git a/edc_consent/view_mixins/consent_view_mixins.py b/edc_consent/view_mixins/consent_view_mixins.py index edfb20a..7464790 100644 --- a/edc_consent/view_mixins/consent_view_mixins.py +++ b/edc_consent/view_mixins/consent_view_mixins.py @@ -6,7 +6,8 @@ from django.contrib.messages import ERROR from edc_sites import site_sites -from ..exceptions import ConsentDefinitionDoesNotExist +from .. import site_consents +from ..exceptions import ConsentDefinitionDoesNotExist, NotConsentedError if TYPE_CHECKING: from django.db.models import QuerySet @@ -44,10 +45,13 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: def consents(self) -> QuerySet[ConsentLikeModel]: """Returns a Queryset of consents for this subject.""" if not self._consents: - self._consents = self.consent_definition.model_cls.objects.filter( - subject_identifier=self.subject_identifier, - site_id__in=site_sites.get_site_ids_for_user(request=self.request), - ).order_by("version") + self._consents = [] + for cdef in site_consents.get_consent_definitions(): + if obj := cdef.get_consent_for( + subject_identifier=self.subject_identifier, + raise_if_not_consented=False, + ): + self._consents.append(obj) return self._consents @property @@ -56,10 +60,13 @@ def consent(self) -> ConsentLikeModel | None: period. """ if not self._consent: - self._consent = self.consent_definition.get_consent_for( - subject_identifier=self.subject_identifier, - report_datetime=self.report_datetime, - ) + try: + self._consent = self.consent_definition.get_consent_for( + subject_identifier=self.subject_identifier, + report_datetime=self.report_datetime, + ) + except NotConsentedError as e: + messages.add_message(self.request, message=str(e), level=ERROR) return self._consent @property diff --git a/runtests.py b/runtests.py index 927df37..d7c842e 100644 --- a/runtests.py +++ b/runtests.py @@ -26,6 +26,12 @@ EDC_PROTOCOL_STUDY_OPEN_DATETIME=get_utcnow() - relativedelta(years=1), EDC_PROTOCOL_STUDY_CLOSE_DATETIME=get_utcnow() + relativedelta(years=1), EDC_SITES_REGISTER_DEFAULT=True, + SUBJECT_SCREENING_MODEL="consent_app.subjectscreening", + SUBJECT_CONSENT_MODEL="consent_app.subjectconsent", + SUBJECT_VISIT_MODEL="consent_app.subjectvisit", + SUBJECT_VISIT_MISSED_MODEL="consent_app.subjectvisitmissed", + SUBJECT_REQUISITION_MODEL="consent_app.subjectrequisition", + SUBJECT_REFUSAL_MODEL="consent_app.subjectrefusal", INSTALLED_APPS=[ "django.contrib.admin", "django.contrib.auth", @@ -58,6 +64,7 @@ "edc_visit_tracking.apps.AppConfig", "edc_consent.apps.AppConfig", "edc_auth.apps.AppConfig", + "consent_app.apps.AppConfig", "edc_appconfig.apps.AppConfig", ], add_dashboard_middleware=True,