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 @@