Skip to content

Commit

Permalink
allow bypass checks if cdef overlaps by model and site, seperate syst…
Browse files Browse the repository at this point in the history
…em checks, filter by version in manager
  • Loading branch information
erikvw committed Mar 7, 2024
1 parent 8f95021 commit 1f68176
Show file tree
Hide file tree
Showing 10 changed files with 219 additions and 98 deletions.
132 changes: 84 additions & 48 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,37 @@ Declare the consent model:
subject_identifier_cls = SubjectIdentifier
subject_screening_model = "effect_screening.subjectscreening"
subject_screening_model = "edc_example.subjectscreening"
objects = SubjectConsentManager()
objects = ConsentObjectsManager()
on_site = CurrentSiteManager()
consent = ConsentManager()
history = HistoricalRecords()
class Meta(ConsentModelMixin.Meta, BaseUuidModel.Meta):
pass
Declare at least one ``ConsentDefinition`` that references your consent model.
class SubjectConsentV1(SubjectConsent):
"""A proxy model completed by the user that captures version 1
of the ICF.
"""
objects = ConsentObjectsByCdefManager()
on_site = CurrentSiteByCdefManager()
history = HistoricalRecords()
class Meta:
proxy = True
verbose_name = "Consent V1"
verbose_name_plural = "Consent V1"
The next step is to declare and register a ``ConsentDefinition``. A consent definition is a class that represents an
approved Informed Consent. It is linked to a proxy of the consent model, for example ``SubjectConsent`` from above,
using the class attribute
``model``. We use a proxy model since over time each subject may need to submit more than one
version of the consent. Each version of a subject's consent is represented by an instance of the Each version is paird with a proxy model. The approved Informed Consent
also includes a validity period (start=datetime1 to end=datetime2) and a version number
(version=1). There are other attributes of a ``ConsentDefinition`` to consider but lets focus
on the ``start`` date, ``end`` date, ``version`` and ``model`` for now.

``ConsentDefinitions`` are declared in the root of your app in module ``consents.py``. A typical declaration looks something like this:

Expand All @@ -54,7 +74,7 @@ Declare at least one ``ConsentDefinition`` that references your consent model.
from edc_constants.constants import MALE, FEMALE
consent_v1 = ConsentDefinition(
'edc_example.subjectconsent',
'edc_example.subjectconsentv1',
version='1',
start=datetime(2013, 10, 15, tzinfo=ZoneInfo("UTC")),
end=datetime(2016, 10, 15, 23, 59, 999999, tzinfo=ZoneInfo("UTC")),
Expand All @@ -78,19 +98,55 @@ 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``).
To create an instance of the consent for a subject, find the ``ConsentDefinitions`` and use
``model_cls``.


.. code-block:: python
cdef = site_consents.get_consent_definition(
report_datetime=datetime(2013, 10, 16, tzinfo=ZoneInfo("UTC"))
)
assert cdef.version == "1"
assert cdef.model == "edc_example.subjectconsentv1"
consent_obj = cdef.model_cls.objects.create(
subject_identifier="123456789",
consent_datetime=datetime(2013, 10, 16, tzinfo=ZoneInfo("UTC"),
...)
assert consent_obj.consent_version == "1"
assert consent_obj.consent_model == "edc_example.subjectconsentv1"
Add a second ``ConsentDefinition`` to ``your consents.py`` for version 2:
.. code-block:: python
class SubjectConsentV2(SubjectConsent):
"""A proxy model completed by the user that captures version 2
of the ICF.
"""
objects = ConsentObjectsByCdefManager()
on_site = CurrentSiteByCdefManager()
history = HistoricalRecords()
class Meta:
proxy = True
verbose_name = "Consent V2"
verbose_name_plural = "Consent V2"
.. code-block:: python
consent_v1 = ConsentDefinition(...)
consent_v2 = ConsentDefinition(
'edc_example.subjectconsent',
'edc_example.subjectconsentv2',
version='2',
start=datetime(2016, 10, 16, 0,0,0, tzinfo=ZoneInfo("UTC")),
end=datetime(2020, 10, 15, 23, 59, 999999, tzinfo=ZoneInfo("UTC")),
Expand All @@ -103,8 +159,24 @@ Add a second ``ConsentDefinition`` to ``your consents.py`` for version 2:
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".
.. code-block:: python
cdef = site_consents.get_consent_definition(
report_datetime=datetime(2016, 10, 17, tzinfo=ZoneInfo("UTC"))
)
assert cdef.version == "2"
assert cdef.model == "edc_example.subjectconsentv2"
consent_obj = cdef.model_cls.objects.create(
subject_identifier="123456789",
consent_datetime=datetime(2016, 10, 17, tzinfo=ZoneInfo("UTC"),
...)
assert consent_obj.consent_version == "2"
assert consent_obj.consent_model == "edc_example.subjectconsentv2"
``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.
Expand All @@ -120,42 +192,6 @@ Now resave the instance from above with ``consent_datetime = datetime(2017, 1, 1
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.
Expand Down
1 change: 1 addition & 0 deletions edc_consent/consent_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class ConsentDefinition:
gender: list[str] | None = field(default_factory=list, compare=False)
site_ids: list[int] = field(default_factory=list, compare=False)
country: str | None = field(default=None, compare=False)
validate_duration_overlap_by_model: bool | None = field(default=True, compare=False)
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

class ConsentDefinitionFormValidatorMixin:

@property
def subject_consent(self):
cdef = self.get_consent_definition()
return cdef.model_cls.objects.get(subject_identifier=self.subject_identifier)

def get_consent_datetime_or_raise(
self, report_datetime: datetime = None, fldname: str = None, error_code: str = None
) -> datetime:
Expand Down
12 changes: 6 additions & 6 deletions edc_consent/form_validators/subject_consent_form_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ def _clean(self) -> None:
self.validate_demographics()
super()._clean()

def validate_demographics(self) -> None:
self.validate_consent_datetime()
self.validate_age()
self.validate_gender()
self.validate_identity()

@property
def gender(self):
return self.cleaned_data.get("gender")
Expand All @@ -48,12 +54,6 @@ def consent_model_cls(self):
)
return cdef.model_cls

def validate_demographics(self) -> None:
self.validate_consent_datetime()
self.validate_age()
self.validate_gender()
self.validate_identity()

@property
def consent_datetime(self) -> datetime | None:
if not self._consent_datetime:
Expand Down
41 changes: 28 additions & 13 deletions edc_consent/managers.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from django.db import models
from edc_search.model_mixins import SearchSlugManager
from edc_sites.managers import CurrentSiteManager

if TYPE_CHECKING:
from .stubs import ConsentLikeModel
from .site_consents import site_consents


class ObjectConsentManager(models.Manager):
class ConsentObjectsManager(SearchSlugManager, models.Manager):
def get_by_natural_key(self, subject_identifier_as_pk):
return self.get(subject_identifier_as_pk=subject_identifier_as_pk)


class ConsentManager(models.Manager):
def first_consent(self, subject_identifier=None) -> ConsentLikeModel:
"""Returns the first consent by consent_datetime."""
return (
self.filter(subject_identifier=subject_identifier)
.order_by("consent_datetime")
.first()
)
class ConsentObjectsByCdefManager(ConsentObjectsManager):
"""An objects model manager to use on consent proxy models
linked to a ConsentDefinition.
Filters queryset by the proxy model's label_lower
"""

def get_queryset(self):
qs = super().get_queryset()
cdef = site_consents.get_consent_definition(model=qs.model._meta.label_lower)
return qs.filter(version=cdef.version)


class CurrentSiteByCdefManager(CurrentSiteManager):
"""A site model manager to use on consent proxy models
linked to a ConsentDefinition.
Filters queryset by the proxy model's label_lower
"""

def get_queryset(self):
qs = super().get_queryset()
cdef = site_consents.get_consent_definition(model=qs.model._meta.label_lower)
return qs.filter(version=cdef.version)
6 changes: 2 additions & 4 deletions edc_consent/model_mixins/consent_model_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from edc_utils import age, formatted_age

from ..field_mixins import VerificationFieldsMixin
from ..managers import ConsentManager, ObjectConsentManager
from ..managers import ConsentObjectsManager
from .consent_version_model_mixin import ConsentVersionModelMixin


Expand Down Expand Up @@ -70,9 +70,7 @@ class ConsentModelMixin(ConsentVersionModelMixin, VerificationFieldsMixin, model
help_text="A unique identifier for this consent instance",
)

objects = ObjectConsentManager()

consent = ConsentManager()
objects = ConsentObjectsManager()

on_site = CurrentSiteManager()

Expand Down
15 changes: 9 additions & 6 deletions edc_consent/site_consents.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ def register(self, cdef: ConsentDefinition) -> None:
if cdef.name in self.registry:
raise AlreadyRegistered(f"Consent definition already registered. Got {cdef.name}.")
for registered_cdef in self.registry.values():
if registered_cdef.model == cdef.model:
if (
cdef
and cdef.validate_duration_overlap_by_model
and registered_cdef.model == cdef.model
):
if (
registered_cdef.start <= cdef.start <= registered_cdef.end
or registered_cdef.start <= cdef.end <= registered_cdef.end
Expand Down Expand Up @@ -68,15 +72,14 @@ def register(self, cdef: ConsentDefinition) -> None:
self.loaded = True

def get_registry_display(self):
return "', '".join(
[cdef.display_name for cdef in sorted(list(self.registry.values()))]
)
cdefs = sorted(list(self.registry.values()), key=lambda x: x.version)
return "', '".join([cdef.display_name for cdef in cdefs])

def get(self, name) -> ConsentDefinition:
return self.registry.get(name)

def all(self) -> list[ConsentDefinition]:
return sorted(list(self.registry.values()))
return sorted(list(self.registry.values()), key=lambda x: x.version)

def get_consent_definition(
self,
Expand Down Expand Up @@ -145,7 +148,7 @@ def get_consent_definitions(
for k, v in kwargs.items():
if v is not None:
cdefs = [cdef for cdef in cdefs if getattr(cdef, k) == v]
return sorted(cdefs)
return sorted(cdefs, key=lambda x: x.version)

@staticmethod
def _filter_cdefs_by_model_or_raise(
Expand Down
Loading

0 comments on commit 1f68176

Please sign in to comment.