diff --git a/castellum/recruitment/attribute_exporters.py b/castellum/recruitment/attribute_exporters.py new file mode 100644 index 0000000000000000000000000000000000000000..4ecc0576cd2272b5ce0921b41887f678aab9706f --- /dev/null +++ b/castellum/recruitment/attribute_exporters.py @@ -0,0 +1,152 @@ +# (c) 2018-2020 +# MPIB , +# MPI-CBS , +# MPIP +# +# This file is part of Castellum. +# +# Castellum is free software; you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Castellum is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with Castellum. If not, see +# . + +import csv +import json +from io import StringIO + +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.utils.module_loading import import_string + +from .attribute_fields import ANSWER_DECLINED + + +def get_exporter(path=None): + cls = import_string(path or settings.CASTELLUM_ATTRIBUTE_EXPORTER) + return cls() + + +class JSONExporter: + TYPES = { + 'IntegerField': 'integer', + 'BooleanField': 'boolean', + } + FORMATS = { + 'DateField': 'date', + 'AgeField': 'date', + } + + def _json_dumps(self, data): + return json.dumps(data, sort_keys=True, indent=4, cls=DjangoJSONEncoder) + + def get_schema(self, descriptions): + schema = { + 'type': 'object', + 'properties': { + 'id': { + 'type': 'string', + }, + }, + } + + for description in descriptions: + key = description.label + choices = list(description.attributechoice_set.all()) + + schema['properties'][key] = {k: v for k, v in [ + ('type', self.TYPES.get(description.field_type, 'string')), + ('format', self.FORMATS.get(description.field_type)), + ('enum', [c.label for c in choices]), + ('description', description.help_text), + ] if v} + + return self._json_dumps(schema) + + def get_schema_filename(self): + return 'attributes.schema.json' + + def get_data(self, descriptions, subjects): + data = [] + + for _id, subject in subjects: + data.append({ + 'id': str(_id), + }) + + for description in descriptions: + key = description.label + choices = list(description.attributechoice_set.all()) + + value = subject.attributes.get(description.json_key) + if value in [None, ANSWER_DECLINED]: + pass + elif choices: + choice = description.attributechoice_set.get(pk=value) + data[-1][key] = choice.label + else: + data[-1][key] = value + + return self._json_dumps(data) + + def get_data_filename(self): + return 'attributes.json' + + +class BIDSExporter: + # https://bids-specification.readthedocs.io/ + + def _json_dumps(self, data): + return json.dumps(data, sort_keys=True, indent=4, cls=DjangoJSONEncoder) + + def get_schema(self, descriptions): + schema = {} + + for description in descriptions: + key = description.label.lower().replace(' ', '_') + choices = list(description.attributechoice_set.all()) + + schema[key] = {k: v for k, v in [ + ('Levels', {c.id: c.label for c in choices}), + ('Description', description.help_text), + ('TermURL', description.url), + ] if v} + + return self._json_dumps(schema) + + def get_schema_filename(self): + return 'participants.json' + + def get_data(self, descriptions, subjects): + fieldnames = ['participant_id'] + for desc in descriptions: + key = desc.label.lower().replace(' ', '_') + fieldnames.append(key) + + fh = StringIO() + writer = csv.DictWriter(fh, fieldnames=fieldnames, dialect=csv.excel_tab) + writer.writeheader() + + for _id, subject in subjects: + row = {'participant_id': 'sub-%s' % _id} + for desc in descriptions: + key = desc.label.lower().replace(' ', '_') + value = subject.attributes.get(desc.json_key) + if value in [None, ANSWER_DECLINED]: + row[key] = 'n/a' + else: + row[key] = value + writer.writerow(row) + + return fh.getvalue() + + def get_data_filename(self): + return 'participants.tsv' diff --git a/castellum/recruitment/management/commands/attribute_export.py b/castellum/recruitment/management/commands/attribute_export.py index a8006928b1f06a8634814f33687065a9c957e780..c37bd2431a6b61776558f8a2e06a8c64e3dafaa0 100644 --- a/castellum/recruitment/management/commands/attribute_export.py +++ b/castellum/recruitment/management/commands/attribute_export.py @@ -20,59 +20,27 @@ # License along with Castellum. If not, see # . -import json from django.core.management.base import BaseCommand +from castellum.recruitment.attribute_exporters import get_exporter from castellum.recruitment.models import AttributeDescription -from castellum.recruitment.models.attributes import ANSWER_DECLINED from castellum.subjects.models import Subject -TYPES = { - 'IntegerField': 'integer', - 'BooleanField': 'boolean', -} -FORMATS = { - 'DateField': 'date', - 'AgeField': 'date', -} - class Command(BaseCommand): - help = 'Export attributes for a single subject (along with the schema).' + help = 'Export attributes for a single subject or the attribute schema.' def add_arguments(self, parser): - parser.add_argument('subject_id', type=int) + parser.add_argument('subject_id', type=int, nargs='?') + parser.add_argument('--exporter') def handle(self, **options): - subject = Subject.objects.get(pk=options['subject_id']) - - output = { - 'data': {}, - 'schema': { - 'type': 'object', - 'properties': {}, - }, - } - - for description in AttributeDescription.objects.all(): - key = description.label - choices = list(description.attributechoice_set.all()) - - value = subject.attributes.get(description.json_key) - if value in [None, ANSWER_DECLINED]: - pass - elif choices: - choice = description.attributechoice_set.get(pk=value) - output['data'][key] = choice.label - else: - output['data'][key] = value - - output['schema']['properties'][key] = {k: v for k, v in [ - ('type', TYPES.get(description.field_type, 'string')), - ('format', FORMATS.get(description.field_type)), - ('enum', [c.label for c in choices]), - ('description', description.help_text), - ] if v} - - print(json.dumps(output, sort_keys=True, indent=4)) + exporter = get_exporter(options.get('exporter')) + descriptions = AttributeDescription.objects.all() + if options['subject_id'] is None: + s = exporter.get_schema(descriptions) + else: + subject = Subject.objects.get(pk=options['subject_id']) + s = exporter.get_data(descriptions, [(subject.pk, subject)]) + print(s) diff --git a/castellum/settings/default.py b/castellum/settings/default.py index fa48e24c3ec30dd4936b60f668cd0a4c2dbee0b2..8084c13de04d9f792f24f33c48422b1b1d898063 100644 --- a/castellum/settings/default.py +++ b/castellum/settings/default.py @@ -417,5 +417,13 @@ CASTELLUM_FULL_AGE = 16 # Criteria that apply to all studies CASTELLUM_GENERAL_EXCLUSION_CRITERIA = '' +# Default exporter used for attributes. +# Currently available options are +# - 'castellum.recruitment.attribute_exporters.JSONExporter' +# - 'castellum.recruitment.attribute_exporters.BIDSExporter' +# You can also implement your own if required. +# See castellum/recruitment/attribute_exporters.py for details +CASTELLUM_ATTRIBUTE_EXPORTER = 'castellum.recruitment.attribute_exporters.JSONExporter' + SCHEDULER_URL = '' SCHEDULER_TOKEN = '' diff --git a/tests/recruitment/test_attribute_exporters.py b/tests/recruitment/test_attribute_exporters.py new file mode 100644 index 0000000000000000000000000000000000000000..a8cb0ff5534466f2f7771c07518a916147dcc1db --- /dev/null +++ b/tests/recruitment/test_attribute_exporters.py @@ -0,0 +1,111 @@ +from castellum.recruitment import attribute_exporters +from castellum.recruitment.models import AttributeDescription + +ATTRIBUTES = { + 'd1': 1, + 'd2': 'de', + 'd3': '1970-01-01', +} + +JSON_SCHEMA = """{ + "properties": { + "Date of birth": { + "format": "date", + "type": "string" + }, + "Handedness": { + "enum": [ + "Right", + "Left", + "Ambidextrous" + ], + "type": "string" + }, + "Highest degree": { + "enum": [ + "No degree", + "Elementary school", + "Hauptschule", + "Mittlere Reife", + "Abitur", + "Bachelor", + "Master" + ], + "type": "string" + }, + "Language": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "type": "object" +}""" + +JSON_DATA = """[ + { + "Date of birth": "1970-01-01", + "Handedness": "Right", + "Language": "de", + "id": "test" + } +]""" + +BIDS_SCHEMA = """{ + "date_of_birth": { + "TermURL": "http://purl.bioontology.org/ontology/SNOMEDCT/397669002" + }, + "handedness": { + "Levels": { + "1": "Right", + "2": "Left", + "3": "Ambidextrous" + }, + "TermURL": "http://purl.bioontology.org/ontology/SNOMEDCT/57427004" + }, + "highest_degree": { + "Levels": { + "4": "No degree", + "5": "Elementary school", + "6": "Hauptschule", + "7": "Mittlere Reife", + "8": "Abitur", + "9": "Bachelor", + "10": "Master" + } + }, + "language": {} +}""" + +BIDS_DATA = """participant_id\thandedness\tlanguage\tdate_of_birth\thighest_degree\r +sub-test\t1\tde\t1970-01-01\tn/a\r +""" + + +def test_json_schema(attribute_descriptions, db): + exporter = attribute_exporters.JSONExporter() + descriptions = AttributeDescription.objects.all() + assert exporter.get_schema(descriptions) == JSON_SCHEMA + + +def test_json_data(attribute_descriptions, contact): + exporter = attribute_exporters.JSONExporter() + descriptions = AttributeDescription.objects.all() + contact.subject.attributes = ATTRIBUTES + subjects = [('test', contact.subject)] + assert exporter.get_data(descriptions, subjects) == JSON_DATA + + +def test_bids_schema(attribute_descriptions, db): + exporter = attribute_exporters.BIDSExporter() + descriptions = AttributeDescription.objects.all() + assert exporter.get_schema(descriptions) == BIDS_SCHEMA + + +def test_bids_data(attribute_descriptions, contact): + exporter = attribute_exporters.BIDSExporter() + descriptions = AttributeDescription.objects.all() + contact.subject.attributes = ATTRIBUTES + subjects = [('test', contact.subject)] + assert exporter.get_data(descriptions, subjects) == BIDS_DATA