Skip to content

Commit

Permalink
Merge pull request #2 from clinicedc/fix/consent_definition_overlap
Browse files Browse the repository at this point in the history
Fix/consent definition overlap
  • Loading branch information
erikvw committed Mar 26, 2024
2 parents b2de290 + 14ccf6c commit 3dee4e1
Show file tree
Hide file tree
Showing 14 changed files with 367 additions and 192 deletions.
9 changes: 8 additions & 1 deletion consent_app/baker_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
from faker import Faker
from model_bakery.recipe import Recipe, seq

from .models import SubjectConsent, SubjectConsentV1, SubjectConsentV2, SubjectConsentV3
from .models import (
SubjectConsent,
SubjectConsentV1,
SubjectConsentV2,
SubjectConsentV3,
SubjectConsentV4,
)

fake = Faker()

Expand Down Expand Up @@ -42,3 +48,4 @@ def get_opts():
subjectconsentv1 = Recipe(SubjectConsentV1, **get_opts())
subjectconsentv2 = Recipe(SubjectConsentV2, **get_opts())
subjectconsentv3 = Recipe(SubjectConsentV3, **get_opts())
subjectconsentv4 = Recipe(SubjectConsentV4, **get_opts())
9 changes: 8 additions & 1 deletion consent_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ class Meta:


class SubjectConsentV2(SubjectConsent):

on_site = CurrentSiteByCdefManager()
objects = ConsentObjectsByCdefManager()

Expand All @@ -103,6 +102,14 @@ class Meta:
proxy = True


class SubjectConsentV4(SubjectConsent):
on_site = CurrentSiteByCdefManager()
objects = ConsentObjectsByCdefManager()

class Meta:
proxy = True


class SubjectConsentUpdateToV3(SubjectConsent):
class Meta:
proxy = True
Expand Down
35 changes: 8 additions & 27 deletions edc_consent/consent_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
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
from edc_utils.date import ceil_datetime, floor_datetime, to_local

from .exceptions import (
ConsentDefinitionError,
Expand Down Expand Up @@ -120,42 +120,23 @@ def sites(self):
def get_consent_for(
self,
subject_identifier: str = None,
report_datetime: datetime | None = None,
site_id: int | None = None,
raise_if_not_consented: bool | None = None,
) -> ConsentLikeModel | None:
"""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
)
if report_datetime:
opts.update(consent_datetime__lte=to_utc(report_datetime))
opts = dict(subject_identifier=subject_identifier, version=self.version)
if site_id:
opts.update(site_id=site_id)
try:
consent_obj = self.model_cls.objects.get(**opts)
except ObjectDoesNotExist:
if self.updates:
opts.update(version=self.updates.version)
try:
consent_obj = self.updates.model_cls.objects.get(**opts)
except ObjectDoesNotExist:
pass
consent_obj = None
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}'?"
f"Consent not found for this version. Has subject '{subject_identifier}' "
f"completed a version '{self.version}' consent?"
)
return consent_obj

Expand Down
4 changes: 4 additions & 0 deletions edc_consent/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ class AlreadyRegistered(Exception):

class SiteConsentError(Exception):
pass


class ConsentDefinitionNotConfiguredForUpdate(Exception):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

from edc_consent import ConsentDefinitionDoesNotExist, site_consents
from edc_consent.consent_definition import ConsentDefinition
from edc_consent.exceptions import NotConsentedError, SiteConsentError
from edc_consent.exceptions import (
ConsentDefinitionNotConfiguredForUpdate,
NotConsentedError,
SiteConsentError,
)


class ConsentDefinitionFormValidatorMixin:
Expand Down Expand Up @@ -38,18 +42,14 @@ def get_consent_or_raise(
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,
consent_obj = site_consents.get_consent_or_raise(
subject_identifier=self.subject_consent, report_datetime=report_datetime
)
except NotConsentedError as e:
except (NotConsentedError, ConsentDefinitionNotConfiguredForUpdate) as e:
self.raise_validation_error({fldname: str(e)}, error_code)
return consent_obj

Expand Down
2 changes: 1 addition & 1 deletion edc_consent/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ class CurrentSiteByCdefManager(CurrentSiteManager):
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)
return qs.filter(site_id=cdef.site.site_id, version=cdef.version)
44 changes: 25 additions & 19 deletions edc_consent/modelform_mixins/requires_consent_modelform_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

from django import forms
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_utils import formatted_date
from edc_utils.date import to_utc

from .. import NotConsentedError
from ..consent_definition import ConsentDefinition
from ..exceptions import ConsentDefinitionDoesNotExist
from ..exceptions import (
ConsentDefinitionDoesNotExist,
ConsentDefinitionNotConfiguredForUpdate,
NotConsentedError,
)
from ..site_consents import site_consents

__all__ = ["RequiresConsentModelFormMixin"]

Expand All @@ -20,33 +24,35 @@ class RequiresConsentModelFormMixin:

def clean(self):
cleaned_data = super().clean()
self.validate_against_consent()
consent_obj = self.validate_against_consent()
self.validate_against_dob(consent_obj)
return cleaned_data

def validate_against_dob(self, consent_obj):
if to_utc(self.report_datetime).date() < consent_obj.dob:
dte_str = formatted_date(consent_obj.dob)
raise forms.ValidationError(f"Report datetime cannot be before DOB. Got {dte_str}")

def validate_against_consent(self) -> None:
"""Raise an exception if the report datetime doesn't make
sense relative to the consent.
"""
if self.report_datetime:
try:
model_obj = self.consent_definition.get_consent_for(
consent_obj = site_consents.get_consent_or_raise(
subject_identifier=self.get_subject_identifier(),
report_datetime=self.report_datetime,
)
except NotConsentedError as e:
except (NotConsentedError, ConsentDefinitionNotConfiguredForUpdate) as e:
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}"
)
# 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}."
# )
return consent_obj

@property
def consent_definition(self) -> ConsentDefinition:
Expand Down
3 changes: 2 additions & 1 deletion edc_consent/models/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ def requires_consent_on_pre_save(instance, raw, using, update_fields, **kwargs):
consent_definition = site_consents.get_consent_definition(
site=site_sites.get(site.id), report_datetime=instance.report_datetime
)
consent_definition.get_consent_for(
site_consents.get_consent_or_raise(
subject_identifier=subject_identifier,
report_datetime=instance.report_datetime,
site_id=site.id,
)
instance.consent_version = consent_definition.version
instance.consent_model = consent_definition.model
57 changes: 57 additions & 0 deletions edc_consent/site_consents.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
AlreadyRegistered,
ConsentDefinitionDoesNotExist,
ConsentDefinitionError,
ConsentDefinitionNotConfiguredForUpdate,
SiteConsentError,
)

Expand Down Expand Up @@ -47,6 +48,9 @@ def register(
self.registry.update({cdef.name: cdef})
self.loaded = True

def unregister(self, cdef: ConsentDefinition) -> None:
self.registry.pop(cdef.name, None)

def get_registry_display(self):
cdefs = sorted(list(self.registry.values()), key=lambda x: x.version)
return "', '".join([cdef.display_name for cdef in cdefs])
Expand Down Expand Up @@ -96,6 +100,59 @@ def validate_period_overlap_or_raise(self, cdef: ConsentDefinition):
f"Got {cdef.name}."
)

def get_consents(self, subject_identifier: str, site_id: int | None) -> list:
consents = []
for cdef in self.all():
if consent_obj := cdef.get_consent_for(
subject_identifier=subject_identifier,
site_id=site_id,
raise_if_not_consented=False,
):
consents.append(consent_obj)
return consents

def get_consent_or_raise(
self,
subject_identifier: str,
report_datetime: datetime,
site_id: int,
raise_if_not_consented: bool | None = None,
):
"""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 consent does not exist raises a
`NotConsentedError`.
"""
from edc_sites.site import sites as site_sites # avoid circular import

raise_if_not_consented = (
True if raise_if_not_consented is None else raise_if_not_consented
)
single_site = site_sites.get(site_id)
cdef = self.get_consent_definition(report_datetime=report_datetime, site=single_site)
consent_obj = cdef.get_consent_for(
subject_identifier, raise_if_not_consented=raise_if_not_consented
)
if consent_obj and report_datetime < consent_obj.consent_datetime:
if not cdef.updates:
dte = formatted_date(report_datetime)
raise ConsentDefinitionNotConfiguredForUpdate(
f"Consent not configured to update any previous versions. "
f"Got '{cdef.version}'. "
f"Has subject '{subject_identifier}' completed version '{cdef.version}' "
f"of consent on or after report_datetime='{dte}'?"
)
else:
return cdef.updates.get_consent_for(
subject_identifier, raise_if_not_consented=raise_if_not_consented
)
return consent_obj

def get_consent_definition(
self,
model: str = None,
Expand Down
1 change: 1 addition & 0 deletions edc_consent/tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"sites.E101",
"edc_navbar.E002",
"edc_navbar.E003",
"edc_sites.E001",
],
ETC_DIR=str(base_dir / app_name / "tests" / "etc"),
EDC_NAVBAR_DEFAULT="edc_consent",
Expand Down
10 changes: 5 additions & 5 deletions edc_consent/tests/tests/test_consent_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,23 @@ def setUp(self):
self.study_close_datetime = ResearchProtocolConfig().study_close_datetime

self.consent_v1 = self.consent_factory(
proxy_model="consent_app.subjectconsentv1",
start=self.study_open_datetime,
end=self.study_open_datetime + timedelta(days=50),
model="consent_app.subjectconsentv1",
version="1.0",
)

self.consent_v2 = self.consent_factory(
proxy_model="consent_app.subjectconsentv2",
start=self.study_open_datetime + timedelta(days=51),
end=self.study_open_datetime + timedelta(days=100),
model="consent_app.subjectconsentv2",
version="2.0",
)
self.consent_v3 = self.consent_factory(
proxy_model="consent_app.subjectconsentv3",
start=self.study_open_datetime + timedelta(days=101),
end=self.study_open_datetime + timedelta(days=150),
version="3.0",
model="consent_app.subjectconsentv3",
updates=self.consent_v2,
)
site_consents.register(self.consent_v1)
Expand All @@ -92,8 +92,8 @@ def consent_factory(**kwargs):
age_max=kwargs.get("age_max", 64),
age_is_adult=kwargs.get("age_is_adult", 18),
)
model = kwargs.get("model", "consent_app.subjectconsentv1")
consent_definition = ConsentDefinition(model, **options)
proxy_model = kwargs.get("proxy_model", "consent_app.subjectconsentv1")
consent_definition = ConsentDefinition(proxy_model, **options)
return consent_definition

def cleaned_data(self, **kwargs):
Expand Down
Loading

0 comments on commit 3dee4e1

Please sign in to comment.