Skip to content

Commit

Permalink
refactor to handle multiple versions of consents and consents that up…
Browse files Browse the repository at this point in the history
…date previous versions
  • Loading branch information
erikvw committed Mar 1, 2024
1 parent a5337f6 commit 7527f8b
Show file tree
Hide file tree
Showing 36 changed files with 955 additions and 548 deletions.
217 changes: 165 additions & 52 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -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
========
Expand All @@ -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)
Expand All @@ -132,28 +250,23 @@ 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:
* 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
================
Expand Down Expand Up @@ -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.
Expand Down
Empty file added consent_app/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions consent_app/apps.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 27 additions & 1 deletion edc_consent/baker_recipes.py → consent_app/baker_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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(),
)
29 changes: 22 additions & 7 deletions edc_consent/tests/models.py → consent_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 7527f8b

Please sign in to comment.