Skip to content

Commit 8966e7d

Browse files
committed
Merge branch 'release/0.3.0' into main
2 parents d370f52 + 7f5ee2e commit 8966e7d

11 files changed

Lines changed: 616 additions & 28 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "edc-cdisc"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "CDISC compliance"
55
readme = "README.rst"
66
requires-python = ">=3.12"

src/edc_cdisc/constants.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,43 @@
8989
ITEM_GROUP = "IG"
9090
ITEM = "I"
9191
FORM = "F"
92+
USER = "USR" # AdminData User OID prefix
93+
LOCATION = "LOC" # AdminData Location OID prefix
94+
95+
SITE_LOCATION_TYPE = "Site" # ODM Location@LocationType for a study site
96+
USER_TYPE_OTHER = "Other" # default ODM User@UserType (field staff are not Investigators)
97+
98+
# ODM AuditRecord TransactionType, mapped from simple_history history_type
99+
INSERT = "Insert"
100+
UPDATE = "Update"
101+
REMOVE = "Remove"
102+
HISTORY_TYPE_MAP = {"+": INSERT, "~": UPDATE, "-": REMOVE}
92103

93104
SCHEDULED_TYPE = "Scheduled"
94105
UNSCHEDULED_TYPE = "Unscheduled"
95106
COMMON_TYPE = "Common"
96107

108+
SCREENING_EVENT_CATEGORY = "screening"
109+
CONSENT_EVENT_CATEGORY = "consent"
110+
111+
# Models whose export field list is an explicit whitelist instead of the admin
112+
# fieldsets. Used for documents that hold sensitive data (e.g. consent): only
113+
# the named, non-PII fields are exported. The encrypted-field and
114+
# EXCLUDED_FIELD_NAMES floors STILL apply on top (see iter_whitelist_fields), so
115+
# an encrypted/excluded field added here cannot be re-exposed — it is dropped
116+
# and a warning is emitted. Plaintext-sensitive additions are caught by review;
117+
# keep these lists tiny and explicit.
118+
CONSENT_EXPORT_FIELDS: tuple[str, ...] = (
119+
"subject_identifier",
120+
"consent_datetime",
121+
"model_name",
122+
"version",
123+
"consent_definition_name",
124+
)
125+
126+
# Static per-model whitelists (model label_lower -> field names). The consent
127+
# model is resolved dynamically from settings.SUBJECT_CONSENT_MODEL in
128+
# get_whitelist_fields, so it is not listed here.
129+
WHITELIST_FIELDS: dict[str, tuple[str, ...]] = {}
130+
97131
ODM_SCHEMA_PATH = Path(__file__).parent / "odm_schema" / "cdisc-odm-1.3.1" / "ODM1-3-1.xsd"

src/edc_cdisc/serializers/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,16 @@
22
from .metadata_serializer import MetadataSerializer
33
from .serializer import Serializer
44
from .snapshot_serializer import SnapshotSerializer
5+
from .transactional_serializer import (
6+
TransactionalClinicalDataSerializer,
7+
TransactionalSerializer,
8+
)
59

6-
__all__ = ["ClinicalDataSerializer", "MetadataSerializer", "Serializer", "SnapshotSerializer"]
10+
__all__ = [
11+
"ClinicalDataSerializer",
12+
"MetadataSerializer",
13+
"Serializer",
14+
"SnapshotSerializer",
15+
"TransactionalClinicalDataSerializer",
16+
"TransactionalSerializer",
17+
]
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
import warnings
4+
from collections.abc import Iterable
5+
6+
from django.conf import settings
7+
from django.contrib.auth import get_user_model
8+
from edc_sites.site import sites as site_sites
9+
from lxml import etree
10+
11+
from ..constants import (
12+
LOCATION,
13+
SITE_LOCATION_TYPE,
14+
USER,
15+
USER_TYPE_OTHER,
16+
)
17+
from ..utils import oid
18+
19+
# settings map: edc_auth Role.name -> ODM User@UserType; anything not listed
20+
# (and any user with no listed role) defaults to USER_TYPE_OTHER. Field clinical
21+
# staff are "Other", not "Investigator".
22+
USER_TYPE_BY_ROLE_SETTING = "EDC_CDISC_USER_TYPE_BY_ROLE"
23+
# applied in priority order when a user holds several mapped roles
24+
USER_TYPE_PRIORITY = ("Sponsor", "Investigator", "Lab")
25+
26+
27+
class AdminDataMixin:
28+
"""Builds ODM ``AdminData`` (``User`` + ``Location``) for the users and
29+
sites referenced by the exported audit records.
30+
31+
ODM 1.3.1 ``User``/``Location`` are closed types (no ``Alias``/extension),
32+
so edc_auth roles can only be expressed coarsely via ``User@UserType``.
33+
"""
34+
35+
@staticmethod
36+
def user_type_for_roles(role_names: Iterable[str]) -> str:
37+
mapping = getattr(settings, USER_TYPE_BY_ROLE_SETTING, {}) or {}
38+
mapped = {mapping[name] for name in role_names if name in mapping}
39+
for user_type in USER_TYPE_PRIORITY:
40+
if user_type in mapped:
41+
return user_type
42+
return USER_TYPE_OTHER
43+
44+
def build_admin_data(
45+
self,
46+
usernames: Iterable[str],
47+
site_ids: Iterable[int],
48+
metadata_version_oid: str,
49+
) -> etree._Element:
50+
element = etree.Element("AdminData", StudyOID=self.protocol_oid)
51+
for username in sorted(usernames):
52+
element.append(self.build_user_element(username))
53+
for site_id in sorted(site_ids):
54+
element.append(self.build_location_element(site_id, metadata_version_oid))
55+
return element
56+
57+
def build_user_element(self, username: str) -> etree._Element:
58+
user_model_cls = get_user_model()
59+
user = user_model_cls.objects.filter(username=username).first()
60+
if user is None:
61+
# still emit a minimal User so the AuditRecord UserRef resolves
62+
element = etree.Element("User", OID=oid(USER, username))
63+
etree.SubElement(element, "LoginName").text = username
64+
return element
65+
66+
profile = getattr(user, "userprofile", None)
67+
role_names = {role.name for role in profile.roles.all()} if profile else set()
68+
institution = (getattr(profile, "institution", "") if profile else "") or (
69+
self.protocol_config.institution
70+
)
71+
user_site_ids = sorted(site.id for site in profile.sites.all()) if profile else []
72+
73+
element = etree.Element(
74+
"User",
75+
OID=oid(USER, username),
76+
UserType=self.user_type_for_roles(role_names),
77+
)
78+
# children in XSD order: LoginName, FirstName, LastName, Organization,
79+
# Email, LocationRef
80+
etree.SubElement(element, "LoginName").text = username
81+
if user.first_name:
82+
etree.SubElement(element, "FirstName").text = user.first_name
83+
if user.last_name:
84+
etree.SubElement(element, "LastName").text = user.last_name
85+
if institution:
86+
etree.SubElement(element, "Organization").text = institution
87+
if user.email:
88+
etree.SubElement(element, "Email").text = user.email
89+
for site_id in user_site_ids:
90+
etree.SubElement(element, "LocationRef", LocationOID=oid(LOCATION, str(site_id)))
91+
return element
92+
93+
def build_location_element(
94+
self, site_id: int, metadata_version_oid: str
95+
) -> etree._Element:
96+
try:
97+
name = site_sites.get(site_id).title
98+
except Exception: # unknown/unregistered site → fall back to the id
99+
warnings.warn(
100+
f"Site id {site_id} not in edc_sites registry; using id as Name.",
101+
UserWarning,
102+
stacklevel=2,
103+
)
104+
name = str(site_id)
105+
element = etree.Element(
106+
"Location",
107+
OID=oid(LOCATION, str(site_id)),
108+
Name=str(name),
109+
LocationType=SITE_LOCATION_TYPE,
110+
)
111+
etree.SubElement(
112+
element,
113+
"MetaDataVersionRef",
114+
StudyOID=self.protocol_oid,
115+
MetaDataVersionOID=metadata_version_oid,
116+
EffectiveDate=self.protocol_config.study_open_datetime.date().isoformat(),
117+
)
118+
return element

src/edc_cdisc/serializers/clinical_data_serializer.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def build_subject_data(
103103
if event_element is not None:
104104
event_elements.append(event_element)
105105
event_elements += self.build_common_event_data(subject_identifier)
106+
event_elements += self.build_consent_event_data(subject_identifier)
106107

107108
if not event_elements:
108109
return None
@@ -135,13 +136,24 @@ def build_study_event_data(
135136
return element
136137

137138
def build_form_data(
138-
self, model: str, instance, transaction_type: str | None = None
139+
self,
140+
model: str,
141+
instance,
142+
transaction_type: str | None = None,
143+
audit_record: etree._Element | None = None,
139144
) -> etree._Element:
140-
model_cls = type(instance)
145+
# Field selection keys off the *concrete* model label (not type(instance)):
146+
# for a simple_history row type(instance) is the historical model, whose
147+
# label would miss the whitelist/encrypted rules and leak PII. No-op for
148+
# the snapshot, where type(instance) == get_model(model).
149+
model_cls = django_apps.get_model(model)
141150
attrs = {"FormOID": oid(FORM, model)}
142151
if transaction_type:
143152
attrs["TransactionType"] = transaction_type
144153
element = etree.Element("FormData", **attrs)
154+
# ODM: AuditRecord precedes ItemGroupData
155+
if audit_record is not None:
156+
element.append(audit_record)
145157

146158
# SAME field selection as the metadata side → ItemGroupOID/ItemOID
147159
# line up (encrypted fields skipped, sections kept stable).
@@ -187,6 +199,33 @@ def build_common_event_data(self, subject_identifier: str) -> list[etree._Elemen
187199
elements.append(element)
188200
return elements
189201

202+
def build_consent_event_data(self, subject_identifier: str) -> list[etree._Element]:
203+
"""Repeating Common event — one StudyEventData per consent row.
204+
205+
Consent is unique on subject_identifier + version, so a subject may have
206+
several rows; each is emitted with StudyEventRepeatKey = version. Only
207+
the whitelist fields (CONSENT_EXPORT_FIELDS) are serialized — the consent
208+
document holds sensitive data.
209+
"""
210+
consent_model = self.get_consent_model()
211+
if not consent_model:
212+
return []
213+
elements: list[etree._Element] = []
214+
qs = (
215+
django_apps.get_model(consent_model)
216+
.objects.filter(subject_identifier=subject_identifier)
217+
.order_by("version", "consent_datetime")
218+
)
219+
for instance in qs:
220+
element = etree.Element(
221+
"StudyEventData",
222+
StudyEventOID=self.common_event_oid(consent_model),
223+
StudyEventRepeatKey=str(instance.version),
224+
)
225+
element.append(self.build_form_data(consent_model, instance))
226+
elements.append(element)
227+
return elements
228+
190229
@staticmethod
191230
def get_crfs_for_visit(
192231
subject_visit: RelatedVisitProtocol,

src/edc_cdisc/serializers/metadata_serializer.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
from ..constants import (
1111
CODELIST,
1212
COMMON_TYPE,
13+
CONSENT_EVENT_CATEGORY,
1314
FORM,
1415
ITEM,
1516
ITEM_GROUP,
1617
SCHEDULED_TYPE,
18+
SCREENING_EVENT_CATEGORY,
1719
UNSCHEDULED_TYPE,
1820
)
1921
from ..exceptions import ProtocolSerializerError
@@ -70,14 +72,14 @@ def build_metadata_version(self) -> etree._Element:
7072
element = etree.Element("MetaDataVersion")
7173
element.append(self.get_protocol_element())
7274

73-
screening_model = self.get_screening_model()
75+
subject_level_events = list(self.iter_subject_level_events())
7476

7577
# StudyEventDefs (order among them is free; schedule carried by Alias)
76-
for model in self.get_common_models():
77-
element.append(self.get_common_event_def_element(model))
78-
if screening_model:
78+
for model, category, repeating in subject_level_events:
7979
element.append(
80-
self.get_common_event_def_element(screening_model, event_category="screening")
80+
self.get_common_event_def_element(
81+
model, event_category=category, repeating=repeating
82+
)
8183
)
8284
for schedule in self.visit_schedule.schedules.values():
8385
for visit in schedule.visits.values():
@@ -95,9 +97,7 @@ def build_metadata_version(self) -> etree._Element:
9597
for visit in schedule.visits.values()
9698
for crf in [*visit.all_crfs, *visit.all_requisitions]
9799
}
98-
common_models = [*self.get_common_models()]
99-
if screening_model:
100-
common_models.append(screening_model)
100+
common_models = [model for model, _category, _repeating in subject_level_events]
101101
models = [
102102
m
103103
for m in dict.fromkeys([*common_models, *sorted(form_models)])
@@ -130,22 +130,30 @@ def get_protocol_element(self) -> etree._Element:
130130
StudyEventOID=self.unscheduled_event_oid(visit_code),
131131
Mandatory=NO,
132132
)
133-
for model in self.get_common_models():
133+
for model, _category, _repeating in self.iter_subject_level_events():
134134
etree.SubElement(
135135
element,
136136
"StudyEventRef",
137137
StudyEventOID=self.common_event_oid(model),
138138
Mandatory=NO,
139139
)
140-
if screening_model := self.get_screening_model():
141-
etree.SubElement(
142-
element,
143-
"StudyEventRef",
144-
StudyEventOID=self.common_event_oid(screening_model),
145-
Mandatory=NO,
146-
)
147140
return element
148141

142+
def iter_subject_level_events(self) -> Iterator[tuple[str, str | None, bool]]:
143+
"""Yield ``(model, event_category, repeating)`` for each non-scheduled,
144+
subject-level Common event: death/offstudy, then screening, then consent.
145+
146+
Single source for the StudyEventDefs, the definition catalog, and the
147+
Protocol StudyEventRefs so they cannot drift. Consent is repeating
148+
(unique on subject_identifier + version); the rest are singletons.
149+
"""
150+
for model in self.get_common_models():
151+
yield model, None, False
152+
if screening_model := self.get_screening_model():
153+
yield screening_model, SCREENING_EVENT_CATEGORY, False
154+
if consent_model := self.get_consent_model():
155+
yield consent_model, CONSENT_EVENT_CATEGORY, True
156+
149157
@staticmethod
150158
def append_schedule_alias(element: etree._Element, schedule_name: str) -> None:
151159
etree.SubElement(element, "Alias", Context="clinicedc.schedule", Name=schedule_name)
@@ -326,13 +334,14 @@ def get_common_event_def_element(
326334
self,
327335
model: str,
328336
event_category: str | None = None,
337+
repeating: bool = False,
329338
) -> etree._Element:
330339
verbose_name = str(django_apps.get_model(model)._meta.verbose_name)
331340
element = etree.Element(
332341
"StudyEventDef",
333342
OID=self.common_event_oid(model),
334343
Name=verbose_name,
335-
Repeating=NO,
344+
Repeating=YES if repeating else NO,
336345
Type=COMMON_TYPE,
337346
)
338347
etree.SubElement(

0 commit comments

Comments
 (0)