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