Skip to content

Commit

Permalink
Merge branch 'release/0.3.75' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
erikvw committed Mar 8, 2024
2 parents 8f95021 + 917cca0 commit 431a761
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 99 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
43 changes: 30 additions & 13 deletions edc_consent/managers.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
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

from .site_consents import site_consents

if TYPE_CHECKING:
from .stubs import ConsentLikeModel

class ConsentObjectsManager(SearchSlugManager, models.Manager):
use_in_migrations = True

class ObjectConsentManager(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 431a761

Please sign in to comment.