Skip to content

Commit

Permalink
refactor consent objects, rename as consent definitions, more consist…
Browse files Browse the repository at this point in the history
…ent use of exceptions, reraise exceptions in modelform, remove consent_group concept
  • Loading branch information
erikvw committed Jan 5, 2024
1 parent 000b443 commit 9da9bd2
Show file tree
Hide file tree
Showing 27 changed files with 577 additions and 513 deletions.
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ Register your consent model, its version and period of validity, with ``site_con
from datetime import datetime
from zoneifo import ZoneInfo
from edc_consent.consent import Consent
from edc_consent.consent_definition import ConsentDefinition
from edc_consent.site_consents import site_consents
from edc_constants.constants import MALE, FEMALE
subjectconsent_v1 = Consent(
subjectconsent_v1 = ConsentDefinition(
'edc_example.subjectconsent',
version='1',
start=datetime(2013, 10, 15, tzinfo=ZoneInfo("UTC")),
Expand Down
2 changes: 1 addition & 1 deletion edc_consent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

__version__ = version("edc_consent")

from .exceptions import ConsentObjectDoesNotExist, NotConsentedError
from .exceptions import ConsentDefinitionDoesNotExist, NotConsentedError
from .model_wrappers import ConsentModelWrapperMixin
from .site_consents import site_consents
11 changes: 4 additions & 7 deletions edc_consent/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,19 @@

from django.apps import AppConfig as DjangoAppConfig

from .constants import DEFAULT_CONSENT_GROUP


class AppConfig(DjangoAppConfig):
name = "edc_consent"
verbose_name = "Edc Consent"
default_consent_group = DEFAULT_CONSENT_GROUP
include_in_administration_section = True

def ready(self):
from .site_consents import site_consents

sys.stdout.write(f"Loading {self.verbose_name} ...\n")
site_consents.autodiscover()
for consent in site_consents.consents:
start = consent.start.strftime("%Y-%m-%d %Z")
end = consent.end.strftime("%Y-%m-%d %Z")
sys.stdout.write(f" * {consent} covering {start} to {end}\n")
for consent_definition in site_consents.consent_definitions:
start = consent_definition.start.strftime("%Y-%m-%d %Z")
end = consent_definition.end.strftime("%Y-%m-%d %Z")
sys.stdout.write(f" * {consent_definition} covering {start} to {end}\n")
sys.stdout.write(f" Done loading {self.verbose_name}.\n")
77 changes: 0 additions & 77 deletions edc_consent/consent.py

This file was deleted.

57 changes: 57 additions & 0 deletions edc_consent/consent_definition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

from dataclasses import KW_ONLY, dataclass, field
from datetime import datetime

from django.apps import apps as django_apps
from edc_constants.constants import FEMALE, MALE
from edc_utils import formatted_date


class InvalidGender(Exception):
pass


class NaiveDatetimeError(Exception):
pass


@dataclass(order=True)
class ConsentDefinition:
"""A class that represents the general attributes
of a consent.
"""

model: str = field(compare=False)
_ = KW_ONLY
start: datetime = field(compare=False)
end: datetime = field(compare=False)
age_min: int = field(compare=False)
age_max: int = field(compare=False)
age_is_adult: int | None = field(compare=False)
name: str = field(init=False, compare=True)
version: str = field(default="1", compare=False)
gender: list[str] = field(default_factory=list, compare=False)
subject_type: str = field(default="subject", compare=False)
updates_versions: list[str] = field(default_factory=list, compare=False)
proxy_models: list[str] = field(default_factory=list, compare=False)

def __post_init__(self):
if not self.start.tzinfo:
raise NaiveDatetimeError(f"Naive datetime is invalid. Got {self.start}.")
if not self.end.tzinfo:
raise NaiveDatetimeError(f"Naive datetime is invalid. Got {self.end}.")
if MALE not in self.gender and FEMALE not in self.gender:
raise InvalidGender(f"Invalid gender. Got {self.gender}.")
self.name = f"{self.model}-{self.version}"

@property
def model_cls(self):
return django_apps.get_model(self.model)

@property
def display_name(self) -> str:
return (
f"{self.model_cls._meta.verbose_name} v{self.version} valid "
f"from {formatted_date(self.start)} to {formatted_date(self.end)}"
)
113 changes: 113 additions & 0 deletions edc_consent/consent_definition_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from edc_protocol import Protocol
from edc_utils import floor_secs, formatted_datetime
from edc_utils.date import ceil_datetime, floor_datetime

from .exceptions import ConsentVersionSequenceError

if TYPE_CHECKING:
from .consent_definition import ConsentDefinition


class ConsentPeriodError(Exception):
pass


class ConsentPeriodOverlapError(Exception):
pass


class ConsentDefinitionValidator:
def __init__(
self,
consent_definition: ConsentDefinition = None,
consent_definitions: list[ConsentDefinition] = None,
):
self.consent_definitions = consent_definitions
self.check_consent_period_within_study_period(consent_definition)
self.check_consent_period_for_overlap(consent_definition)
self.check_version(consent_definition)
self.check_updates_versions(consent_definition)

def get_consent_definitions_by_model(self, model: str = None) -> list[ConsentDefinition]:
"""Returns a list of ConsentDefinitions configured with the
given consent model label_lower.
"""
return [cdef for cdef in self.consent_definitions if cdef.model == model]

def get_consent_definitions_by_version(
self, model: str = None, version: str = None
) -> list[ConsentDefinition]:
"""Returns a list of ConsentDefinitions of "version"
configured with the given consent model.
"""
consents = self.get_consent_definitions_by_model(model=model)
return [consent for consent in consents if consent.version == version]

def check_consent_period_for_overlap(
self, consent_definition: ConsentDefinition = None
) -> None:
"""Raises an error if consent period overlaps with an
already registered consent object.
"""
for cdef in self.consent_definitions:
if cdef.model == consent_definition.model:
if (
consent_definition.start <= cdef.start <= consent_definition.end
or consent_definition.start <= cdef.end <= consent_definition.end
):
raise ConsentPeriodOverlapError(
f"Consent period overlaps with an already registered consent."
f"See already registered consent {cdef}. "
f"Got {consent_definition}."
)

@staticmethod
def check_consent_period_within_study_period(consent_definition: ConsentDefinition = None):
"""Raises if the start or end date of the consent period
it not within the opening and closing dates of the protocol.
"""
protocol = Protocol()
study_open_datetime = protocol.study_open_datetime
study_close_datetime = protocol.study_close_datetime
for index, dt in enumerate([consent_definition.start, consent_definition.end]):
if not (
floor_secs(floor_datetime(study_open_datetime))
<= floor_secs(dt)
<= floor_secs(ceil_datetime(study_close_datetime))
):
dt_label = "start" if index == 0 else "end"
formatted_study_open_datetime = formatted_datetime(study_open_datetime)
formatted_study_close_datetime = formatted_datetime(study_close_datetime)
formatted_dt = formatted_datetime(dt)
raise ConsentPeriodError(
f"Invalid consent. Consent period for {consent_definition.name} "
"must be within study opening/closing dates of "
f"{formatted_study_open_datetime} - "
f"{formatted_study_close_datetime}. "
f"Got {dt_label}={formatted_dt}."
)

def check_updates_versions(self, consent_definition: ConsentDefinition = None):
for version in consent_definition.updates_versions:
if not self.get_consent_definitions_by_version(
model=consent_definition.model, version=version
):
raise ConsentVersionSequenceError(
f"Consent version {version} cannot be an update to version(s) "
f"'{consent_definition.updates_versions}'. "
f"Version '{version}' not found for '{consent_definition.model}'"
)

def check_version(self, consent_definition: ConsentDefinition = None):
if self.get_consent_definitions_by_version(
model=consent_definition.model, version=consent_definition.version
):
raise ConsentVersionSequenceError(
"Consent version already registered. "
f"Version {consent_definition.version}. "
f"Got {consent_definition}."
)
13 changes: 6 additions & 7 deletions edc_consent/consent_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class ConsentHelper:

"""A class to get the consent configuration object and to
"""A class to get the consent definition object and to
validate version numbers if this consent is an update
of a previous.
"""
Expand Down Expand Up @@ -34,15 +34,14 @@ def __init__(
self.subject_identifier = subject_identifier
self.update_previous = True if update_previous is None else update_previous

self.consent_object = site_consents.get_consent_for_period(
self.consent_definition = site_consents.get_consent_definition_for_period(
model=self.model_cls._meta.label_lower,
consent_group=self.model_cls._meta.consent_group,
report_datetime=consent_datetime,
)

# these to be set on the model
self.version = self.consent_object.version
self.updates_versions = self.consent_object.updates_versions
self.version = self.consent_definition.version
self.updates_versions = self.consent_definition.updates_versions

# if updates a previous, validate version sequence
# and update the subject_identifier_as_pk, etc
Expand All @@ -62,13 +61,13 @@ def previous_consent(self):
opts = dict(
subject_identifier=self.subject_identifier,
identity=self.identity,
version__in=self.consent_object.updates_versions,
version__in=self.consent_definition.updates_versions,
)
opts = {k: v for k, v in opts.items() if v is not None}
try:
self._previous_consent = self.model_cls.objects.get(**opts)
except ObjectDoesNotExist:
updates_versions = ", ".join(self.consent_object.updates_versions)
updates_versions = ", ".join(self.consent_definition.updates_versions)
raise ConsentVersionSequenceError(
f"Failed to update previous version. A previous consent "
f"with version in {updates_versions} for {self.subject_identifier} "
Expand Down
Loading

0 comments on commit 9da9bd2

Please sign in to comment.