diff --git a/.readthedocs.yml b/.readthedocs.yml index 44b0bcde2..7b78cce75 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -15,9 +15,7 @@ formats: - htmlzip # Optionally set the version of Python and requirements required to build your docs -#python: -# version: 3.7 -# install: -# - requirements: requirements.txt -# - requirements: docs/requirements.txt -# - method: pip +python: + version: 3.7 + install: + - requirements: requirements.txt \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 5df749e32..09dfe5110 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,19 +4,19 @@ python: - "3.6" - "3.8" addons: - postgresql: "11" + postgresql: "12" apt: packages: - - postgresql-11 - - postgresql-contrib-11 + - postgresql-12 + - postgresql-contrib-12 before_install: - - sudo -u postgres psql -U postgres -p 5432 -d postgres -c "alter user postgres with password 'hj38f3Ntr';" + - sudo -u postgres psql -U postgres -p 5433 -d postgres -c "alter user postgres with password 'hj38f3Ntr';" install: - pip install -r requirements.txt - pip install . script: - - export POSTGRES_USER="postgres" && export POSTGRES_PASSWORD="hj38f3Ntr" && export POSTGRES_PORT=5432 - - python3 -m coverage run ./manage.py test + - export POSTGRES_USER="postgres" && export POSTGRES_PASSWORD="hj38f3Ntr" && export POSTGRES_PORT=5433 + - python3 -m tox - codecov - rm -rf chord_metadata_service - python3 -m coverage run ./manage.py test chord_metadata_service diff --git a/MANIFEST.in b/MANIFEST.in index 5e497cd7e..60049fcb3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ -include chord_metadata_service/chord/workflows/phenopackets_json.wdl +include chord_metadata_service/chord/workflows/*.wdl +include chord_metadata_service/chord/tests/*.json include chord_metadata_service/dats/* include chord_metadata_service/package.cfg diff --git a/README.md b/README.md index dc640f94a..387bc4f13 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,24 @@ under the BSD 3-clause license. CHORD Metadata Service is a service to store epigenomic metadata. 1. Patients service handles anonymized individual’s data (individual id, sex, age or date of birth) - * Data model: aggregated profile from GA4GH Phenopackets Individual and FHIR Patient + * Data model: aggregated profile from GA4GH Phenopackets Individual, FHIR Patient and mCODE Patient. 2. Phenopackets service handles phenotypic and clinical data * Data model: [GA4GH Phenopackets schema](https://github.com/phenopackets/phenopacket-schema) -3. CHORD service handles metadata about dataset, has relation to phenopackets (one dataset can have many phenopackets) +3. mCode service handles patient's oncology related data. + * Data model: [mCODE data elements](https://mcodeinitiative.org/) + +4. Experiments service handles experiment related data. + * Data model: derived from [IHEC Metadata Experiment](https://github.com/IHEC/ihec-ecosystems/blob/master/docs/metadata/2.0/Ihec_metadata_specification.md#experiments) + +5. Resources service handles metadata about ontologies used for data annotation. + * Data model: derived from Phenopackets Resource profile + +6. CHORD service handles metadata about dataset, has relation to phenopackets (one dataset can have many phenopackets) * Data model: [DATS](https://github.com/datatagsuite) + [GA4GH DUO](https://github.com/EBISPOT/DUO) -4. Rest api service handles all generic functionality shared among other services +7. Rest api service handles all generic functionality shared among other services ## REST API highlights @@ -42,7 +51,6 @@ Phenopackets model is mapped to [FHIR](https://www.hl7.org/fhir/) using To retrieve data in fhir append `?format=fhir` . * Ingest endpoint: `/private/ingest`. -Example of POST body is in `chord/views_ingest.py` (`METADATA_WORKFLOWS`). ## Install @@ -131,22 +139,28 @@ out and tagged from the tagged major/minor release in `master`. Tests are located in tests directory in an individual app folder. -Run all tests for the whole project: +Run all tests and linting checks for the whole project: +```bash +tox ``` + +Run all tests for the whole project: + +```bash python manage.py test ``` Run tests for an individual app, e.g.: -``` +```bash python manage.py test chord_metadata_service.phenopackets.tests.test_api ``` -Create coverage html report: +Test and create `coverage` HTML report: -``` -coverage run manage.py test +```bash +tox coverage html ``` diff --git a/chord_metadata_service/chord/admin.py b/chord_metadata_service/chord/admin.py index 11f475f24..abdc96641 100644 --- a/chord_metadata_service/chord/admin.py +++ b/chord_metadata_service/chord/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import * +from .models import Project, Dataset, TableOwnership, Table @admin.register(Project) @@ -16,3 +16,8 @@ class DatasetAdmin(admin.ModelAdmin): @admin.register(TableOwnership) class TableOwnershipAdmin(admin.ModelAdmin): pass + + +@admin.register(Table) +class TableAdmin(admin.ModelAdmin): + pass diff --git a/chord_metadata_service/chord/api_views.py b/chord_metadata_service/chord/api_views.py index d8e914efd..aad9f96a9 100644 --- a/chord_metadata_service/chord/api_views.py +++ b/chord_metadata_service/chord/api_views.py @@ -4,12 +4,12 @@ from chord_metadata_service.restapi.api_renderers import PhenopacketsRenderer, JSONLDDatasetRenderer, RDFDatasetRenderer from chord_metadata_service.restapi.pagination import LargeResultsSetPagination -from .models import * +from .models import Project, Dataset, TableOwnership, Table from .permissions import OverrideOrSuperUserOnly -from .serializers import * +from .serializers import ProjectSerializer, DatasetSerializer, TableOwnershipSerializer, TableSerializer -__all__ = ["ProjectViewSet", "DatasetViewSet", "TableOwnershipViewSet"] +__all__ = ["ProjectViewSet", "DatasetViewSet", "TableOwnershipViewSet", "TableViewSet"] class ReadOnly(BasePermission): @@ -61,8 +61,23 @@ class TableOwnershipViewSet(CHORDPublicModelViewSet): post: Create a new relationship between a dataset (and optionally a specific biosample) and a table - in another service + in a data service """ queryset = TableOwnership.objects.all().order_by("table_id") serializer_class = TableOwnershipSerializer + + +class TableViewSet(CHORDPublicModelViewSet): + """ + get: + Return a list of tables + + post: + Create a new table + """ + + # TODO: Create TableOwnership if needed - here or model? + + queryset = Table.objects.all().prefetch_related("ownership_record").order_by("ownership_record_id") + serializer_class = TableSerializer diff --git a/chord_metadata_service/chord/apps.py b/chord_metadata_service/chord/apps.py index 76f5509a6..70f7fa3e1 100644 --- a/chord_metadata_service/chord/apps.py +++ b/chord_metadata_service/chord/apps.py @@ -2,4 +2,4 @@ class ChordConfig(AppConfig): - name = 'chord' + name = 'chord_metadata_service.chord' diff --git a/chord_metadata_service/chord/data_types.py b/chord_metadata_service/chord/data_types.py new file mode 100644 index 000000000..5267b7196 --- /dev/null +++ b/chord_metadata_service/chord/data_types.py @@ -0,0 +1,35 @@ +from chord_metadata_service.experiments.search_schemas import EXPERIMENT_SEARCH_SCHEMA +from chord_metadata_service.phenopackets.search_schemas import PHENOPACKET_SEARCH_SCHEMA +from chord_metadata_service.mcode.schemas import MCODE_SCHEMA + +__all__ = [ + "DATA_TYPE_EXPERIMENT", + "DATA_TYPE_PHENOPACKET", + "DATA_TYPE_MCODEPACKET", + "DATA_TYPES", +] + +DATA_TYPE_EXPERIMENT = "experiment" +DATA_TYPE_PHENOPACKET = "phenopacket" +DATA_TYPE_MCODEPACKET = "mcodepacket" + +DATA_TYPES = { + DATA_TYPE_EXPERIMENT: { + "schema": EXPERIMENT_SEARCH_SCHEMA, + "metadata_schema": { + "type": "object", # TODO + }, + }, + DATA_TYPE_PHENOPACKET: { + "schema": PHENOPACKET_SEARCH_SCHEMA, + "metadata_schema": { + "type": "object", # TODO + } + }, + DATA_TYPE_MCODEPACKET: { + "schema": MCODE_SCHEMA, + "metadata_schema": { + "type": "object", # TODO + } + } +} diff --git a/chord_metadata_service/chord/ingest.py b/chord_metadata_service/chord/ingest.py new file mode 100644 index 000000000..eb76c78b4 --- /dev/null +++ b/chord_metadata_service/chord/ingest.py @@ -0,0 +1,427 @@ +import json +import os +import uuid + +from dateutil.parser import isoparse +from typing import Callable + +from chord_metadata_service.chord.data_types import DATA_TYPE_EXPERIMENT, DATA_TYPE_PHENOPACKET, DATA_TYPE_MCODEPACKET +from chord_metadata_service.chord.models import Table, TableOwnership +from chord_metadata_service.experiments import models as em +from chord_metadata_service.phenopackets import models as pm +from chord_metadata_service.resources import models as rm, utils as ru +from chord_metadata_service.restapi.fhir_ingest import ( + ingest_patients, + ingest_observations, + ingest_conditions, + ingest_specimens +) +from chord_metadata_service.mcode.parse_fhir_mcode import parse_bundle +from chord_metadata_service.mcode.mcode_ingest import ingest_mcodepacket + + +__all__ = [ + "METADATA_WORKFLOWS", + "WORKFLOWS_PATH", + "ingest_resource", + "WORKFLOW_INGEST_FUNCTION_MAP", +] + +WORKFLOW_PHENOPACKETS_JSON = "phenopackets_json" +WORKFLOW_EXPERIMENTS_JSON = "experiments_json" +WORKFLOW_FHIR_JSON = "fhir_json" +WORKFLOW_MCODE_FHIR_JSON = "mcode_fhir_json" + +METADATA_WORKFLOWS = { + "ingestion": { + WORKFLOW_PHENOPACKETS_JSON: { + "name": "Bento Phenopackets-Compatible JSON", + "description": "This ingestion workflow will validate and import a Phenopackets schema-compatible " + "JSON document.", + "data_type": DATA_TYPE_PHENOPACKET, + "file": "phenopackets_json.wdl", + "inputs": [ + { + "id": "json_document", + "type": "file", + "required": True, + "extensions": [".json"] + } + ], + "outputs": [ + { + "id": "json_document", + "type": "file", + "value": "{json_document}" + } + ] + }, + WORKFLOW_EXPERIMENTS_JSON: { + "name": "Bento Experiments JSON", + "description": "This ingestion workflow will validate and import a Bento Experiments schema-compatible " + "JSON document.", + "data_type": DATA_TYPE_EXPERIMENT, + "file": "experiments_json.wdl", + "inputs": [ + { + "id": "json_document", + "type": "file", + "required": True, + "extensions": [".json"] + } + ], + "outputs": [ + { + "id": "json_document", + "type": "file", + "value": "{json_document}" + } + ] + }, + WORKFLOW_FHIR_JSON: { + "name": "FHIR Resources JSON", + "description": "This ingestion workflow will validate and import a FHIR schema-compatible " + "JSON document, and convert it to the Bento metadata service's internal Phenopackets-based " + "data model.", + "data_type": DATA_TYPE_PHENOPACKET, + "file": "fhir_json.wdl", + "inputs": [ + { + "id": "patients", + "type": "file", + "required": True, + "extensions": [".json"] + }, + { + "id": "observations", + "type": "file", + "required": False, + "extensions": [".json"] + }, + { + "id": "conditions", + "type": "file", + "required": False, + "extensions": [".json"] + }, + { + "id": "specimens", + "type": "file", + "required": False, + "extensions": [".json"] + }, + { + "id": "created_by", + "required": False, + "type": "string" + }, + + ], + "outputs": [ + { + "id": "patients", + "type": "file", + "value": "{patients}" + }, + { + "id": "observations", + "type": "file", + "value": "{observations}" + }, + { + "id": "conditions", + "type": "file", + "value": "{conditions}" + }, + { + "id": "specimens", + "type": "file", + "value": "{specimens}" + }, + { + "id": "created_by", + "type": "string", + "value": "{created_by}" + }, + + ] + }, + WORKFLOW_MCODE_FHIR_JSON: { + "name": "MCODE FHIR Resources JSON", + "description": "This ingestion workflow will validate and import a mCODE FHIR 4.0. schema-compatible " + "JSON document, and convert it to the Bento metadata service's internal mCODE-based " + "data model.", + "data_type": DATA_TYPE_MCODEPACKET, + "file": "mcode_fhir_json.wdl", + "inputs": [ + { + "id": "json_document", + "type": "file", + "required": True, + "extensions": [".json"] + } + ], + "outputs": [ + { + "id": "json_document", + "type": "file", + "value": "{json_document}" + } + ] + } + }, + "analysis": {} +} + +WORKFLOWS_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "workflows") + + +def create_phenotypic_feature(pf): + pf_obj = pm.PhenotypicFeature( + description=pf.get("description", ""), + pftype=pf["type"], + negated=pf.get("negated", False), + severity=pf.get("severity"), + modifier=pf.get("modifier", []), # TODO: Validate ontology term in schema... + onset=pf.get("onset"), + evidence=pf.get("evidence") # TODO: Separate class? + ) + + pf_obj.save() + return pf_obj + + +def _query_and_check_nulls(obj: dict, key: str, transform: Callable = lambda x: x): + value = obj.get(key) + return {f"{key}__isnull": True} if value is None else {key: transform(value)} + + +def ingest_resource(resource: dict) -> rm.Resource: + namespace_prefix = resource["namespace_prefix"].strip() + version = resource.get("version", "").strip() + assigned_resource_id = ru.make_resource_id(namespace_prefix, version) + + rs_obj, _ = rm.Resource.objects.get_or_create( + # If this doesn't match assigned_resource_id, it'll throw anyway + id=resource.get("id", assigned_resource_id), + name=resource["name"], + namespace_prefix=namespace_prefix, + url=resource["url"], + version=version, + iri_prefix=resource["iri_prefix"] + ) + + return rs_obj + + +def ingest_experiment(experiment_data, table_id) -> em.Experiment: + """Ingests a single experiment.""" + + new_experiment_id = experiment_data.get("id", str(uuid.uuid4())) + + reference_registry_id = experiment_data.get("reference_registry_id") + qc_flags = experiment_data.get("qc_flags", []) + experiment_type = experiment_data["experiment_type"] + experiment_ontology = experiment_data.get("experiment_ontology", []) + molecule_ontology = experiment_data.get("molecule_ontology", []) + molecule = experiment_data.get("molecule") + library_strategy = experiment_data["library_strategy"] + other_fields = experiment_data.get("other_fields", {}) + biosample = experiment_data.get("biosample") + + if biosample is not None: + biosample = pm.Biosample.objects.get(id=biosample) # TODO: Handle error nicer + + new_experiment = em.Experiment.objects.create( + id=new_experiment_id, + reference_registry_id=reference_registry_id, + qc_flags=qc_flags, + experiment_type=experiment_type, + experiment_ontology=experiment_ontology, + molecule_ontology=molecule_ontology, + molecule=molecule, + library_strategy=library_strategy, + other_fields=other_fields, + biosample=biosample, + table=Table.objects.get(ownership_record_id=table_id, data_type=DATA_TYPE_EXPERIMENT) + ) + + return new_experiment + + +def ingest_phenopacket(phenopacket_data, table_id) -> pm.Phenopacket: + """Ingests a single phenopacket.""" + + new_phenopacket_id = phenopacket_data.get("id", str(uuid.uuid4())) + + subject = phenopacket_data.get("subject") + phenotypic_features = phenopacket_data.get("phenotypic_features", []) + biosamples = phenopacket_data.get("biosamples", []) + genes = phenopacket_data.get("genes", []) + diseases = phenopacket_data.get("diseases", []) + hts_files = phenopacket_data.get("hts_files", []) + meta_data = phenopacket_data["meta_data"] + + if subject: + # Be a bit flexible with the subject date_of_birth field for Signature; convert blank strings to None. + subject["date_of_birth"] = subject.get("date_of_birth") or None + subject_query = _query_and_check_nulls(subject, "date_of_birth", transform=isoparse) + for k in ("alternate_ids", "age", "sex", "karyotypic_sex", "taxonomy"): + subject_query.update(_query_and_check_nulls(subject, k)) + subject, _ = pm.Individual.objects.get_or_create(id=subject["id"], **subject_query) + + phenotypic_features_db = [create_phenotypic_feature(pf) for pf in phenotypic_features] + + biosamples_db = [] + for bs in biosamples: + # TODO: This should probably be a JSON field, or compound key with code/body_site + procedure, _ = pm.Procedure.objects.get_or_create(**bs["procedure"]) + + bs_query = _query_and_check_nulls(bs, "individual_id", lambda i: pm.Individual.objects.get(id=i)) + for k in ("sampled_tissue", "taxonomy", "individual_age_at_collection", "histological_diagnosis", + "tumor_progression", "tumor_grade"): + bs_query.update(_query_and_check_nulls(bs, k)) + + bs_obj, bs_created = pm.Biosample.objects.get_or_create( + id=bs["id"], + description=bs.get("description", ""), + procedure=procedure, + is_control_sample=bs.get("is_control_sample", False), + diagnostic_markers=bs.get("diagnostic_markers", []), + **bs_query + ) + + if bs_created: + bs_pfs = [create_phenotypic_feature(pf) for pf in bs.get("phenotypic_features", [])] + bs_obj.phenotypic_features.set(bs_pfs) + + # TODO: Update phenotypic features otherwise? + + biosamples_db.append(bs_obj) + + # TODO: May want to augment alternate_ids + genes_db = [] + for g in genes: + # TODO: Validate CURIE + # TODO: Rename alternate_id + g_obj, _ = pm.Gene.objects.get_or_create( + id=g["id"], + alternate_ids=g.get("alternate_ids", []), + symbol=g["symbol"] + ) + genes_db.append(g_obj) + + diseases_db = [] + for disease in diseases: + # TODO: Primary key, should this be a model? + d_obj, _ = pm.Disease.objects.get_or_create( + term=disease["term"], + disease_stage=disease.get("disease_stage", []), + tnm_finding=disease.get("tnm_finding", []), + **_query_and_check_nulls(disease, "onset") + ) + diseases_db.append(d_obj.id) + + hts_files_db = [] + for htsfile in hts_files: + htsf_obj, _ = pm.HtsFile.objects.get_or_create( + uri=htsfile["uri"], + description=htsfile.get("description", None), + hts_format=htsfile["hts_format"], + genome_assembly=htsfile["genome_assembly"], + individual_to_sample_identifiers=htsfile.get("individual_to_sample_identifiers", None), + extra_properties=htsfile.get("extra_properties", None) + ) + hts_files_db.append(htsf_obj) + + resources_db = [ingest_resource(rs) for rs in meta_data.get("resources", [])] + + meta_data_obj = pm.MetaData( + created_by=meta_data["created_by"], + submitted_by=meta_data.get("submitted_by"), + phenopacket_schema_version="1.0.0-RC3", + external_references=meta_data.get("external_references", []) + ) + meta_data_obj.save() + + meta_data_obj.resources.set(resources_db) + + new_phenopacket = pm.Phenopacket( + id=new_phenopacket_id, + subject=subject, + meta_data=meta_data_obj, + table=Table.objects.get(ownership_record_id=table_id, data_type=DATA_TYPE_PHENOPACKET) + ) + + new_phenopacket.save() + + new_phenopacket.phenotypic_features.set(phenotypic_features_db) + new_phenopacket.biosamples.set(biosamples_db) + new_phenopacket.genes.set(genes_db) + new_phenopacket.diseases.set(diseases_db) + new_phenopacket.hts_files.set(hts_files_db) + + return new_phenopacket + + +def _map_if_list(fn, data, *args): + # TODO: Any sequence? + return [fn(d, *args) for d in data] if isinstance(data, list) else fn(data, *args) + + +def ingest_experiments_workflow(workflow_outputs, table_id): + with open(workflow_outputs["json_document"], "r") as jf: + json_data = json.load(jf) + + dataset = TableOwnership.objects.get(table_id=table_id).dataset + + for rs in json_data.get("resources", []): + dataset.additional_resources.add(ingest_resource(rs)) + + return [ingest_experiment(exp, table_id) for exp in json_data.get("experiments", [])] + + +def ingest_phenopacket_workflow(workflow_outputs, table_id): + with open(workflow_outputs["json_document"], "r") as jf: + json_data = json.load(jf) + return _map_if_list(ingest_phenopacket, json_data, table_id) + + +def ingest_fhir_workflow(workflow_outputs, table_id): + with open(workflow_outputs["patients"], "r") as pf: + patients_data = json.load(pf) + phenopacket_ids = ingest_patients( + patients_data, + table_id, + workflow_outputs.get("created_by") or "Imported from file.", + ) + + if "observations" in workflow_outputs: + with open(workflow_outputs["observations"], "r") as of: + observations_data = json.load(of) + ingest_observations(phenopacket_ids, observations_data) + + if "conditions" in workflow_outputs: + with open(workflow_outputs["conditions"], "r") as cf: + conditions_data = json.load(cf) + ingest_conditions(phenopacket_ids, conditions_data) + + if "specimens" in workflow_outputs: + with open(workflow_outputs["specimens"], "r") as sf: + specimens_data = json.load(sf) + ingest_specimens(phenopacket_ids, specimens_data) + + +def ingest_mcode_fhir_workflow(workflow_outputs, table_id): + with open(workflow_outputs["json_document"], "r") as jf: + json_data = json.load(jf) + mcodepacket = parse_bundle(json_data) + ingest_mcodepacket(mcodepacket, table_id) + + +WORKFLOW_INGEST_FUNCTION_MAP = { + WORKFLOW_EXPERIMENTS_JSON: ingest_experiments_workflow, + WORKFLOW_PHENOPACKETS_JSON: ingest_phenopacket_workflow, + WORKFLOW_FHIR_JSON: ingest_fhir_workflow, + WORKFLOW_MCODE_FHIR_JSON: ingest_mcode_fhir_workflow, +} diff --git a/chord_metadata_service/chord/migrations/0001_initial.py b/chord_metadata_service/chord/migrations/0001_initial.py deleted file mode 100644 index fadb78c94..000000000 --- a/chord_metadata_service/chord/migrations/0001_initial.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 2.2.8 on 2019-12-10 21:04 - -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Dataset', - fields=[ - ('identifier', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('title', models.CharField(max_length=200, unique=True)), - ('description', models.TextField(blank=True)), - ('data_use', django.contrib.postgres.fields.jsonb.JSONField()), - ('alternate_identifiers', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='Alternate identifiers for the dataset.', null=True, size=None)), - ('related_identifiers', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='Related identifiers for the dataset.', null=True, size=None)), - ('dates', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='Relevant dates for the datasets, a date must be added, e.g. creation date or last modification date should be added.', null=True, size=None)), - ('stored_in', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The data repository hosting the dataset.', null=True)), - ('spatial_coverage', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The geographical extension and span covered by the dataset and its measured dimensions/variables.', null=True, size=None)), - ('types', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A term, ideally from a controlled terminology, identifying the dataset type or nature of the data, placing it in a typology.', null=True, size=None)), - ('availability', models.CharField(blank=True, help_text='A qualifier indicating the different types of availability for a dataset (available, unavailable, embargoed, available with restriction, information not available).', max_length=200)), - ('refinement', models.CharField(blank=True, help_text='A qualifier to describe the level of data processing of the dataset and its distributions.', max_length=200)), - ('aggregation', models.CharField(blank=True, help_text="A qualifier indicating if the entity represents an 'instance of dataset' or a 'collection of datasets'.", max_length=200)), - ('privacy', models.CharField(blank=True, help_text='A qualifier to describe the data protection applied to the dataset. This is relevant for clinical data.', max_length=200)), - ('distributions', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The distribution(s) by which datasets are made available (for example: mySQL dump).', null=True, size=None)), - ('dimensions', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The different dimensions (granular components) making up a dataset.', null=True, size=None)), - ('primary_publications', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The primary publication(s) associated with the dataset, usually describing how the dataset was produced.', null=True, size=None)), - ('citations', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The publication(s) that cite this dataset.', null=True, size=None)), - ('citation_count', models.IntegerField(blank=True, help_text='The number of publications that cite this dataset (enumerated in the citations property).', null=True)), - ('produced_by', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A study process which generated a given dataset, if any.', null=True)), - ('creators', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The person(s) or organization(s) which contributed to the creation of the dataset.', null=True, size=None)), - ('licenses', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The terms of use of the dataset.', null=True, size=None)), - ('acknowledges', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='The grant(s) which funded and supported the work reported by the dataset.', null=True, size=None)), - ('keywords', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='Tags associated with the dataset, which will help in its discovery.', null=True, size=None)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that do not fit in the previous specified attributes.', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Project', - fields=[ - ('identifier', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('title', models.CharField(max_length=200, unique=True)), - ('description', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='TableOwnership', - fields=[ - ('table_id', models.CharField(max_length=200, primary_key=True, serialize=False)), - ('service_id', models.UUIDField()), - ('service_artifact', models.CharField(default='', max_length=200)), - ('data_type', models.CharField(max_length=200)), - ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_ownerships', to='chord.Dataset')), - ], - ), - ] diff --git a/chord_metadata_service/chord/migrations/0001_v1_0_0.py b/chord_metadata_service/chord/migrations/0001_v1_0_0.py new file mode 100644 index 000000000..c416d9aac --- /dev/null +++ b/chord_metadata_service/chord/migrations/0001_v1_0_0.py @@ -0,0 +1,90 @@ +# Generated by Django 2.2.13 on 2020-07-06 14:55 + +import chord_metadata_service.chord.models +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('resources', '0001_v1_0_0'), + ] + + operations = [ + migrations.CreateModel( + name='Dataset', + fields=[ + ('identifier', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=200, unique=True)), + ('description', models.TextField(blank=True)), + ('contact_info', models.TextField(blank=True)), + ('data_use', django.contrib.postgres.fields.jsonb.JSONField()), + ('linked_field_sets', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Data type fields which are linked together.')), + ('alternate_identifiers', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Alternate identifiers for the dataset.')), + ('related_identifiers', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Related identifiers for the dataset.')), + ('dates', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Relevant dates for the datasets, a date must be added, e.g. creation date or last modification date should be added.')), + ('stored_in', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The data repository hosting the dataset.', null=True)), + ('spatial_coverage', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The geographical extension and span covered by the dataset and its measured dimensions/variables.')), + ('types', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='A term, ideally from a controlled terminology, identifying the dataset type or nature of the data, placing it in a typology.')), + ('availability', models.CharField(blank=True, help_text='A qualifier indicating the different types of availability for a dataset (available, unavailable, embargoed, available with restriction, information not available).', max_length=200)), + ('refinement', models.CharField(blank=True, help_text='A qualifier to describe the level of data processing of the dataset and its distributions.', max_length=200)), + ('aggregation', models.CharField(blank=True, help_text="A qualifier indicating if the entity represents an 'instance of dataset' or a 'collection of datasets'.", max_length=200)), + ('privacy', models.CharField(blank=True, help_text='A qualifier to describe the data protection applied to the dataset. This is relevant for clinical data.', max_length=200)), + ('distributions', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The distribution(s) by which datasets are made available (for example: mySQL dump).')), + ('dimensions', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The different dimensions (granular components) making up a dataset.')), + ('primary_publications', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The primary publication(s) associated with the dataset, usually describing how the dataset was produced.')), + ('citations', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The publication(s) that cite this dataset.')), + ('citation_count', models.IntegerField(blank=True, help_text='The number of publications that cite this dataset (enumerated in the citations property).', null=True)), + ('produced_by', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A study process which generated a given dataset, if any.', null=True)), + ('creators', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The person(s) or organization(s) which contributed to the creation of the dataset.')), + ('licenses', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The terms of use of the dataset.')), + ('acknowledges', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='The grant(s) which funded and supported the work reported by the dataset.')), + ('keywords', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Tags associated with the dataset, which will help in its discovery.')), + ('version', models.CharField(blank=True, default=chord_metadata_service.chord.models.version_default, help_text='A release point for the dataset when applicable.', max_length=200)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that do not fit in the previous specified attributes.', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('additional_resources', models.ManyToManyField(blank=True, help_text="Any resource objects linked to this dataset that aren't specified by a phenopacket in the dataset.", to='resources.Resource')), + ('has_part', models.ManyToManyField(blank=True, help_text="A Dataset that is a subset of this Dataset; Datasets declaring the 'hasPart' relationship are considered a collection of Datasets, the aggregation criteria could be included in the 'description' field.", related_name='_dataset_has_part_+', to='chord.Dataset')), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('identifier', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=200, unique=True)), + ('description', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='TableOwnership', + fields=[ + ('table_id', models.CharField(max_length=200, primary_key=True, serialize=False)), + ('service_id', models.CharField(max_length=200)), + ('service_artifact', models.CharField(default='', max_length=200)), + ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_ownership', to='chord.Dataset')), + ], + ), + migrations.CreateModel( + name='Table', + fields=[ + ('ownership_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='chord.TableOwnership')), + ('name', models.CharField(max_length=200, unique=True)), + ('data_type', models.CharField(choices=[('experiment', 'experiment'), ('phenopacket', 'phenopacket')], max_length=30)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddField( + model_name='dataset', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='datasets', to='chord.Project'), + ), + ] diff --git a/chord_metadata_service/chord/migrations/0002_auto_20191210_2104.py b/chord_metadata_service/chord/migrations/0002_auto_20191210_2104.py deleted file mode 100644 index 0476f6649..000000000 --- a/chord_metadata_service/chord/migrations/0002_auto_20191210_2104.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 2.2.8 on 2019-12-10 21:04 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('phenopackets', '0001_initial'), - ('chord', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='tableownership', - name='sample', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Biosample'), - ), - migrations.AddField( - model_name='dataset', - name='has_part', - field=models.ManyToManyField(blank=True, help_text="A Dataset that is a subset of this Dataset; Datasets declaring the 'hasPart' relationship are considered a collection of Datasets, the aggregation criteria could be included in the 'description' field.", related_name='_dataset_has_part_+', to='chord.Dataset'), - ), - migrations.AddField( - model_name='dataset', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='datasets', to='chord.Project'), - ), - ] diff --git a/chord_metadata_service/chord/migrations/0003_dataset_field_links.py b/chord_metadata_service/chord/migrations/0003_dataset_field_links.py deleted file mode 100644 index c1812a844..000000000 --- a/chord_metadata_service/chord/migrations/0003_dataset_field_links.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-22 20:38 - -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0002_auto_20191210_2104'), - ] - - operations = [ - migrations.AddField( - model_name='dataset', - name='field_links', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(), blank=True, default=list, help_text='Data type fields which are linked together.', size=None), - ), - ] diff --git a/chord_metadata_service/chord/migrations/0004_auto_20200123_2126.py b/chord_metadata_service/chord/migrations/0004_auto_20200123_2126.py deleted file mode 100644 index c65aa67b6..000000000 --- a/chord_metadata_service/chord/migrations/0004_auto_20200123_2126.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-23 21:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0003_dataset_field_links'), - ] - - operations = [ - migrations.RenameField( - model_name='dataset', - old_name='field_links', - new_name='field_link_sets', - ), - ] diff --git a/chord_metadata_service/chord/migrations/0005_auto_20200123_2144.py b/chord_metadata_service/chord/migrations/0005_auto_20200123_2144.py deleted file mode 100644 index dd86c9448..000000000 --- a/chord_metadata_service/chord/migrations/0005_auto_20200123_2144.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-23 21:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0004_auto_20200123_2126'), - ] - - operations = [ - migrations.RenameField( - model_name='dataset', - old_name='field_link_sets', - new_name='linked_field_sets', - ), - ] diff --git a/chord_metadata_service/chord/migrations/0006_dataset_contact_info.py b/chord_metadata_service/chord/migrations/0006_dataset_contact_info.py deleted file mode 100644 index dfd86b8f1..000000000 --- a/chord_metadata_service/chord/migrations/0006_dataset_contact_info.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-28 21:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0005_auto_20200123_2144'), - ] - - operations = [ - migrations.AddField( - model_name='dataset', - name='contact_info', - field=models.TextField(blank=True), - ), - ] diff --git a/chord_metadata_service/chord/migrations/0007_auto_20200129_1537.py b/chord_metadata_service/chord/migrations/0007_auto_20200129_1537.py deleted file mode 100644 index df19acc07..000000000 --- a/chord_metadata_service/chord/migrations/0007_auto_20200129_1537.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-29 15:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0006_dataset_contact_info'), - ] - - operations = [ - migrations.AlterField( - model_name='dataset', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='dataset', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='project', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='project', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/chord_metadata_service/chord/migrations/0008_dataset_version.py b/chord_metadata_service/chord/migrations/0008_dataset_version.py deleted file mode 100644 index b8897e133..000000000 --- a/chord_metadata_service/chord/migrations/0008_dataset_version.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.9 on 2020-02-17 21:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0007_auto_20200129_1537'), - ] - - operations = [ - migrations.AddField( - model_name='dataset', - name='version', - field=models.CharField(blank=True, default='version_2020-02-17 16:47:59.425036', help_text='A release point for the dataset when applicable.', max_length=200), - ), - ] diff --git a/chord_metadata_service/chord/migrations/0009_auto_20200218_1615.py b/chord_metadata_service/chord/migrations/0009_auto_20200218_1615.py deleted file mode 100644 index 17aec5acf..000000000 --- a/chord_metadata_service/chord/migrations/0009_auto_20200218_1615.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.9 on 2020-02-18 21:15 - -import chord_metadata_service.chord.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0008_dataset_version'), - ] - - operations = [ - migrations.AlterField( - model_name='dataset', - name='version', - field=models.CharField(blank=True, default=chord_metadata_service.chord.models.version_default, help_text='A release point for the dataset when applicable.', max_length=200), - ), - ] diff --git a/chord_metadata_service/chord/migrations/0010_auto_20200309_1945.py b/chord_metadata_service/chord/migrations/0010_auto_20200309_1945.py deleted file mode 100644 index 5292a5273..000000000 --- a/chord_metadata_service/chord/migrations/0010_auto_20200309_1945.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.11 on 2020-03-09 19:45 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('chord', '0009_auto_20200218_1615'), - ] - - operations = [ - migrations.AlterField( - model_name='tableownership', - name='dataset', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_ownership', to='chord.Dataset'), - ), - ] diff --git a/chord_metadata_service/chord/models.py b/chord_metadata_service/chord/models.py index 59110e052..392838b78 100644 --- a/chord_metadata_service/chord/models.py +++ b/chord_metadata_service/chord/models.py @@ -1,11 +1,17 @@ +import collections import uuid -from django.contrib.postgres.fields import JSONField, ArrayField +from django.core.exceptions import ValidationError +from django.contrib.postgres.fields import JSONField from django.db import models from django.utils import timezone +from chord_metadata_service.phenopackets.models import Phenopacket +from chord_metadata_service.resources.models import Resource +from .data_types import DATA_TYPE_EXPERIMENT, DATA_TYPE_PHENOPACKET -__all__ = ["Project", "Dataset", "TableOwnership"] + +__all__ = ["Project", "Dataset", "TableOwnership", "Table"] def version_default(): @@ -51,32 +57,44 @@ class Dataset(models.Model): ) data_use = JSONField() + linked_field_sets = JSONField(blank=True, default=list, help_text="Data type fields which are linked together.") + + additional_resources = models.ManyToManyField(Resource, blank=True, help_text="Any resource objects linked to this " + "dataset that aren't specified by a " + "phenopacket in the dataset.") - linked_field_sets = ArrayField(JSONField(), blank=True, default=list, - help_text="Data type fields which are linked together.") + @property + def resources(self): + # Union of phenopacket resources and any additional resources for other table types + return Resource.objects.filter(id__in={ + *(r.id for r in self.additional_resources.all()), + *( + r.id + for p in Phenopacket.objects.filter( + table_id__in={t.table_id for t in self.table_ownership.all()} + ).prefetch_related("meta_data", "meta_data__resources") + for r in p.meta_data.resources.all() + ), + }) @property def n_of_tables(self): - # TODO: No hard-code: +1 for phenopackets table - return TableOwnership.objects.filter(dataset=self).count() + 1 + return TableOwnership.objects.filter(dataset=self).count() # --------------------------- DATS model fields --------------------------- - alternate_identifiers = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="Alternate identifiers for the dataset.") - related_identifiers = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="Related identifiers for the dataset.") - dates = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="Relevant dates for the datasets, a date must be added, e.g. creation date or last " - "modification date should be added.") + alternate_identifiers = JSONField(blank=True, default=list, help_text="Alternate identifiers for the dataset.") + related_identifiers = JSONField(blank=True, default=list, help_text="Related identifiers for the dataset.") + dates = JSONField(blank=True, default=list, help_text="Relevant dates for the datasets, a date must be added, e.g. " + "creation date or last modification date should be added.") # TODO: Can this be auto-synthesized? (Specified in settings) stored_in = JSONField(blank=True, null=True, help_text="The data repository hosting the dataset.") - spatial_coverage = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The geographical extension and span covered by the dataset and its " - "measured dimensions/variables.") - types = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="A term, ideally from a controlled terminology, identifying the dataset type or " - "nature of the data, placing it in a typology.") + spatial_coverage = JSONField(blank=True, default=list, help_text="The geographical extension and span covered " + "by the dataset and its measured " + "dimensions/variables.") + types = JSONField(blank=True, default=list, help_text="A term, ideally from a controlled terminology, identifying " + "the dataset type or nature of the data, placing it in a " + "typology.") # TODO: Can this be derived from / combined with DUO stuff? availability = models.CharField(max_length=200, blank=True, help_text="A qualifier indicating the different types of availability for a " @@ -91,46 +109,54 @@ def n_of_tables(self): privacy = models.CharField(max_length=200, blank=True, help_text="A qualifier to describe the data protection applied to the dataset. This is " "relevant for clinical data.") - distributions = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The distribution(s) by which datasets are made available (for example: " - "mySQL dump).") - dimensions = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The different dimensions (granular components) making up a dataset.") - primary_publications = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The primary publication(s) associated with the dataset, usually " - "describing how the dataset was produced.") - citations = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The publication(s) that cite this dataset.") + distributions = JSONField(blank=True, default=list, help_text="The distribution(s) by which datasets are made " + "available (for example: mySQL dump).") + dimensions = JSONField(blank=True, default=list, help_text="The different dimensions (granular components) " + "making up a dataset.") + primary_publications = JSONField(blank=True, default=list, help_text="The primary publication(s) associated with " + "the dataset, usually describing how the " + "dataset was produced.") + citations = JSONField(blank=True, default=list, help_text="The publication(s) that cite this dataset.") citation_count = models.IntegerField(blank=True, null=True, help_text="The number of publications that cite this dataset (enumerated in " "the citations property).") produced_by = JSONField(blank=True, null=True, help_text="A study process which generated a given dataset, if any.") - creators = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The person(s) or organization(s) which contributed to the creation of the " - "dataset.") + creators = JSONField(blank=True, default=list, help_text="The person(s) or organization(s) which contributed to " + "the creation of the dataset.") # TODO: How to reconcile this and data_use? - licenses = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The terms of use of the dataset.") + licenses = JSONField(blank=True, default=list, help_text="The terms of use of the dataset.") # is_about this field will be calculated based on sample field # in tableOwnership - has_part = models.ManyToManyField("self", blank=True, - help_text="A Dataset that is a subset of this Dataset; Datasets declaring the " - "'hasPart' relationship are considered a collection of Datasets, the " - "aggregation criteria could be included in the 'description' field.") - acknowledges = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="The grant(s) which funded and supported the work reported by the dataset.") - keywords = ArrayField(JSONField(null=True, blank=True), blank=True, null=True, - help_text="Tags associated with the dataset, which will help in its discovery.") + has_part = models.ManyToManyField("self", blank=True, help_text="A Dataset that is a subset of this Dataset; " + "Datasets declaring the 'hasPart' relationship are " + "considered a collection of Datasets, the " + "aggregation criteria could be included in " + "the 'description' field.") + acknowledges = JSONField(blank=True, default=list, help_text="The grant(s) which funded and supported the work " + "reported by the dataset.") + keywords = JSONField(blank=True, default=list, help_text="Tags associated with the dataset, which will help in " + "its discovery.") version = models.CharField(max_length=200, blank=True, default=version_default, help_text="A release point for the dataset when applicable.") - extra_properties = JSONField(blank=True, null=True, - help_text="Extra properties that do not fit in the previous specified attributes.") + extra_properties = JSONField(blank=True, null=True, help_text="Extra properties that do not fit in the previous " + "specified attributes.") # ------------------------------------------------------------------------- created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + def clean(self): + # Check that all namespace prefices are unique within a dataset + c = collections.Counter(r.namespace_prefix for r in self.resources) + mc = (*c.most_common(1), (None, 0))[0] + if mc[1] > 1: + raise ValidationError(f"Dataset {self.identifier} cannot have ambiguous resource namespace prefix {mc[0]}") + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + def __str__(self): return f"{self.title} (ID: {self.identifier})" @@ -142,14 +168,33 @@ class TableOwnership(models.Model): """ table_id = models.CharField(primary_key=True, max_length=200) - service_id = models.UUIDField(max_length=200) + service_id = models.CharField(max_length=200) service_artifact = models.CharField(max_length=200, default="") - data_type = models.CharField(max_length=200) # TODO: Is this needed? # Delete table ownership upon project/dataset deletion dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name='table_ownership') - # If not specified, compound table which may link to many samples TODO: ??? - sample = models.ForeignKey("phenopackets.Biosample", on_delete=models.CASCADE, blank=True, null=True) def __str__(self): - return f"{self.dataset if not self.sample else self.sample} -> {self.table_id}" + return f"{self.dataset} -> {self.table_id}" + + +class Table(models.Model): + TABLE_DATA_TYPE_CHOICES = ( + (DATA_TYPE_EXPERIMENT, DATA_TYPE_EXPERIMENT), + (DATA_TYPE_PHENOPACKET, DATA_TYPE_PHENOPACKET), + ) + + ownership_record = models.OneToOneField(TableOwnership, on_delete=models.CASCADE, primary_key=True) + name = models.CharField(max_length=200, unique=True) + data_type = models.CharField(max_length=30, choices=TABLE_DATA_TYPE_CHOICES) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + @property + def identifier(self): + return self.ownership_record_id + + @property + def dataset(self): + return self.ownership_record.dataset diff --git a/chord_metadata_service/chord/permissions.py b/chord_metadata_service/chord/permissions.py index 442746306..ca4a24324 100644 --- a/chord_metadata_service/chord/permissions.py +++ b/chord_metadata_service/chord/permissions.py @@ -1,5 +1,16 @@ from django.conf import settings -from rest_framework.permissions import BasePermission +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +__all__ = [ + "ReadOnly", + "OverrideOrSuperUserOnly", +] + + +class ReadOnly(BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS class OverrideOrSuperUserOnly(BasePermission): diff --git a/chord_metadata_service/chord/serializers.py b/chord_metadata_service/chord/serializers.py index 492583a79..54b61737b 100644 --- a/chord_metadata_service/chord/serializers.py +++ b/chord_metadata_service/chord/serializers.py @@ -1,15 +1,19 @@ -from chord_lib.schemas.chord import CHORD_DATA_USE_SCHEMA +from bento_lib.schemas.bento import BENTO_DATA_USE_SCHEMA from chord_metadata_service.restapi.serializers import GenericSerializer from jsonschema import Draft7Validator, Draft4Validator from rest_framework import serializers from chord_metadata_service.restapi.dats_schemas import get_dats_schema, CREATORS from chord_metadata_service.restapi.utils import transform_keys -from .models import * +from .models import Project, Dataset, TableOwnership, Table from .schemas import LINKED_FIELD_SETS_SCHEMA -__all__ = ["ProjectSerializer", "DatasetSerializer", "TableOwnershipSerializer"] +__all__ = ["ProjectSerializer", "DatasetSerializer", "TableOwnershipSerializer", "TableSerializer"] + + +BENTO_DATA_USE_SCHEMA_VALIDATOR = Draft7Validator(BENTO_DATA_USE_SCHEMA) +LINKED_FIELD_SETS_SCHEMA_VALIDATOR = Draft7Validator(LINKED_FIELD_SETS_SCHEMA) ############################################################# @@ -51,17 +55,17 @@ def validate_creators(self, value): # noinspection PyMethodMayBeStatic def validate_data_use(self, value): - validation = Draft7Validator(CHORD_DATA_USE_SCHEMA).is_valid(value) + validation = BENTO_DATA_USE_SCHEMA_VALIDATOR.is_valid(value) if not validation: raise serializers.ValidationError("Data use is not valid") return value # noinspection PyMethodMayBeStatic def validate_linked_field_sets(self, value): - v = Draft7Validator(LINKED_FIELD_SETS_SCHEMA) - validation = v.is_valid(value) + validation = LINKED_FIELD_SETS_SCHEMA_VALIDATOR.is_valid(value) if not validation: - raise serializers.ValidationError([str(error.message) for error in v.iter_errors(value)]) + raise serializers.ValidationError([ + str(error.message) for error in LINKED_FIELD_SETS_SCHEMA_VALIDATOR.iter_errors(value)]) return value def validate(self, data): @@ -149,3 +153,12 @@ def validate_title(self, value): class Meta: model = Project fields = '__all__' + + +class TableSerializer(GenericSerializer): + identifier = serializers.CharField(read_only=True) + dataset = DatasetSerializer(read_only=True, exclude_when_nested=["table_ownership"]) + + class Meta: + model = Table + fields = "__all__" diff --git a/chord_metadata_service/chord/tests/constants.py b/chord_metadata_service/chord/tests/constants.py index 957e2af8c..30c9fd6d6 100644 --- a/chord_metadata_service/chord/tests/constants.py +++ b/chord_metadata_service/chord/tests/constants.py @@ -1,9 +1,14 @@ +import uuid + +from ..data_types import DATA_TYPE_PHENOPACKET + __all__ = [ "VALID_DATA_USE_1", "VALID_PROJECT_1", "VALID_DATS_CREATORS", "INVALID_DATS_CREATORS", "valid_dataset_1", + "valid_table_1", "dats_dataset", "TEST_SEARCH_QUERY_1", "TEST_SEARCH_QUERY_2", @@ -73,6 +78,24 @@ def valid_dataset_1(project_id): } +def valid_table_1(dataset_id, model_compatible=False): + table_id = str(uuid.uuid4()) + service_id = str(uuid.uuid4()) # TODO: Real service ID + return ( + { + "table_id": table_id, + "service_id": service_id, + "service_artifact": "metadata", + ("dataset_id" if model_compatible else "dataset"): dataset_id, + }, + { + ("ownership_record_id" if model_compatible else "ownership_record"): table_id, + "name": "Table 1", + "data_type": DATA_TYPE_PHENOPACKET, + } + ) + + def dats_dataset(project_id, creators): return { "version": "1.0", diff --git a/chord_metadata_service/chord/tests/example_ingest.py b/chord_metadata_service/chord/tests/example_ingest.py index 16d56a4b2..4c234ac47 100644 --- a/chord_metadata_service/chord/tests/example_ingest.py +++ b/chord_metadata_service/chord/tests/example_ingest.py @@ -1,234 +1,12 @@ -EXAMPLE_INGEST = { - "subject": { - "id": "patient1", - "date_of_birth": "1964-03-15T00:00:00Z", - "sex": "MALE", - "karyotypic_sex": "UNKNOWN_KARYOTYPE" - }, - "phenotypic_features": [ - { - "description": "", - "type": { - "id": "HP:0000790", - "label": "Hematuria" - }, - "negated": False, - "modifier": [], - "evidence": [] - }, - { - "description": "", - "type": { - "id": "HP:0100518", - "label": "Dysuria" - }, - "negated": False, - "severity": { - "id": "HP:0012828", - "label": "Severe" - }, - "modifier": [], - "evidence": [] - } - ], - "diseases": [ - { - "term": { - "id": "NCIT:C39853", - "label": "Infiltrating Urothelial Carcinoma" - }, - "disease_stage": [ - { - "id": "NCIT:C48766", - "label": "pT2b Stage Finding" - }, - { - "id": "NCIT:C48750", - "label": "pN2 Stage Finding" - } - ] - } - ], - "meta_data": { - "created": "2019-04-03T15:31:40.765Z", - "created_by": "Peter R", - "submitted_by": "Peter R", - "resources": [ - { - "id": "hp", - "name": "human phenotype ontology", - "namespace_prefix": "HP", - "url": "http://purl.obolibrary.org/obo/hp.owl", - "version": "2019-04-08", - "iri_prefix": "http://purl.obolibrary.org/obo/HP_" - }, - { - "id": "uberon", - "name": "uber anatomy ontology", - "namespace_prefix": "UBERON", - "url": "http://purl.obolibrary.org/obo/uberon.owl", - "version": "2019-03-08", - "iri_prefix": "http://purl.obolibrary.org/obo/UBERON_" - }, - { - "id": "ncit", - "name": "NCI Thesaurus OBO Edition", - "namespace_prefix": "NCIT", - "url": "http://purl.obolibrary.org/obo/ncit.owl", - "version": "18.05d", - "iri_prefix": "http://purl.obolibrary.org/obo/NCIT_" - } - ], - "updated": [], - "external_references": [ - { - "id": "PMID:29221636", - "description": "Urothelial neoplasms in pediatric and young adult patients: A large single-center " - "series" - } - ] - }, - "biosamples": [ - { - "id": "sample1", - "individual_id": "patient1", - "description": "", - "sampled_tissue": { - "id": "UBERON_0001256", - "label": "wall of urinary bladder" - }, - "phenotypic_features": [], - "individual_age_at_collection": { - "age": "P52Y2M" - }, - "histological_diagnosis": { - "id": "NCIT:C39853", - "label": "Infiltrating Urothelial Carcinoma" - }, - "tumor_progression": { - "id": "NCIT:C84509", - "label": "Primary Malignant Neoplasm" - }, - "diagnostic_markers": [], - "procedure": { - "code": { - "id": "NCIT:C5189", - "label": "Radical Cystoprostatectomy" - } - }, - "is_control_sample": False - }, - { - "id": "sample2", - "individual_id": "patient1", - "description": "", - "sampled_tissue": { - "id": "UBERON:0002367", - "label": "prostate gland" - }, - "phenotypic_features": [], - "individual_age_at_collection": { - "age": "P52Y2M" - }, - "histological_diagnosis": { - "id": "NCIT:C5596", - "label": "Prostate Acinar Adenocarcinoma" - }, - "tumor_progression": { - "id": "NCIT:C95606", - "label": "Second Primary Malignant Neoplasm" - }, - "tumor_grade": { - "id": "NCIT:C28091", - "label": "Gleason Score 7" - }, - "disease_stage": [], - "diagnostic_markers": [], - "procedure": { - "code": { - "id": "NCIT:C15189", - "label": "Biopsy" - } - }, - "is_control_sample": False - }, - { - "id": "sample3", - "individual_id": "patient1", - "description": "", - "sampled_tissue": { - "id": "UBERON:0001223", - "label": "left ureter" - }, - "phenotypic_features": [], - "individual_age_at_collection": { - "age": "P52Y2M" - }, - "histological_diagnosis": { - "id": "NCIT:C38757", - "label": "Negative Finding" - }, - "disease_stage": [], - "diagnostic_markers": [], - "procedure": { - "code": { - "id": "NCIT:C15189", - "label": "Biopsy" - } - }, - "is_control_sample": False - }, - { - "id": "sample4", - "individual_id": "patient1", - "description": "", - "sampled_tissue": { - "id": "UBERON:0001222", - "label": "right ureter" - }, - "phenotypic_features": [], - "individual_age_at_collection": { - "age": "P52Y2M" - }, - "histological_diagnosis": { - "id": "NCIT:C38757", - "label": "Negative Finding" - }, - "disease_stage": [], - "diagnostic_markers": [], - "procedure": { - "code": { - "id": "NCIT:C15189", - "label": "Biopsy" - } - }, - "is_control_sample": False - }, - { - "id": "sample5", - "individual_id": "patient1", - "description": "", - "sampled_tissue": { - "id": "UBERON:0015876", - "label": "pelvic lymph node" - }, - "phenotypic_features": [], - "individual_age_at_collection": { - "age": "P52Y2M" - }, - "tumor_progression": { - "id": "NCIT:C3261", - "label": "Metastatic Neoplasm" - }, - "disease_stage": [], - "diagnostic_markers": [], - "procedure": { - "code": { - "id": "NCIT:C15189", - "label": "Biopsy" - } - }, - "is_control_sample": False - } - ] +import json +import os + + +__all__ = ["EXAMPLE_INGEST_PHENOPACKET", "EXAMPLE_INGEST_OUTPUTS"] + +with open(os.path.join(os.path.dirname(__file__), "example_phenopacket.json"), "r") as pf: + EXAMPLE_INGEST_PHENOPACKET = json.load(pf) + +EXAMPLE_INGEST_OUTPUTS = { + "json_document": os.path.join(os.path.dirname(__file__), "example_phenopacket.json"), } diff --git a/chord_metadata_service/chord/tests/example_phenopacket.json b/chord_metadata_service/chord/tests/example_phenopacket.json new file mode 100644 index 000000000..10d1d8f87 --- /dev/null +++ b/chord_metadata_service/chord/tests/example_phenopacket.json @@ -0,0 +1,233 @@ +{ + "subject": { + "id": "patient1", + "date_of_birth": "1964-03-15T00:00:00Z", + "sex": "MALE", + "karyotypic_sex": "UNKNOWN_KARYOTYPE" + }, + "phenotypic_features": [ + { + "description": "", + "type": { + "id": "HP:0000790", + "label": "Hematuria" + }, + "negated": false, + "modifier": [], + "evidence": [] + }, + { + "description": "", + "type": { + "id": "HP:0100518", + "label": "Dysuria" + }, + "negated": false, + "severity": { + "id": "HP:0012828", + "label": "Severe" + }, + "modifier": [], + "evidence": [] + } + ], + "diseases": [ + { + "term": { + "id": "NCIT:C39853", + "label": "Infiltrating Urothelial Carcinoma" + }, + "disease_stage": [ + { + "id": "NCIT:C48766", + "label": "pT2b Stage Finding" + }, + { + "id": "NCIT:C48750", + "label": "pN2 Stage Finding" + } + ] + } + ], + "meta_data": { + "created": "2019-04-03T15:31:40.765Z", + "created_by": "Peter R", + "submitted_by": "Peter R", + "resources": [ + { + "id": "HP:2019-04-08", + "name": "human phenotype ontology", + "namespace_prefix": "HP", + "url": "http://purl.obolibrary.org/obo/hp.owl", + "version": "2019-04-08", + "iri_prefix": "http://purl.obolibrary.org/obo/HP_" + }, + { + "id": "UBERON:2019-03-08", + "name": "uber anatomy ontology", + "namespace_prefix": "UBERON", + "url": "http://purl.obolibrary.org/obo/uberon.owl", + "version": "2019-03-08", + "iri_prefix": "http://purl.obolibrary.org/obo/UBERON_" + }, + { + "id": "NCIT:18.05d", + "name": "NCI Thesaurus OBO Edition", + "namespace_prefix": "NCIT", + "url": "http://purl.obolibrary.org/obo/ncit.owl", + "version": "18.05d", + "iri_prefix": "http://purl.obolibrary.org/obo/NCIT_" + } + ], + "updated": [], + "external_references": [ + { + "id": "PMID:29221636", + "description": "Urothelial neoplasms in pediatric and young adult patients: A large single-center series" + } + ] + }, + "biosamples": [ + { + "id": "sample1", + "individual_id": "patient1", + "description": "", + "sampled_tissue": { + "id": "UBERON_0001256", + "label": "wall of urinary bladder" + }, + "phenotypic_features": [], + "individual_age_at_collection": { + "age": "P52Y2M" + }, + "histological_diagnosis": { + "id": "NCIT:C39853", + "label": "Infiltrating Urothelial Carcinoma" + }, + "tumor_progression": { + "id": "NCIT:C84509", + "label": "Primary Malignant Neoplasm" + }, + "diagnostic_markers": [], + "procedure": { + "code": { + "id": "NCIT:C5189", + "label": "Radical Cystoprostatectomy" + } + }, + "is_control_sample": false + }, + { + "id": "sample2", + "individual_id": "patient1", + "description": "", + "sampled_tissue": { + "id": "UBERON:0002367", + "label": "prostate gland" + }, + "phenotypic_features": [], + "individual_age_at_collection": { + "age": "P52Y2M" + }, + "histological_diagnosis": { + "id": "NCIT:C5596", + "label": "Prostate Acinar Adenocarcinoma" + }, + "tumor_progression": { + "id": "NCIT:C95606", + "label": "Second Primary Malignant Neoplasm" + }, + "tumor_grade": { + "id": "NCIT:C28091", + "label": "Gleason Score 7" + }, + "disease_stage": [], + "diagnostic_markers": [], + "procedure": { + "code": { + "id": "NCIT:C15189", + "label": "Biopsy" + } + }, + "is_control_sample": false + }, + { + "id": "sample3", + "individual_id": "patient1", + "description": "", + "sampled_tissue": { + "id": "UBERON:0001223", + "label": "left ureter" + }, + "phenotypic_features": [], + "individual_age_at_collection": { + "age": "P52Y2M" + }, + "histological_diagnosis": { + "id": "NCIT:C38757", + "label": "Negative Finding" + }, + "disease_stage": [], + "diagnostic_markers": [], + "procedure": { + "code": { + "id": "NCIT:C15189", + "label": "Biopsy" + } + }, + "is_control_sample": false + }, + { + "id": "sample4", + "individual_id": "patient1", + "description": "", + "sampled_tissue": { + "id": "UBERON:0001222", + "label": "right ureter" + }, + "phenotypic_features": [], + "individual_age_at_collection": { + "age": "P52Y2M" + }, + "histological_diagnosis": { + "id": "NCIT:C38757", + "label": "Negative Finding" + }, + "disease_stage": [], + "diagnostic_markers": [], + "procedure": { + "code": { + "id": "NCIT:C15189", + "label": "Biopsy" + } + }, + "is_control_sample": false + }, + { + "id": "sample5", + "individual_id": "patient1", + "description": "", + "sampled_tissue": { + "id": "UBERON:0015876", + "label": "pelvic lymph node" + }, + "phenotypic_features": [], + "individual_age_at_collection": { + "age": "P52Y2M" + }, + "tumor_progression": { + "id": "NCIT:C3261", + "label": "Metastatic Neoplasm" + }, + "disease_stage": [], + "diagnostic_markers": [], + "procedure": { + "code": { + "id": "NCIT:C15189", + "label": "Biopsy" + } + }, + "is_control_sample": false + } + ] +} \ No newline at end of file diff --git a/chord_metadata_service/chord/tests/test_api.py b/chord_metadata_service/chord/tests/test_api.py index 8069151e0..47849606d 100644 --- a/chord_metadata_service/chord/tests/test_api.py +++ b/chord_metadata_service/chord/tests/test_api.py @@ -4,8 +4,15 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from .constants import * -from ..models import * +from .constants import ( + VALID_PROJECT_1, + VALID_DATA_USE_1, + valid_dataset_1, + dats_dataset, + VALID_DATS_CREATORS, + INVALID_DATS_CREATORS, +) +from ..models import Project, Dataset class CreateProjectTest(APITestCase): @@ -105,3 +112,6 @@ def test_dats(self): # TODO: Create TableOwnership # TODO: Update TableOwnership # TODO: Delete TableOwnership +# TODO: Create Table +# TODO: Update Table +# TODO: Delete Table diff --git a/chord_metadata_service/chord/tests/test_api_ingest.py b/chord_metadata_service/chord/tests/test_api_ingest.py index 5e028e410..19bc30c2b 100644 --- a/chord_metadata_service/chord/tests/test_api_ingest.py +++ b/chord_metadata_service/chord/tests/test_api_ingest.py @@ -6,11 +6,11 @@ from rest_framework.test import APITestCase from uuid import uuid4 -from .constants import * +from .constants import VALID_PROJECT_1, valid_dataset_1, valid_table_1 from ..views_ingest import METADATA_WORKFLOWS -def generate_ingest(table_id): +def generate_phenopackets_ingest(table_id): return { "table_id": table_id, "workflow_id": "phenopackets_json", @@ -59,31 +59,35 @@ def setUp(self) -> None: content_type="application/json") self.dataset = r.json() + table_ownership, table_record = valid_table_1(self.dataset["identifier"]) + self.client.post(reverse("tableownership-list"), data=json.dumps(table_ownership), + content_type="application/json") + r = self.client.post(reverse("table-list"), data=json.dumps(table_record), content_type="application/json") + self.table = r.json() + @override_settings(AUTH_OVERRIDE=True) # For permissions - def test_ingest(self): + def test_phenopackets_ingest(self): # No ingestion body r = self.client.post(reverse("ingest"), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) - # Invalid ingestion request - r = self.client.post(reverse("ingest"), data=json.dumps({}), content_type="application/json") - self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) + bad_ingest_bodies = ( + # Invalid ingest request + {}, - # Non-existent dataset ID - r = self.client.post(reverse("ingest"), data=json.dumps(generate_ingest(str(uuid4()))), - content_type="application/json") - self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) + # Non-existent table ID + generate_phenopackets_ingest(str(uuid4())), - # Non-existent workflow ID - bad_wf = generate_ingest(self.dataset["identifier"]) - bad_wf["workflow_id"] += "_invalid" - r = self.client.post(reverse("ingest"), data=json.dumps(bad_wf), content_type="application/json") - self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) + # Non-existent workflow ID + {**generate_phenopackets_ingest(self.table["identifier"]), "workflow_id": "phenopackets_json_invalid"}, - # json_document not in output - bad_wf = generate_ingest(self.dataset["identifier"]) - bad_wf["workflow_outputs"] = {} - r = self.client.post(reverse("ingest"), data=json.dumps(bad_wf), content_type="application/json") - self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) + # json_document not in output + {**generate_phenopackets_ingest(self.table["identifier"]), "workflow_outputs": {}}, + ) + + for data in bad_ingest_bodies: + print(data, flush=True) + r = self.client.post(reverse("ingest"), data=json.dumps(data), content_type="application/json") + self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) # TODO: More diff --git a/chord_metadata_service/chord/tests/test_api_search.py b/chord_metadata_service/chord/tests/test_api_search.py index 24ffb6d49..41fce4f2b 100644 --- a/chord_metadata_service/chord/tests/test_api_search.py +++ b/chord_metadata_service/chord/tests/test_api_search.py @@ -8,13 +8,26 @@ from rest_framework import status from rest_framework.test import APITestCase -from chord_metadata_service.phenopackets.tests.constants import * -from chord_metadata_service.phenopackets.models import * +from chord_metadata_service.patients.models import Individual +from chord_metadata_service.phenopackets.tests.constants import ( + VALID_PROCEDURE_1, + valid_biosample_1, + valid_biosample_2, + VALID_META_DATA_1, +) +from chord_metadata_service.phenopackets.models import Biosample, MetaData, Phenopacket, Procedure from chord_metadata_service.chord.tests.es_mocks import SEARCH_SUCCESS -from .constants import * -from ..models import * -from ..views_search import PHENOPACKET_DATA_TYPE_ID, PHENOPACKET_SCHEMA, PHENOPACKET_METADATA_SCHEMA +from .constants import ( + VALID_PROJECT_1, + valid_dataset_1, + valid_table_1, + TEST_SEARCH_QUERY_1, + TEST_SEARCH_QUERY_2, + TEST_FHIR_SEARCH_QUERY, +) +from ..models import Project, Dataset, TableOwnership, Table +from ..data_types import DATA_TYPE_EXPERIMENT, DATA_TYPE_MCODEPACKET, DATA_TYPE_PHENOPACKET, DATA_TYPES class DataTypeTest(APITestCase): @@ -22,45 +35,47 @@ def test_data_type_list(self): r = self.client.get(reverse("data-type-list")) self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() - self.assertEqual(len(c), 1) - self.assertEqual(c[0]["id"], PHENOPACKET_DATA_TYPE_ID) + self.assertEqual(len(c), 3) + ids = (c[0]["id"], c[1]["id"], c[2]["id"]) + self.assertIn(DATA_TYPE_EXPERIMENT, ids) + self.assertIn(DATA_TYPE_MCODEPACKET, ids) + self.assertIn(DATA_TYPE_PHENOPACKET, ids) def test_data_type_detail(self): - r = self.client.get(reverse("data-type-detail")) # Only mounted with phenopacket right now + r = self.client.get(reverse("data-type-detail", kwargs={"data_type": DATA_TYPE_PHENOPACKET})) self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() self.assertDictEqual(c, { - "id": PHENOPACKET_DATA_TYPE_ID, - "schema": PHENOPACKET_SCHEMA, - "metadata_schema": PHENOPACKET_METADATA_SCHEMA + "id": DATA_TYPE_PHENOPACKET, + **DATA_TYPES[DATA_TYPE_PHENOPACKET], }) def test_data_type_schema(self): - r = self.client.get(reverse("data-type-schema")) # Only mounted with phenopacket right now + r = self.client.get(reverse("data-type-schema", kwargs={"data_type": DATA_TYPE_PHENOPACKET})) self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() - self.assertDictEqual(c, PHENOPACKET_SCHEMA) + self.assertDictEqual(c, DATA_TYPES[DATA_TYPE_PHENOPACKET]["schema"]) def test_data_type_metadata_schema(self): - r = self.client.get(reverse("data-type-metadata-schema")) # Only mounted with phenopacket right now + r = self.client.get(reverse("data-type-metadata-schema", kwargs={"data_type": DATA_TYPE_PHENOPACKET})) self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() - self.assertDictEqual(c, PHENOPACKET_METADATA_SCHEMA) + self.assertDictEqual(c, DATA_TYPES[DATA_TYPE_PHENOPACKET]["metadata_schema"]) class TableTest(APITestCase): @staticmethod - def dataset_rep(dataset, created, updated): + def table_rep(table, created, updated): return { - "id": dataset["identifier"], - "name": dataset["title"], + "id": table["identifier"], + "name": table["name"], "metadata": { - "description": dataset["description"], - "project_id": dataset["project"], + "dataset_id": table["dataset"]["identifier"], "created": created, "updated": updated }, - "schema": PHENOPACKET_SCHEMA + "data_type": table["data_type"], + "schema": DATA_TYPES[table["data_type"]]["schema"], } @override_settings(AUTH_OVERRIDE=True) # For permissions @@ -74,22 +89,27 @@ def setUp(self) -> None: content_type="application/json") self.dataset = r.json() - def test_table_list(self): + to, tr = valid_table_1(self.dataset["identifier"]) + self.client.post(reverse("tableownership-list"), data=json.dumps(to), content_type="application/json") + r = self.client.post(reverse("table-list"), data=json.dumps(tr), content_type="application/json") + self.table = r.json() + + def test_chord_table_list(self): # No data type specified - r = self.client.get(reverse("table-list")) + r = self.client.get(reverse("chord-table-list")) self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) - r = self.client.get(reverse("table-list"), {"data-type": PHENOPACKET_DATA_TYPE_ID}) + r = self.client.get(reverse("chord-table-list"), {"data-type": DATA_TYPE_PHENOPACKET}) self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() self.assertEqual(len(c), 1) - self.assertEqual(c[0], self.dataset_rep(self.dataset, c[0]["metadata"]["created"], c[0]["metadata"]["updated"])) + self.assertEqual(c[0], self.table_rep(self.table, c[0]["metadata"]["created"], c[0]["metadata"]["updated"])) def test_table_summary(self): r = self.client.get(reverse("table-summary", kwargs={"table_id": str(uuid.uuid4())})) self.assertEqual(r.status_code, 404) - r = self.client.get(reverse("table-summary", kwargs={"table_id": self.dataset["identifier"]})) + r = self.client.get(reverse("table-summary", kwargs={"table_id": self.table["identifier"]})) s = r.json() self.assertEqual(s["count"], 0) # No phenopackets self.assertIn("data_type_specific", s) @@ -99,6 +119,9 @@ class SearchTest(APITestCase): def setUp(self) -> None: self.project = Project.objects.create(**VALID_PROJECT_1) self.dataset = Dataset.objects.create(**valid_dataset_1(self.project)) + to, tr = valid_table_1(self.dataset.identifier, model_compatible=True) + TableOwnership.objects.create(**to) + self.table = Table.objects.create(**tr) # Set up a dummy phenopacket @@ -116,7 +139,7 @@ def setUp(self) -> None: id="phenopacket_id:1", subject=self.individual, meta_data=self.meta_data, - dataset=self.dataset + table=self.table ) self.phenopacket.biosamples.set([self.biosample_1, self.biosample_2]) @@ -134,7 +157,7 @@ def test_common_search_2(self): def test_common_search_3(self): # No query - r = self.client.post(reverse("search"), data=json.dumps({"data_type": PHENOPACKET_DATA_TYPE_ID}), + r = self.client.post(reverse("search"), data=json.dumps({"data_type": DATA_TYPE_PHENOPACKET}), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) @@ -149,7 +172,7 @@ def test_common_search_4(self): def test_common_search_5(self): # Bad syntax for query r = self.client.post(reverse("search"), data=json.dumps({ - "data_type": PHENOPACKET_DATA_TYPE_ID, + "data_type": DATA_TYPE_PHENOPACKET, "query": ["hello", "world"] }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) @@ -157,21 +180,21 @@ def test_common_search_5(self): def test_search_with_result(self): # Valid search with result r = self.client.post(reverse("search"), data=json.dumps({ - "data_type": PHENOPACKET_DATA_TYPE_ID, + "data_type": DATA_TYPE_PHENOPACKET, "query": TEST_SEARCH_QUERY_1 }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() self.assertEqual(len(c["results"]), 1) self.assertDictEqual(c["results"][0], { - "id": str(self.dataset.identifier), - "data_type": PHENOPACKET_DATA_TYPE_ID + "id": str(self.table.identifier), + "data_type": DATA_TYPE_PHENOPACKET }) def test_search_without_result(self): # Valid search without result r = self.client.post(reverse("search"), data=json.dumps({ - "data_type": PHENOPACKET_DATA_TYPE_ID, + "data_type": DATA_TYPE_PHENOPACKET, "query": TEST_SEARCH_QUERY_2 }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_200_OK) @@ -181,45 +204,45 @@ def test_search_without_result(self): def test_private_search(self): # Valid search with result r = self.client.post(reverse("private-search"), data=json.dumps({ - "data_type": PHENOPACKET_DATA_TYPE_ID, + "data_type": DATA_TYPE_PHENOPACKET, "query": TEST_SEARCH_QUERY_1 }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() - self.assertIn(str(self.dataset.identifier), c["results"]) - self.assertEqual(c["results"][str(self.dataset.identifier)]["data_type"], PHENOPACKET_DATA_TYPE_ID) - self.assertEqual(self.phenopacket.id, c["results"][str(self.dataset.identifier)]["matches"][0]["id"]) + self.assertIn(str(self.table.identifier), c["results"]) + self.assertEqual(c["results"][str(self.table.identifier)]["data_type"], DATA_TYPE_PHENOPACKET) + self.assertEqual(self.phenopacket.id, c["results"][str(self.table.identifier)]["matches"][0]["id"]) # TODO: Check schema? def test_private_table_search_1(self): # No body - r = self.client.post(reverse("public-table-search", args=[str(self.dataset.identifier)])) + r = self.client.post(reverse("public-table-search", args=[str(self.table.identifier)])) self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) - r = self.client.post(reverse("private-table-search", args=[str(self.dataset.identifier)])) + r = self.client.post(reverse("private-table-search", args=[str(self.table.identifier)])) self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) def test_private_table_search_2(self): # No query - r = self.client.post(reverse("public-table-search", args=[str(self.dataset.identifier)]), data=json.dumps({}), + r = self.client.post(reverse("public-table-search", args=[str(self.table.identifier)]), data=json.dumps({}), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) - r = self.client.post(reverse("private-table-search", args=[str(self.dataset.identifier)]), data=json.dumps({}), + r = self.client.post(reverse("private-table-search", args=[str(self.table.identifier)]), data=json.dumps({}), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) def test_private_table_search_3(self): # Bad syntax for query - r = self.client.post(reverse("public-table-search", args=[str(self.dataset.identifier)]), data=json.dumps({ + r = self.client.post(reverse("public-table-search", args=[str(self.table.identifier)]), data=json.dumps({ "query": ["hello", "world"] }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) - r = self.client.post(reverse("private-table-search", args=[str(self.dataset.identifier)]), data=json.dumps({ + r = self.client.post(reverse("private-table-search", args=[str(self.table.identifier)]), data=json.dumps({ "query": ["hello", "world"] }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) @@ -227,14 +250,14 @@ def test_private_table_search_3(self): def test_private_table_search_4(self): # Valid query with one result - r = self.client.post(reverse("public-table-search", args=[str(self.dataset.identifier)]), data=json.dumps({ + r = self.client.post(reverse("public-table-search", args=[str(self.table.identifier)]), data=json.dumps({ "query": TEST_SEARCH_QUERY_1 }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() self.assertEqual(c, True) - r = self.client.post(reverse("private-table-search", args=[str(self.dataset.identifier)]), data=json.dumps({ + r = self.client.post(reverse("private-table-search", args=[str(self.table.identifier)]), data=json.dumps({ "query": TEST_SEARCH_QUERY_1 }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_200_OK) @@ -242,12 +265,22 @@ def test_private_table_search_4(self): self.assertEqual(len(c["results"]), 1) self.assertEqual(self.phenopacket.id, c["results"][0]["id"]) + def test_private_table_search_5(self): + # Valid query: literal "true" + r = self.client.post(reverse("private-table-search", args=[str(self.table.identifier)]), data=json.dumps({ + "query": True + }), content_type="application/json") + self.assertEqual(r.status_code, status.HTTP_200_OK) + c = r.json() + self.assertEqual(len(c["results"]), 1) + self.assertEqual(self.phenopacket.id, c["results"][0]["id"]) + @patch('chord_metadata_service.chord.views_search.es') def test_fhir_search(self, mocked_es): mocked_es.search.return_value = SEARCH_SUCCESS # Valid search with result r = self.client.post(reverse("fhir-search"), data=json.dumps({ - "data_type": PHENOPACKET_DATA_TYPE_ID, + "data_type": DATA_TYPE_PHENOPACKET, "query": TEST_FHIR_SEARCH_QUERY }), content_type="application/json") @@ -256,8 +289,8 @@ def test_fhir_search(self, mocked_es): self.assertEqual(len(c["results"]), 1) self.assertDictEqual(c["results"][0], { - "id": str(self.dataset.identifier), - "data_type": PHENOPACKET_DATA_TYPE_ID + "id": str(self.table.identifier), + "data_type": DATA_TYPE_PHENOPACKET }) @patch('chord_metadata_service.chord.views_search.es') @@ -265,13 +298,13 @@ def test_private_fhir_search(self, mocked_es): mocked_es.search.return_value = SEARCH_SUCCESS # Valid search with result r = self.client.post(reverse("fhir-private-search"), data=json.dumps({ - "data_type": PHENOPACKET_DATA_TYPE_ID, + "data_type": DATA_TYPE_PHENOPACKET, "query": TEST_FHIR_SEARCH_QUERY }), content_type="application/json") self.assertEqual(r.status_code, status.HTTP_200_OK) c = r.json() - self.assertIn(str(self.dataset.identifier), c["results"]) - self.assertEqual(c["results"][str(self.dataset.identifier)]["data_type"], PHENOPACKET_DATA_TYPE_ID) - self.assertEqual(self.phenopacket.id, c["results"][str(self.dataset.identifier)]["matches"][0]["id"]) + self.assertIn(str(self.table.identifier), c["results"]) + self.assertEqual(c["results"][str(self.table.identifier)]["data_type"], DATA_TYPE_PHENOPACKET) + self.assertEqual(self.phenopacket.id, c["results"][str(self.table.identifier)]["matches"][0]["id"]) diff --git a/chord_metadata_service/chord/tests/test_ingest.py b/chord_metadata_service/chord/tests/test_ingest.py index 9924cd56b..d86fb34fd 100644 --- a/chord_metadata_service/chord/tests/test_ingest.py +++ b/chord_metadata_service/chord/tests/test_ingest.py @@ -1,12 +1,20 @@ +import uuid + from django.test import TestCase from dateutil.parser import isoparse -from chord_metadata_service.chord.models import Project, Dataset -from chord_metadata_service.chord.views_ingest import create_phenotypic_feature, ingest_phenopacket +from chord_metadata_service.chord.data_types import DATA_TYPE_PHENOPACKET +from chord_metadata_service.chord.models import Project, Dataset, TableOwnership, Table +# noinspection PyProtectedMember +from chord_metadata_service.chord.ingest import ( + WORKFLOW_PHENOPACKETS_JSON, + create_phenotypic_feature, + WORKFLOW_INGEST_FUNCTION_MAP, +) from chord_metadata_service.phenopackets.models import PhenotypicFeature, Phenopacket from .constants import VALID_DATA_USE_1 -from .example_ingest import EXAMPLE_INGEST +from .example_ingest import EXAMPLE_INGEST_PHENOPACKET, EXAMPLE_INGEST_OUTPUTS class IngestTest(TestCase): @@ -14,6 +22,10 @@ def setUp(self) -> None: p = Project.objects.create(title="Project 1", description="") self.d = Dataset.objects.create(title="Dataset 1", description="Some dataset", data_use=VALID_DATA_USE_1, project=p) + # TODO: Real service ID + to = TableOwnership.objects.create(table_id=uuid.uuid4(), service_id=uuid.uuid4(), service_artifact="metadata", + dataset=self.d) + self.t = Table.objects.create(ownership_record=to, name="Table 1", data_type=DATA_TYPE_PHENOPACKET) def test_create_pf(self): p1 = create_phenotypic_feature({ @@ -31,22 +43,22 @@ def test_create_pf(self): self.assertEqual(p1.pk, p2.pk) - def test_ingesting_json(self): - p = ingest_phenopacket(EXAMPLE_INGEST, self.d.identifier) + def test_ingesting_phenopackets_json(self): + p = WORKFLOW_INGEST_FUNCTION_MAP[WORKFLOW_PHENOPACKETS_JSON](EXAMPLE_INGEST_OUTPUTS, self.t.identifier) self.assertEqual(p.id, Phenopacket.objects.get(id=p.id).id) - self.assertEqual(p.subject.id, EXAMPLE_INGEST["subject"]["id"]) - self.assertEqual(p.subject.date_of_birth, isoparse(EXAMPLE_INGEST["subject"]["date_of_birth"])) - self.assertEqual(p.subject.sex, EXAMPLE_INGEST["subject"]["sex"]) - self.assertEqual(p.subject.karyotypic_sex, EXAMPLE_INGEST["subject"]["karyotypic_sex"]) + self.assertEqual(p.subject.id, EXAMPLE_INGEST_PHENOPACKET["subject"]["id"]) + self.assertEqual(p.subject.date_of_birth, isoparse(EXAMPLE_INGEST_PHENOPACKET["subject"]["date_of_birth"])) + self.assertEqual(p.subject.sex, EXAMPLE_INGEST_PHENOPACKET["subject"]["sex"]) + self.assertEqual(p.subject.karyotypic_sex, EXAMPLE_INGEST_PHENOPACKET["subject"]["karyotypic_sex"]) pfs = list(p.phenotypic_features.all().order_by("pftype__id")) self.assertEqual(len(pfs), 2) - self.assertEqual(pfs[0].description, EXAMPLE_INGEST["phenotypic_features"][0]["description"]) - self.assertEqual(pfs[0].pftype["id"], EXAMPLE_INGEST["phenotypic_features"][0]["type"]["id"]) - self.assertEqual(pfs[0].pftype["label"], EXAMPLE_INGEST["phenotypic_features"][0]["type"]["label"]) - self.assertEqual(pfs[0].negated, EXAMPLE_INGEST["phenotypic_features"][0]["negated"]) + self.assertEqual(pfs[0].description, EXAMPLE_INGEST_PHENOPACKET["phenotypic_features"][0]["description"]) + self.assertEqual(pfs[0].pftype["id"], EXAMPLE_INGEST_PHENOPACKET["phenotypic_features"][0]["type"]["id"]) + self.assertEqual(pfs[0].pftype["label"], EXAMPLE_INGEST_PHENOPACKET["phenotypic_features"][0]["type"]["label"]) + self.assertEqual(pfs[0].negated, EXAMPLE_INGEST_PHENOPACKET["phenotypic_features"][0]["negated"]) # TODO: Test more properties diseases = list(p.diseases.all().order_by("term__id")) @@ -60,6 +72,6 @@ def test_ingesting_json(self): # TODO: More # Test ingesting again - p2 = ingest_phenopacket(EXAMPLE_INGEST, self.d.identifier) + p2 = WORKFLOW_INGEST_FUNCTION_MAP[WORKFLOW_PHENOPACKETS_JSON](EXAMPLE_INGEST_OUTPUTS, self.t.identifier) self.assertNotEqual(p.id, p2.id) # TODO: More diff --git a/chord_metadata_service/chord/tests/test_models.py b/chord_metadata_service/chord/tests/test_models.py index fc14a1477..59e3afde9 100644 --- a/chord_metadata_service/chord/tests/test_models.py +++ b/chord_metadata_service/chord/tests/test_models.py @@ -1,6 +1,7 @@ from django.test import TestCase from uuid import uuid4 -from ..models import * +from ..data_types import DATA_TYPE_PHENOPACKET +from ..models import Project, Dataset, TableOwnership, Table from .constants import VALID_DATA_USE_1 @@ -52,8 +53,6 @@ def setUp(self) -> None: table_id=TABLE_ID, service_id=SERVICE_ID, service_artifact="variant", - data_type="variant", - dataset=d ) @@ -62,9 +61,27 @@ def test_table_ownership(self): t = TableOwnership.objects.get(table_id=TABLE_ID, service_id=SERVICE_ID) self.assertEqual(t.service_artifact, "variant") - self.assertEqual(t.data_type, "variant") self.assertEqual(t.dataset, d) self.assertIn(t, d.table_ownership.all()) self.assertEqual(str(t), f"{str(d)} -> {t.table_id}") + + +class TableTest(TestCase): + def setUp(self) -> None: + p = Project.objects.create(title="Project 1", description="") + self.d = Dataset.objects.create(title="Dataset 1", description="", data_use=VALID_DATA_USE_1, project=p) + to = TableOwnership.objects.create( + table_id=TABLE_ID, + service_id=SERVICE_ID, + service_artifact="variant", + dataset=self.d + ) + Table.objects.create(ownership_record=to, name="Table 1", data_type=DATA_TYPE_PHENOPACKET) + + def test_table(self): + t = Table.objects.get(ownership_record_id=TABLE_ID) + self.assertEqual(t.data_type, DATA_TYPE_PHENOPACKET) + self.assertEqual(t.identifier, TABLE_ID) + self.assertEqual(t.dataset, self.d) diff --git a/chord_metadata_service/chord/tests/test_search.py b/chord_metadata_service/chord/tests/test_search.py index 76ef26715..d1326c598 100644 --- a/chord_metadata_service/chord/tests/test_search.py +++ b/chord_metadata_service/chord/tests/test_search.py @@ -1,14 +1,12 @@ from django.test import TestCase from jsonschema import Draft7Validator -from ..views_search import PHENOPACKET_SCHEMA, PHENOPACKET_METADATA_SCHEMA +from ..data_types import DATA_TYPES class SchemaTest(TestCase): @staticmethod - def test_phenopacket_schema(): - Draft7Validator.check_schema(PHENOPACKET_SCHEMA) - - @staticmethod - def test_phenopacket_metadata_schema(): - Draft7Validator.check_schema(PHENOPACKET_METADATA_SCHEMA) + def test_data_type_schemas(): + for d in DATA_TYPES.values(): + Draft7Validator.check_schema(d["schema"]) + Draft7Validator.check_schema(d["metadata_schema"]) diff --git a/chord_metadata_service/chord/urls.py b/chord_metadata_service/chord/urls.py new file mode 100644 index 000000000..64190a594 --- /dev/null +++ b/chord_metadata_service/chord/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from . import views_ingest, views_search + +urlpatterns = [ + path('workflows', views_ingest.workflow_list, name="workflows"), + path('workflows/', views_ingest.workflow_item, name="workflow-detail"), + path('workflows/.wdl', views_ingest.workflow_file, name="workflow-file"), + + path('private/ingest', views_ingest.ingest, name="ingest"), + + path('data-types', views_search.data_type_list, name="data-type-list"), + path('data-types/', views_search.data_type_detail, name="data-type-detail"), + path('data-types//schema', views_search.data_type_schema, name="data-type-schema"), + # TODO: Consistent snake or kebab + path('data-types//metadata_schema', views_search.data_type_metadata_schema, + name="data-type-metadata-schema"), + path('tables', views_search.table_list, name="chord-table-list"), + path('tables/', views_search.table_detail, name="chord-table-detail"), + path('tables//summary', views_search.chord_table_summary, name="table-summary"), + path('tables//search', views_search.chord_public_table_search, name="public-table-search"), + path('search', views_search.chord_search, name="search"), + path('fhir-search', views_search.fhir_public_search, name="fhir-search"), + path('private/fhir-search', views_search.fhir_private_search, name="fhir-private-search"), + path('private/search', views_search.chord_private_search, name="private-search"), + path('private/tables//search', views_search.chord_private_table_search, name="private-table-search"), +] diff --git a/chord_metadata_service/chord/views_ingest.py b/chord_metadata_service/chord/views_ingest.py index 041741309..89870f578 100644 --- a/chord_metadata_service/chord/views_ingest.py +++ b/chord_metadata_service/chord/views_ingest.py @@ -1,52 +1,24 @@ -import chord_lib import json -import jsonschema -import jsonschema.exceptions import os import uuid -from dateutil.parser import isoparse - +from django.core.exceptions import ValidationError +from django.db import transaction +from jsonschema import Draft7Validator from rest_framework.decorators import api_view, permission_classes, renderer_classes from rest_framework.permissions import AllowAny from rest_framework.renderers import BaseRenderer from rest_framework.response import Response -from chord_lib.responses.errors import * -from chord_lib.workflows import get_workflow, get_workflow_resource, workflow_exists - -from typing import Callable +from bento_lib.schemas.bento import BENTO_INGEST_SCHEMA +from bento_lib.responses import errors +from bento_lib.workflows import get_workflow, get_workflow_resource, workflow_exists -from chord_metadata_service.chord.models import * -from chord_metadata_service.phenopackets.models import * +from .ingest import METADATA_WORKFLOWS, WORKFLOWS_PATH, WORKFLOW_INGEST_FUNCTION_MAP +from .models import Table -METADATA_WORKFLOWS = { - "ingestion": { - "phenopackets_json": { - "name": "Phenopackets-Compatible JSON", - "description": "This ingestion workflow will validate and import a Phenopackets schema-compatible " - "JSON document.", - "data_type": "phenopacket", - "file": "phenopackets_json.wdl", - "inputs": [ - { - "id": "json_document", - "type": "file", - "extensions": [".json"] - } - ], - "outputs": [ - { - "id": "json_document", - "type": "file", - "value": "{json_document}" - } - ] - }, - }, - "analysis": {} -} +BENTO_INGEST_SCHEMA_VALIDATOR = Draft7Validator(BENTO_INGEST_SCHEMA) class WDLRenderer(BaseRenderer): @@ -67,7 +39,7 @@ def workflow_list(_request): @permission_classes([AllowAny]) def workflow_item(_request, workflow_id): if not workflow_exists(workflow_id, METADATA_WORKFLOWS): - return Response(not_found_error(f"No workflow with ID {workflow_id}"), status=404) + return Response(errors.not_found_error(f"No workflow with ID {workflow_id}"), status=404) return Response(get_workflow(workflow_id, METADATA_WORKFLOWS)) @@ -79,28 +51,11 @@ def workflow_file(_request, workflow_id): if not workflow_exists(workflow_id, METADATA_WORKFLOWS): return Response(status=404, data="Not found") - wdl_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "workflows", - get_workflow_resource(workflow_id, METADATA_WORKFLOWS)) - + wdl_path = os.path.join(WORKFLOWS_PATH, get_workflow_resource(workflow_id, METADATA_WORKFLOWS)) with open(wdl_path, "r") as wf: return Response(wf.read()) -def create_phenotypic_feature(pf): - pf_obj = PhenotypicFeature( - description=pf.get("description", ""), - pftype=pf["type"], - negated=pf.get("negated", False), - severity=pf.get("severity", None), - modifier=pf.get("modifier", []), # TODO: Validate ontology term in schema... - onset=pf.get("onset", None), - evidence=pf.get("evidence", None) # TODO: Separate class? - ) - - pf_obj.save() - return pf_obj - - # Mounted on /private/, so will get protected anyway; this allows for access from WES # TODO: Ugly and misleading permissions @api_view(["POST"]) @@ -112,154 +67,42 @@ def ingest(request): # TODO: Use serializers with basic objects and maybe some more complex ones too (but for performance, this might # not be optimal...) - try: - jsonschema.validate(request.data, chord_lib.schemas.chord.CHORD_INGEST_SCHEMA) - except jsonschema.exceptions.ValidationError: - return Response(bad_request_error("Invalid ingest request body"), status=400) # TODO: Validation errors + if not BENTO_INGEST_SCHEMA_VALIDATOR.is_valid(request.data): + return Response(errors.bad_request_error("Invalid ingest request body"), status=400) # TODO: Validation errors table_id = request.data["table_id"] - if not Dataset.objects.filter(identifier=table_id).exists(): - return Response(bad_request_error(f"Table with ID {table_id} does not exist"), status=400) + if not Table.objects.filter(ownership_record_id=table_id).exists(): + return Response(errors.bad_request_error(f"Table with ID {table_id} does not exist"), status=400) table_id = str(uuid.UUID(table_id)) # Normalize dataset ID to UUID's str format. workflow_id = request.data["workflow_id"].strip() workflow_outputs = request.data["workflow_outputs"] - if not chord_lib.workflows.workflow_exists(workflow_id, METADATA_WORKFLOWS): # Check that the workflow exists - return Response(bad_request_error(f"Workflow with ID {workflow_id} does not exist"), status=400) - - if "json_document" not in workflow_outputs: - return Response(bad_request_error("Missing workflow output 'json_document'"), status=400) - - with open(workflow_outputs["json_document"], "r") as jf: - try: - phenopacket_data = json.load(jf) - if isinstance(phenopacket_data, list): - for item in phenopacket_data: - ingest_phenopacket(item, table_id) - else: - ingest_phenopacket(phenopacket_data, table_id) - - except json.decoder.JSONDecodeError as e: - return Response(bad_request_error(f"Invalid JSON provided for phenopacket document (message: {e})"), - status=400) - - # TODO: Schema validation - # TODO: Rollback in case of failures - return Response(status=204) - - -def _query_and_check_nulls(obj: dict, key: str, transform: Callable = lambda x: x): - value = obj.get(key, None) - return {f"{key}__isnull": True} if value is None else {key: transform(value)} - - -def ingest_phenopacket(phenopacket_data, table_id): - """ Ingests one phenopacket. """ - - new_phenopacket_id = str(uuid.uuid4()) # TODO: Is this provided? - - subject = phenopacket_data.get("subject", None) - phenotypic_features = phenopacket_data.get("phenotypic_features", []) - biosamples = phenopacket_data.get("biosamples", []) - genes = phenopacket_data.get("genes", []) - diseases = phenopacket_data.get("diseases", []) - meta_data = phenopacket_data["meta_data"] + if not workflow_exists(workflow_id, METADATA_WORKFLOWS): # Check that the workflow exists + return Response(errors.bad_request_error(f"Workflow with ID {workflow_id} does not exist"), status=400) - if subject: - subject_query = _query_and_check_nulls(subject, "date_of_birth", transform=isoparse) - for k in ("alternate_ids", "age", "sex", "karyotypic_sex", "taxonomy"): - subject_query.update(_query_and_check_nulls(subject, k)) - subject, _ = Individual.objects.get_or_create(id=subject["id"], **subject_query) - - phenotypic_features_db = [create_phenotypic_feature(pf) for pf in phenotypic_features] - - biosamples_db = [] - for bs in biosamples: - # TODO: This should probably be a JSON field, or compound key with code/body_site - procedure, _ = Procedure.objects.get_or_create(**bs["procedure"]) - - bs_query = _query_and_check_nulls(bs, "individual_id", lambda i: Individual.objects.get(id=i)) - for k in ("sampled_tissue", "taxonomy", "individual_age_at_collection", "histological_diagnosis", - "tumor_progression", "tumor_grade"): - bs_query.update(_query_and_check_nulls(bs, k)) - - bs_obj, bs_created = Biosample.objects.get_or_create( - id=bs["id"], - description=bs.get("description", ""), - procedure=procedure, - is_control_sample=bs.get("is_control_sample", False), - diagnostic_markers=bs.get("diagnostic_markers", []), - **bs_query - ) - - if bs_created: - bs_pfs = [create_phenotypic_feature(pf) for pf in bs.get("phenotypic_features", [])] - bs_obj.phenotypic_features.set(bs_pfs) - - # TODO: Update phenotypic features otherwise? - - biosamples_db.append(bs_obj) - - # TODO: May want to augment alternate_ids - genes_db = [] - for g in genes: - # TODO: Validate CURIE - # TODO: Rename alternate_id - g_obj, _ = Gene.objects.get_or_create( - id=g["id"], - alternate_ids=g.get("alternate_ids", []), - symbol=g["symbol"] - ) - genes_db.append(g_obj) - - diseases_db = [] - for disease in diseases: - # TODO: Primary key, should this be a model? - d_obj, _ = Disease.objects.get_or_create( - term=disease["term"], - disease_stage=disease.get("disease_stage", []), - tnm_finding=disease.get("tnm_finding", []), - **_query_and_check_nulls(disease, "onset") - ) - diseases_db.append(d_obj.id) - - resources_db = [] - for rs in meta_data.get("resources", []): - rs_obj, _ = Resource.objects.get_or_create( - id=rs["id"], # TODO: This ID is a bit iffy, because they're researcher-provided - name=rs["name"], - namespace_prefix=rs["namespace_prefix"], - url=rs["url"], - version=rs["version"], - iri_prefix=rs["iri_prefix"] - ) - resources_db.append(rs_obj) - - meta_data_obj = MetaData( - created_by=meta_data["created_by"], - submitted_by=meta_data.get("submitted_by", None), - phenopacket_schema_version="1.0.0-RC3", - external_references=meta_data.get("external_references", []) - ) - meta_data_obj.save() - - meta_data_obj.resources.set(resources_db) # TODO: primary key ??? - - new_phenopacket = Phenopacket( - id=new_phenopacket_id, - subject=subject, - meta_data=meta_data_obj, - dataset=Dataset.objects.get(identifier=table_id) - ) - - new_phenopacket.save() - - new_phenopacket.phenotypic_features.set(phenotypic_features_db) - new_phenopacket.biosamples.set(biosamples_db) - new_phenopacket.genes.set(genes_db) - new_phenopacket.diseases.set(diseases_db) - - return new_phenopacket + try: + with transaction.atomic(): + # Wrap ingestion in a transaction, so if it fails we don't end up in a partial state in the database. + WORKFLOW_INGEST_FUNCTION_MAP[workflow_id](workflow_outputs, table_id) + + except KeyError: + # Tried to access a non-existant workflow output + # TODO: More precise error (which key?) + return Response(errors.bad_request_error("Missing workflow output"), status=400) + + except json.decoder.JSONDecodeError as e: + return Response(errors.bad_request_error(f"Invalid JSON provided for ingest document (message: {e})"), + status=400) + + except ValidationError as e: + return Response(errors.bad_request_error( + "Encountered validation errors during ingestion", + *(e.error_list if hasattr(e, "error_list") else e.error_dict.items()), + )) + + # TODO: Schema validation + # TODO: Rollback in case of failures + return Response(status=204) diff --git a/chord_metadata_service/chord/views_search.py b/chord_metadata_service/chord/views_search.py index 506a10978..456efb81d 100644 --- a/chord_metadata_service/chord/views_search.py +++ b/chord_metadata_service/chord/views_search.py @@ -1,5 +1,9 @@ import itertools +import json +import uuid +from bento_lib.responses import errors +from bento_lib.search import build_search_response, postgres from collections import Counter from datetime import datetime from django.db import connection @@ -8,166 +12,237 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response +from typing import Any, Callable, Dict -from chord_lib.responses.errors import * -from chord_lib.search import build_search_response, postgres -from chord_metadata_service.metadata.settings import DEBUG +from chord_metadata_service.experiments.models import Experiment +from chord_metadata_service.experiments.serializers import ExperimentSerializer +from chord_metadata_service.mcode.models import MCodePacket +from chord_metadata_service.metadata.elastic import es +from chord_metadata_service.metadata.settings import DEBUG, CHORD_SERVICE_ARTIFACT, CHORD_SERVICE_ID from chord_metadata_service.patients.models import Individual from chord_metadata_service.phenopackets.api_views import PHENOPACKET_PREFETCH from chord_metadata_service.phenopackets.models import Phenopacket -from chord_metadata_service.phenopackets.schemas import PHENOPACKET_SCHEMA from chord_metadata_service.phenopackets.serializers import PhenopacketSerializer -from chord_metadata_service.metadata.elastic import es - -from .models import Dataset -from .permissions import OverrideOrSuperUserOnly -PHENOPACKET_DATA_TYPE_ID = "phenopacket" - -PHENOPACKET_METADATA_SCHEMA = { - "type": "object" - # TODO -} +from .data_types import DATA_TYPE_EXPERIMENT, DATA_TYPE_MCODEPACKET, DATA_TYPE_PHENOPACKET, DATA_TYPES +from .models import Dataset, TableOwnership, Table +from .permissions import ReadOnly, OverrideOrSuperUserOnly @api_view(["GET"]) @permission_classes([AllowAny]) def data_type_list(_request): - return Response([{"id": PHENOPACKET_DATA_TYPE_ID, "schema": PHENOPACKET_SCHEMA}]) + return Response(sorted( + ({"id": k, "schema": dt["schema"]} for k, dt in DATA_TYPES.items()), + key=lambda dt: dt["id"] + )) @api_view(["GET"]) @permission_classes([AllowAny]) -def data_type_phenopacket(_request): - return Response({ - "id": PHENOPACKET_DATA_TYPE_ID, - "schema": PHENOPACKET_SCHEMA, - "metadata_schema": PHENOPACKET_METADATA_SCHEMA - }) +def data_type_detail(_request, data_type: str): + if data_type not in DATA_TYPES: + return Response(errors.not_found_error(f"Date type {data_type} not found"), status=404) + + return Response({"id": data_type, **DATA_TYPES[data_type]}) @api_view(["GET"]) @permission_classes([AllowAny]) -def data_type_phenopacket_schema(_request): - return Response(PHENOPACKET_SCHEMA) +def data_type_schema(_request, data_type: str): + if data_type not in DATA_TYPES: + return Response(errors.not_found_error(f"Date type {data_type} not found"), status=404) + + return Response(DATA_TYPES[data_type]["schema"]) @api_view(["GET"]) @permission_classes([AllowAny]) -def data_type_phenopacket_metadata_schema(_request): - return Response(PHENOPACKET_METADATA_SCHEMA) +def data_type_metadata_schema(_request, data_type: str): + if data_type not in DATA_TYPES: + return Response(errors.not_found_error(f"Date type {data_type} not found"), status=404) + return Response(DATA_TYPES[DATA_TYPE_PHENOPACKET]["metadata_schema"]) -@api_view(["GET"]) -@permission_classes([AllowAny]) -def table_list(request): - data_types = request.query_params.getlist("data-type") - if PHENOPACKET_DATA_TYPE_ID not in data_types: - return Response(bad_request_error(f"Missing or invalid data type (Specified: {data_types})"), status=400) - return Response([{ - "id": d.identifier, - "name": d.title, +def chord_table_representation(table: Table) -> dict: + return { + "id": table.identifier, + "name": table.name, "metadata": { - "description": d.description, - "project_id": d.project_id, - "created": d.created.isoformat(), - "updated": d.updated.isoformat() + "dataset_id": table.ownership_record.dataset_id, + "created": table.created.isoformat(), + "updated": table.updated.isoformat() }, - "schema": PHENOPACKET_SCHEMA - } for d in Dataset.objects.all()]) + "data_type": table.data_type, + "schema": DATA_TYPES[table.data_type]["schema"], + } -# TODO: Remove pragma: no cover when GET/POST implemented -# TODO: Should this exist? managed -@api_view(["DELETE"]) -@permission_classes([OverrideOrSuperUserOnly]) +@api_view(["GET", "POST"]) +@permission_classes([OverrideOrSuperUserOnly | ReadOnly]) +def table_list(request): + if request.method == "POST": + request_data = json.loads(request.body) # TODO: Handle JSON errors here + + name = request_data.get("name", "").strip() + data_type = request_data.get("data_type", "") + dataset = request_data.get("dataset") + + if name == "": + return Response(errors.bad_request_error("Missing or blank name field"), status=400) + + if data_type not in DATA_TYPES: + return Response(errors.bad_request_error(f"Invalid data type for table: {data_type}"), status=400) + + table_id = str(uuid.uuid4()) + + table_ownership = TableOwnership.objects.create( + table_id=table_id, + service_id=CHORD_SERVICE_ID, + service_artifact=CHORD_SERVICE_ARTIFACT, + dataset=Dataset.objects.get(identifier=dataset), + ) + + table = Table.objects.create( + ownership_record=table_ownership, + name=name, + data_type=data_type, + ) + + return Response(chord_table_representation(table)) + + # GET + + data_types = request.query_params.getlist("data-type") + + if len(data_types) == 0 or next((dt for dt in data_types if dt not in DATA_TYPES), None) is not None: + return Response(errors.bad_request_error(f"Missing or invalid data type(s) (Specified: {data_types})"), + status=400) + + return Response([chord_table_representation(t) for t in Table.objects.filter(data_type__in=data_types)]) + + +# TODO: Remove pragma: no cover when POST implemented +@api_view(["GET", "DELETE"]) +@permission_classes([OverrideOrSuperUserOnly | ReadOnly]) def table_detail(request, table_id): # pragma: no cover # TODO: Implement GET, POST # TODO: Permissions: Check if user has control / more direct access over this table and/or dataset? # Or just always use owner... try: - table = Dataset.objects.get(identifier=table_id) - except Dataset.DoesNotExist: - return Response(not_found_error(f"Table with ID {table_id} not found"), status=404) + table = Table.objects.get(ownership_record_id=table_id) + except Table.DoesNotExist: + return Response(errors.not_found_error(f"Table with ID {table_id} not found"), status=404) if request.method == "DELETE": table.delete() return Response(status=204) + # GET + return Response(chord_table_representation(table)) -@api_view(["GET"]) -@permission_classes([OverrideOrSuperUserOnly]) -def chord_table_summary(_request, table_id): - try: - table = Dataset.objects.get(identifier=table_id) - phenopackets = Phenopacket.objects.filter(dataset=table) - diseases_counter = Counter() - phenotypic_features_counter = Counter() +def experiment_table_summary(table): + experiments = Experiment.objects.filter(table=table) # TODO + + return Response({ + "count": experiments.count(), + "data_type_specific": {}, # TODO + }) - biosamples_set = set() - individuals_set = set() - biosamples_cs = Counter() - biosamples_taxonomy = Counter() +def mcodepacket_table_summary(table): + mcodepackets = MCodePacket.objects.filter(table=table) # TODO + + return Response({ + "count": mcodepackets.count(), + "data_type_specific": {}, # TODO + }) + - individuals_sex = Counter() - individuals_k_sex = Counter() - individuals_taxonomy = Counter() +def phenopacket_table_summary(table): + phenopackets = Phenopacket.objects.filter(table=table) # TODO - def count_individual(ind): - individuals_set.add(ind.id) - individuals_sex.update((ind.sex,)) - individuals_k_sex.update((ind.karyotypic_sex,)) - if ind.taxonomy is not None: - individuals_taxonomy.update((ind.taxonomy["id"],)) + diseases_counter = Counter() + phenotypic_features_counter = Counter() - for p in phenopackets.prefetch_related("biosamples"): - for b in p.biosamples.all(): - biosamples_set.add(b.id) - biosamples_cs.update((b.is_control_sample,)) + biosamples_set = set() + individuals_set = set() - if b.taxonomy is not None: - biosamples_taxonomy.update((b.taxonomy["id"],)) + biosamples_cs = Counter() + biosamples_taxonomy = Counter() - if b.individual is not None: - count_individual(b.individual) + individuals_sex = Counter() + individuals_k_sex = Counter() + individuals_taxonomy = Counter() - for pf in b.phenotypic_features.all(): - phenotypic_features_counter.update((pf.pftype["id"],)) + def count_individual(ind): + individuals_set.add(ind.id) + individuals_sex.update((ind.sex,)) + individuals_k_sex.update((ind.karyotypic_sex,)) + if ind.taxonomy is not None: + individuals_taxonomy.update((ind.taxonomy["id"],)) - for d in p.diseases.all(): - diseases_counter.update((d.term["id"],)) + for p in phenopackets.prefetch_related("biosamples"): + for b in p.biosamples.all(): + biosamples_set.add(b.id) + biosamples_cs.update((b.is_control_sample,)) - for pf in p.phenotypic_features.all(): + if b.taxonomy is not None: + biosamples_taxonomy.update((b.taxonomy["id"],)) + + if b.individual is not None: + count_individual(b.individual) + + for pf in b.phenotypic_features.all(): phenotypic_features_counter.update((pf.pftype["id"],)) - # Currently, phenopacket subject is required so we can assume it's not None - count_individual(p.subject) - - return Response({ - "count": phenopackets.count(), - "data_type_specific": { - "biosamples": { - "count": len(biosamples_set), - "is_control_sample": dict(biosamples_cs), - "taxonomy": dict(biosamples_taxonomy), - }, - "diseases": dict(diseases_counter), - "individuals": { - "count": len(individuals_set), - "sex": {k: individuals_sex[k] for k in (s[0] for s in Individual.SEX)}, - "karyotypic_sex": {k: individuals_k_sex[k] for k in (s[0] for s in Individual.KARYOTYPIC_SEX)}, - "taxonomy": dict(individuals_taxonomy), - # TODO: age histogram - }, - "phenotypic_features": dict(phenotypic_features_counter), - } - }) - - except Dataset.DoesNotExist: - return Response(not_found_error(f"Table with ID {table_id} not found"), status=404) + for d in p.diseases.all(): + diseases_counter.update((d.term["id"],)) + + for pf in p.phenotypic_features.all(): + phenotypic_features_counter.update((pf.pftype["id"],)) + + # Currently, phenopacket subject is required so we can assume it's not None + count_individual(p.subject) + + return Response({ + "count": phenopackets.count(), + "data_type_specific": { + "biosamples": { + "count": len(biosamples_set), + "is_control_sample": dict(biosamples_cs), + "taxonomy": dict(biosamples_taxonomy), + }, + "diseases": dict(diseases_counter), + "individuals": { + "count": len(individuals_set), + "sex": {k: individuals_sex[k] for k in (s[0] for s in Individual.SEX)}, + "karyotypic_sex": {k: individuals_k_sex[k] for k in (s[0] for s in Individual.KARYOTYPIC_SEX)}, + "taxonomy": dict(individuals_taxonomy), + # TODO: age histogram + }, + "phenotypic_features": dict(phenotypic_features_counter), + } + }) + + +SUMMARY_HANDLERS: Dict[str, Callable[[Any], Response]] = { + DATA_TYPE_EXPERIMENT: experiment_table_summary, + DATA_TYPE_MCODEPACKET: mcodepacket_table_summary, + DATA_TYPE_PHENOPACKET: phenopacket_table_summary, +} + + +@api_view(["GET"]) +@permission_classes([OverrideOrSuperUserOnly]) +def chord_table_summary(_request, table_id): + try: + table = Table.objects.get(ownership_record_id=table_id) + return SUMMARY_HANDLERS[table.data_type](table) + except Table.DoesNotExist: + return Response(errors.not_found_error(f"Table with ID {table_id} not found"), status=404) # TODO: CHORD-standardized logging @@ -176,13 +251,19 @@ def debug_log(message): # pragma: no cover print(f"[CHORD Metadata {datetime.now()}] [DEBUG] {message}", flush=True) -def phenopacket_results(query, params, key="id"): +def data_type_results(query, params, key="id"): with connection.cursor() as cursor: debug_log(f"Executing SQL:\n {query.as_string(cursor.connection)}") cursor.execute(query.as_string(cursor.connection), params) return set(dict(zip([col[0] for col in cursor.description], row))[key] for row in cursor.fetchall()) +def experiment_query_results(query, params): + # TODO: possibly a quite inefficient way of doing things... + # TODO: Prefetch related biosample or no? + return Experiment.objects.filter(id__in=data_type_results(query, params, "id")) + + def phenopacket_query_results(query, params): # TODO: possibly a quite inefficient way of doing things... # To expand further on this query : the select_related call @@ -191,7 +272,7 @@ def phenopacket_query_results(query, params): # sure that, for instance, when querying diseases, we won't make multiple call # for the same set of data return Phenopacket.objects.filter( - id__in=phenopacket_results(query, params, "id") + id__in=data_type_results(query, params, "id") ).select_related( 'subject', 'meta_data' @@ -201,39 +282,48 @@ def phenopacket_query_results(query, params): def search(request, internal_data=False): - if "data_type" not in request.data: - return Response(bad_request_error("Missing data_type in request body"), status=400) + data_type = request.data.get("data_type") + + if not data_type: + return Response(errors.bad_request_error("Missing data_type in request body"), status=400) if "query" not in request.data: - return Response(bad_request_error("Missing query in request body"), status=400) + return Response(errors.bad_request_error("Missing query in request body"), status=400) start = datetime.now() - if request.data["data_type"] != PHENOPACKET_DATA_TYPE_ID: - return Response(bad_request_error(f"Missing or invalid data type (Specified: {request.data['data_type']})"), - status=400) + if data_type not in DATA_TYPES: + return Response( + errors.bad_request_error(f"Missing or invalid data type (Specified: {request.data['data_type']})"), + status=400 + ) try: - compiled_query, params = postgres.search_query_to_psycopg2_sql(request.data["query"], PHENOPACKET_SCHEMA) + compiled_query, params = postgres.search_query_to_psycopg2_sql(request.data["query"], + DATA_TYPES[data_type]["schema"]) except (SyntaxError, TypeError, ValueError) as e: - return Response(bad_request_error(f"Error compiling query (message: {str(e)})"), status=400) + return Response(errors.bad_request_error(f"Error compiling query (message: {str(e)})"), status=400) if not internal_data: - datasets = Dataset.objects.filter(identifier__in=phenopacket_results( + tables = Table.objects.filter(ownership_record_id__in=data_type_results( query=compiled_query, params=params, - key="dataset_id" + key="table_id" )) # TODO: Maybe can avoid hitting DB here - return Response(build_search_response([{"id": d.identifier, "data_type": PHENOPACKET_DATA_TYPE_ID} - for d in datasets], start)) + return Response(build_search_response([{"id": t.identifier, "data_type": DATA_TYPE_PHENOPACKET} + for t in tables], start)) + + # TODO: Dict-ify + serializer_class = PhenopacketSerializer if data_type == DATA_TYPE_PHENOPACKET else ExperimentSerializer + query_function = phenopacket_query_results if data_type == DATA_TYPE_PHENOPACKET else experiment_query_results return Response(build_search_response({ - dataset_id: { - "data_type": PHENOPACKET_DATA_TYPE_ID, - "matches": list(PhenopacketSerializer(p).data for p in dataset_phenopackets) - } for dataset_id, dataset_phenopackets in itertools.groupby( - phenopacket_query_results(compiled_query, params), - key=lambda p: str(p.dataset_id) + table_id: { + "data_type": data_type, + "matches": list(serializer_class(p).data for p in table_objects) + } for table_id, table_objects in itertools.groupby( + query_function(compiled_query, params), + key=lambda o: str(o.table_id) ) }, start)) @@ -254,7 +344,7 @@ def chord_private_search(request): def phenopacket_filter_results(subject_ids, htsfile_ids, disease_ids, biosample_ids, - phenotypicfeature_ids, phenopacket_ids, prefetch=False): + phenotypicfeature_ids, phenopacket_ids): query = Phenopacket.objects.get_queryset() @@ -286,7 +376,7 @@ def fhir_search(request, internal_data=False): # TODO: not all that sure about the query format we'll want # keep it simple for now if "query" not in request.data: - return Response(bad_request_error("Missing query in request body"), status=400) + return Response(errors.bad_request_error("Missing query in request body"), status=400) query = request.data["query"] start = datetime.now() @@ -322,14 +412,14 @@ def hits_for(resource_type: str): if not internal_data: # TODO: Maybe can avoid hitting DB here - datasets = Dataset.objects.filter(identifier__in=frozenset(p.dataset_id for p in phenopackets)) - return Response(build_search_response([{"id": d.identifier, "data_type": PHENOPACKET_DATA_TYPE_ID} + datasets = Table.objects.filter(ownership_record_id__in=frozenset(p.table_id for p in phenopackets)) + return Response(build_search_response([{"id": d.identifier, "data_type": DATA_TYPE_PHENOPACKET} for d in datasets], start)) return Response(build_search_response({ - dataset_id: { - "data_type": PHENOPACKET_DATA_TYPE_ID, - "matches": list(PhenopacketSerializer(p).data for p in dataset_phenopackets) - } for dataset_id, dataset_phenopackets in itertools.groupby(phenopackets, key=lambda p: str(p.dataset_id)) + table_id: { + "data_type": DATA_TYPE_PHENOPACKET, + "matches": list(PhenopacketSerializer(p).data for p in table_phenopackets) + } for table_id, table_phenopackets in itertools.groupby(phenopackets, key=lambda p: str(p.table_id)) }, start)) @@ -353,22 +443,23 @@ def chord_table_search(request, table_id, internal=False): if request.data is None or "query" not in request.data: # TODO: Better error - return Response(bad_request_error("Missing query in request body"), status=400) + return Response(errors.bad_request_error("Missing query in request body"), status=400) # Check that dataset exists - dataset = Dataset.objects.get(identifier=table_id) + table = Table.objects.get(ownership_record_id=table_id) try: - compiled_query, params = postgres.search_query_to_psycopg2_sql(request.data["query"], PHENOPACKET_SCHEMA) + compiled_query, params = postgres.search_query_to_psycopg2_sql(request.data["query"], + DATA_TYPES[table.data_type]["schema"]) except (SyntaxError, TypeError, ValueError) as e: print("[CHORD Metadata] Error encountered compiling query {}:\n {}".format(request.data["query"], str(e))) - return Response(bad_request_error(f"Error compiling query (message: {str(e)})"), status=400) + return Response(errors.bad_request_error(f"Error compiling query (message: {str(e)})"), status=400) debug_log(f"Finished compiling query in {datetime.now() - start}") - query_results = phenopacket_query_results( - query=sql.SQL("{} AND dataset_id = {}").format(compiled_query, sql.Placeholder()), - params=params + (dataset.identifier,) + query_results = phenopacket_query_results( # TODO: Generic + query=sql.SQL("{} AND table_id = {}").format(compiled_query, sql.Placeholder()), + params=params + (table.identifier,) ) debug_log(f"Finished running query in {datetime.now() - start}") @@ -385,7 +476,7 @@ def chord_table_search(request, table_id, internal=False): @api_view(["POST"]) @permission_classes([AllowAny]) def chord_public_table_search(request, table_id): - # Search phenopacket data types in specific tables without leaking internal data + # Search data types in specific tables without leaking internal data return chord_table_search(request, table_id, internal=False) @@ -394,6 +485,6 @@ def chord_public_table_search(request, table_id): @api_view(["POST"]) @permission_classes([AllowAny]) def chord_private_table_search(request, table_id): - # Search phenopacket data types in specific tables + # Search data types in specific tables # Private search endpoints are protected by URL namespace, not by Django permissions. return chord_table_search(request, table_id, internal=True) diff --git a/chord_metadata_service/chord/workflows/experiments_json.wdl b/chord_metadata_service/chord/workflows/experiments_json.wdl new file mode 100644 index 000000000..e3817f973 --- /dev/null +++ b/chord_metadata_service/chord/workflows/experiments_json.wdl @@ -0,0 +1,17 @@ +workflow experiments_json { + File json_document + + call identity_task { + input: json_document_in = json_document + } +} + +task identity_task { + File json_document_in + command { + true + } + output { + File json_document = "${json_document_in}" + } +} diff --git a/chord_metadata_service/chord/workflows/fhir_json.wdl b/chord_metadata_service/chord/workflows/fhir_json.wdl new file mode 100644 index 000000000..e3b2bae78 --- /dev/null +++ b/chord_metadata_service/chord/workflows/fhir_json.wdl @@ -0,0 +1,48 @@ +workflow fhir_json { + File patients + File? observations + File? conditions + File? specimens + + call identity_task { + input: json_in = patients + } + + call optional_fhir_json_task as ofjt1 { + input: json_in = observations, file_name = "observations.json" + } + call optional_fhir_json_task as ofjt2 { + input: json_in = conditions, file_name = "conditions.json" + } + call optional_fhir_json_task as ofjt3 { + input: json_in = specimens, file_name = "specimens.json" + } +} + +task identity_task { + File json_in + + command { + true + } + + output { + File json_out = "${json_in}" + } +} + +task optional_fhir_json_task { + File? json_in + String file_name + + command <<< + if [[ -f "${json_in}" ]]; then + mv "${json_in}" "${file_name}"; + else + echo '{"resourceType": "bundle", "entry": []}' > "${file_name}"; + fi + >>> + output { + File json_out = "${file_name}" + } +} diff --git a/chord_metadata_service/chord/workflows/mcode_fhir_json.wdl b/chord_metadata_service/chord/workflows/mcode_fhir_json.wdl new file mode 100644 index 000000000..8ca83c578 --- /dev/null +++ b/chord_metadata_service/chord/workflows/mcode_fhir_json.wdl @@ -0,0 +1,20 @@ +workflow mcode_fhir_json { + File json_document + + call identity_task { + input: json_document_in = json_document + } + +} + +task identity_task { + File json_document_in + + command { + true + } + + output { + File json_document = "${json_document_in}" + } +} diff --git a/chord_metadata_service/experiments/api_views.py b/chord_metadata_service/experiments/api_views.py index 157980c75..1a9c0eb85 100644 --- a/chord_metadata_service/experiments/api_views.py +++ b/chord_metadata_service/experiments/api_views.py @@ -1,7 +1,12 @@ from rest_framework import viewsets from rest_framework.settings import api_settings +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + from .serializers import ExperimentSerializer from .models import Experiment +from .schemas import EXPERIMENT_SCHEMA from chord_metadata_service.restapi.pagination import LargeResultsSetPagination @@ -18,3 +23,13 @@ class ExperimentViewSet(viewsets.ModelViewSet): serializer_class = ExperimentSerializer pagination_class = LargeResultsSetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_experiment_schema(_request): + """ + get: + Experiment schema + """ + return Response(EXPERIMENT_SCHEMA) diff --git a/chord_metadata_service/experiments/descriptions.py b/chord_metadata_service/experiments/descriptions.py index 993acf257..b0656c2ef 100644 --- a/chord_metadata_service/experiments/descriptions.py +++ b/chord_metadata_service/experiments/descriptions.py @@ -1,22 +1,37 @@ -from chord_metadata_service.restapi.description_utils import EXTRA_PROPERTIES +from chord_metadata_service.restapi.description_utils import EXTRA_PROPERTIES, ontology_class + EXPERIMENT = { - "description": "A subject of a phenopacket, representing either a human (typically) or another organism.", + "description": "Experiment related metadata.", "properties": { "id": "An arbitrary identifier for the experiment.", - "reference_registry_id": "The IHEC EpiRR ID for this dataset, only for IHEC Reference Epigenome datasets. Otherwise leave empty.", - "qc_flags": "Any quanlity control observations can be noted here. This field can be omitted if empty", - "experiment_type": "(Controlled Vocabulary) The assay target (e.g. ‘DNA Methylation’, ‘mRNA-Seq’, ‘smRNA-Seq’, 'Histone H3K4me1').", - "experiment_ontology": "(Ontology: OBI) links to experiment ontology information.", - "molecule_ontology": "(Ontology: SO) links to molecule ontology information.", - "molecule": "(Controlled Vocabulary) The type of molecule that was extracted from the biological material. Include one of the following: total RNA, polyA RNA, cytoplasmic RNA, nuclear RNA, small RNA, genomic DNA, protein, or other.", - "library_strategy": "(Controlled Vocabulary) The assay used. These are defined within the SRA metadata specifications with a controlled vocabulary (e.g. ‘Bisulfite-Seq’, ‘RNA-Seq’, ‘ChIP-Seq’). For a complete list, see https://www.ebi.ac.uk/ena/submit/reads-library-strategy.", + "reference_registry_id": "The IHEC EpiRR ID for this dataset, only for IHEC Reference Epigenome datasets. " + "Otherwise leave empty.", + "qc_flags": { + "description": "Any quality control observations can be noted here. This field can be omitted if empty", + "items": "A quality control observation.", + }, + "experiment_type": "(Controlled Vocabulary) The assay target (e.g. ‘DNA Methylation’, ‘mRNA-Seq’, ‘smRNA-Seq’, " + "'Histone H3K4me1').", + "experiment_ontology": { + "description": "Links to experiment ontology information (e.g. via the OBI ontology.)", + "items": ontology_class("describing the experiment"), + }, + "molecule_ontology": { + "description": "Links to molecule ontology information (e.g. via the SO ontology.)", + "items": ontology_class("describing a molecular property"), + }, + "molecule": "(Controlled Vocabulary) The type of molecule that was extracted from the biological material." + "Include one of the following: total RNA, polyA RNA, cytoplasmic RNA, nuclear RNA, small RNA, " + "genomic DNA, protein, or other.", + "library_strategy": "(Controlled Vocabulary) The assay used. These are defined within the SRA metadata " + "specifications with a controlled vocabulary (e.g. ‘Bisulfite-Seq’, ‘RNA-Seq’, ‘ChIP-Seq’)." + " For a complete list, see https://www.ebi.ac.uk/ena/submit/reads-library-strategy.", - "other_fields": "The other fields for the experiment", + "other_fields": "The other fields for the experiment.", - "biosample": "Biosamples on which this experiment was done", - "individual": "Donor on which this experiment was done", + "biosample": "Biosample on which this experiment was done.", **EXTRA_PROPERTIES } diff --git a/chord_metadata_service/experiments/migrations/0001_initial.py b/chord_metadata_service/experiments/migrations/0001_initial.py deleted file mode 100644 index b854ba022..000000000 --- a/chord_metadata_service/experiments/migrations/0001_initial.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 2.2.11 on 2020-03-25 19:50 - -import chord_metadata_service.restapi.models -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Experiment', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the experiment.', max_length=200, primary_key=True, serialize=False)), - ('reference_registry_id', models.CharField(blank=True, help_text='The IHEC EpiRR ID for this dataset, only for IHEC Reference Epigenome datasets. Otherwise leave empty.', max_length=30, null=True)), - ('qc_flags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(help_text='Any quanlity control observations can be noted here. This field can be omitted if empty', max_length=100), default=list, null=True, size=None)), - ('experiment_type', models.CharField(help_text="(Controlled Vocabulary) The assay target (e.g. ‘DNA Methylation’, ‘mRNA-Seq’, ‘smRNA-Seq’, 'Histone H3K4me1').", max_length=30)), - ('experiment_ontology', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='(Ontology: OBI) links to experiment ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'ONTOLOGY_CLASS', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})])), - ('molecule_ontology', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='(Ontology: SO) links to molecule ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'ONTOLOGY_CLASS', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})])), - ('molecule', models.CharField(blank=True, choices=[('total RNA', 'total RNA'), ('polyA RNA', 'polyA RNA'), ('cytoplasmic RNA', 'cytoplasmic RNA'), ('nuclear RNA', 'nuclear RNA'), ('small RNA', 'small RNA'), ('genomic DNA', 'genomic DNA'), ('protein', 'protein'), ('other', 'other')], help_text='(Controlled Vocabulary) The type of molecule that was extracted from the biological material. Include one of the following: total RNA, polyA RNA, cytoplasmic RNA, nuclear RNA, small RNA, genomic DNA, protein, or other.', max_length=20, null=True)), - ('library_strategy', models.CharField(choices=[('DNase-Hypersensitivity', 'DNase-Hypersensitivity'), ('ATAC-seq', 'ATAC-seq'), ('NOME-Seq', 'NOME-Seq'), ('Bisulfite-Seq', 'Bisulfite-Seq'), ('MeDIP-Seq', 'MeDIP-Seq'), ('MRE-Seq', 'MRE-Seq'), ('ChIP-Seq', 'ChIP-Seq'), ('RNA-Seq', 'RNA-Seq'), ('miRNA-Seq', 'miRNA-Seq'), ('WGS', 'WGS')], help_text='(Controlled Vocabulary) The assay used. These are defined within the SRA metadata specifications with a controlled vocabulary (e.g. ‘Bisulfite-Seq’, ‘RNA-Seq’, ‘ChIP-Seq’). For a complete list, see https://www.ebi.ac.uk/ena/submit/reads-library-strategy.', max_length=25)), - ('other_fields', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The other fields for the experiment', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'KEY_VALUE_OBJECT', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'The schema represents a key-value object.', 'patternProperties': {'^.*$': {'type': 'string'}}, 'title': 'Key-value object', 'type': 'object'})])), - ], - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - ] diff --git a/chord_metadata_service/experiments/migrations/0001_v1_0_0.py b/chord_metadata_service/experiments/migrations/0001_v1_0_0.py new file mode 100644 index 000000000..3bf230ccc --- /dev/null +++ b/chord_metadata_service/experiments/migrations/0001_v1_0_0.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.13 on 2020-07-06 14:55 + +import chord_metadata_service.restapi.models +import chord_metadata_service.restapi.validators +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('chord', '0001_v1_0_0'), + ('phenopackets', '0001_v1_0_0'), + ] + + operations = [ + migrations.CreateModel( + name='Experiment', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the experiment.', max_length=200, primary_key=True, serialize=False)), + ('reference_registry_id', models.CharField(blank=True, help_text='The IHEC EpiRR ID for this dataset, only for IHEC Reference Epigenome datasets. Otherwise leave empty.', max_length=30, null=True)), + ('qc_flags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(help_text='Any quality control observations can be noted here. This field can be omitted if empty', max_length=100), blank=True, default=list, size=None)), + ('experiment_type', models.CharField(help_text="(Controlled Vocabulary) The assay target (e.g. ‘DNA Methylation’, ‘mRNA-Seq’, ‘smRNA-Seq’, 'Histone H3K4me1').", max_length=30)), + ('experiment_ontology', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Links to experiment ontology information (e.g. via the OBI ontology.)', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('molecule_ontology', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='Links to molecule ontology information (e.g. via the SO ontology.)', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('molecule', models.CharField(blank=True, choices=[('total RNA', 'total RNA'), ('polyA RNA', 'polyA RNA'), ('cytoplasmic RNA', 'cytoplasmic RNA'), ('nuclear RNA', 'nuclear RNA'), ('small RNA', 'small RNA'), ('genomic DNA', 'genomic DNA'), ('protein', 'protein'), ('other', 'other')], help_text='(Controlled Vocabulary) The type of molecule that was extracted from the biological material.Include one of the following: total RNA, polyA RNA, cytoplasmic RNA, nuclear RNA, small RNA, genomic DNA, protein, or other.', max_length=20, null=True)), + ('library_strategy', models.CharField(choices=[('DNase-Hypersensitivity', 'DNase-Hypersensitivity'), ('ATAC-seq', 'ATAC-seq'), ('NOME-Seq', 'NOME-Seq'), ('Bisulfite-Seq', 'Bisulfite-Seq'), ('MeDIP-Seq', 'MeDIP-Seq'), ('MRE-Seq', 'MRE-Seq'), ('ChIP-Seq', 'ChIP-Seq'), ('RNA-Seq', 'RNA-Seq'), ('miRNA-Seq', 'miRNA-Seq'), ('WGS', 'WGS')], help_text='(Controlled Vocabulary) The assay used. These are defined within the SRA metadata specifications with a controlled vocabulary (e.g. ‘Bisulfite-Seq’, ‘RNA-Seq’, ‘ChIP-Seq’). For a complete list, see https://www.ebi.ac.uk/ena/submit/reads-library-strategy.', max_length=25)), + ('other_fields', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, help_text='The other fields for the experiment.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:key_value_object_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'The schema represents a key-value object.', 'patternProperties': {'^.*$': {'type': 'string'}}, 'title': 'Key-value object', 'type': 'object'}, formats=None)])), + ('biosample', models.ForeignKey(help_text='Biosample on which this experiment was done.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Biosample')), + ('table', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chord.Table')), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + ] diff --git a/chord_metadata_service/experiments/migrations/0002_auto_20200327_1728.py b/chord_metadata_service/experiments/migrations/0002_auto_20200327_1728.py deleted file mode 100644 index a651e05d5..000000000 --- a/chord_metadata_service/experiments/migrations/0002_auto_20200327_1728.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 2.2.11 on 2020-03-27 17:28 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('experiments', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='experiment', - name='experiment_ontology', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='(Ontology: OBI) links to experiment ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'ONTOLOGY_CLASS', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})]), - ), - migrations.AlterField( - model_name='experiment', - name='molecule', - field=models.CharField(choices=[('total RNA', 'total RNA'), ('polyA RNA', 'polyA RNA'), ('cytoplasmic RNA', 'cytoplasmic RNA'), ('nuclear RNA', 'nuclear RNA'), ('small RNA', 'small RNA'), ('genomic DNA', 'genomic DNA'), ('protein', 'protein'), ('other', 'other')], help_text='(Controlled Vocabulary) The type of molecule that was extracted from the biological material. Include one of the following: total RNA, polyA RNA, cytoplasmic RNA, nuclear RNA, small RNA, genomic DNA, protein, or other.', max_length=20, null=True), - ), - migrations.AlterField( - model_name='experiment', - name='molecule_ontology', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='(Ontology: SO) links to molecule ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'ONTOLOGY_CLASS', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})]), - ), - migrations.AlterField( - model_name='experiment', - name='other_fields', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The other fields for the experiment', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'KEY_VALUE_OBJECT', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'The schema represents a key-value object.', 'patternProperties': {'^.*$': {'type': 'string'}}, 'title': 'Key-value object', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='experiment', - name='reference_registry_id', - field=models.CharField(help_text='The IHEC EpiRR ID for this dataset, only for IHEC Reference Epigenome datasets. Otherwise leave empty.', max_length=30, null=True), - ), - ] diff --git a/chord_metadata_service/experiments/migrations/0003_auto_20200331_1841.py b/chord_metadata_service/experiments/migrations/0003_auto_20200331_1841.py deleted file mode 100644 index 6a16c6c2b..000000000 --- a/chord_metadata_service/experiments/migrations/0003_auto_20200331_1841.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 2.2.11 on 2020-03-31 18:41 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0004_auto_20200129_1537'), - ('phenopackets', '0004_auto_20200129_1537'), - ('experiments', '0002_auto_20200327_1728'), - ] - - operations = [ - migrations.AddField( - model_name='experiment', - name='biosample', - field=models.ForeignKey(blank=True, help_text='Biosamples on which this experiment was done', null=True, on_delete=django.db.models.deletion.SET_NULL, to='phenopackets.Biosample'), - ), - migrations.AddField( - model_name='experiment', - name='individual', - field=models.ForeignKey(blank=True, help_text='Donor on which this experiment was done', null=True, on_delete=django.db.models.deletion.SET_NULL, to='patients.Individual'), - ), - migrations.AlterField( - model_name='experiment', - name='experiment_ontology', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='(Ontology: OBI) links to experiment ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'ONTOLOGY_CLASS', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})]), - ), - migrations.AlterField( - model_name='experiment', - name='molecule', - field=models.CharField(blank=True, choices=[('total RNA', 'total RNA'), ('polyA RNA', 'polyA RNA'), ('cytoplasmic RNA', 'cytoplasmic RNA'), ('nuclear RNA', 'nuclear RNA'), ('small RNA', 'small RNA'), ('genomic DNA', 'genomic DNA'), ('protein', 'protein'), ('other', 'other')], help_text='(Controlled Vocabulary) The type of molecule that was extracted from the biological material. Include one of the following: total RNA, polyA RNA, cytoplasmic RNA, nuclear RNA, small RNA, genomic DNA, protein, or other.', max_length=20, null=True), - ), - migrations.AlterField( - model_name='experiment', - name='molecule_ontology', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='(Ontology: SO) links to molecule ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'ONTOLOGY_CLASS', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})]), - ), - migrations.AlterField( - model_name='experiment', - name='other_fields', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The other fields for the experiment', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'KEY_VALUE_OBJECT', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'The schema represents a key-value object.', 'patternProperties': {'^.*$': {'type': 'string'}}, 'title': 'Key-value object', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='experiment', - name='qc_flags', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(help_text='Any quanlity control observations can be noted here. This field can be omitted if empty', max_length=100), blank=True, default=list, null=True, size=None), - ), - migrations.AlterField( - model_name='experiment', - name='reference_registry_id', - field=models.CharField(blank=True, help_text='The IHEC EpiRR ID for this dataset, only for IHEC Reference Epigenome datasets. Otherwise leave empty.', max_length=30, null=True), - ), - ] diff --git a/chord_metadata_service/experiments/migrations/0004_auto_20200401_1445.py b/chord_metadata_service/experiments/migrations/0004_auto_20200401_1445.py deleted file mode 100644 index 2acccced4..000000000 --- a/chord_metadata_service/experiments/migrations/0004_auto_20200401_1445.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.10 on 2020-04-01 18:45 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('experiments', '0003_auto_20200331_1841'), - ] - - operations = [ - migrations.AlterField( - model_name='experiment', - name='experiment_ontology', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='(Ontology: OBI) links to experiment ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})]), - ), - migrations.AlterField( - model_name='experiment', - name='molecule_ontology', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='(Ontology: SO) links to molecule ontology information.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})]), - ), - ] diff --git a/chord_metadata_service/experiments/models.py b/chord_metadata_service/experiments/models.py index 8e0a0bc22..b70bf1563 100644 --- a/chord_metadata_service/experiments/models.py +++ b/chord_metadata_service/experiments/models.py @@ -1,14 +1,21 @@ from django.db import models from django.db.models import CharField -from django.core.exceptions import ValidationError from django.contrib.postgres.fields import JSONField, ArrayField from chord_metadata_service.restapi.models import IndexableMixin from chord_metadata_service.restapi.description_utils import rec_help from chord_metadata_service.restapi.validators import ontology_list_validator, key_value_validator -from chord_metadata_service.patients.models import Individual from chord_metadata_service.phenopackets.models import Biosample import chord_metadata_service.experiments.descriptions as d + +__all__ = ["Experiment"] + + +# The experiment class here is primarily designed for *genomic* experiments - thus the need for a biosample ID. If, in +# the future, medical imaging or something which isn't sample-based is desired, it may be best to create a separate +# model for the desired purposes. + + class Experiment(models.Model, IndexableMixin): """ Class to store Experiment information """ @@ -38,22 +45,25 @@ class Experiment(models.Model, IndexableMixin): id = CharField(primary_key=True, max_length=200, help_text=rec_help(d.EXPERIMENT, 'id')) - reference_registry_id = CharField(max_length=30, blank=True, null=True, help_text=rec_help(d.EXPERIMENT, 'reference_registry_id')) - qc_flags = ArrayField(CharField(max_length=100, help_text=rec_help(d.EXPERIMENT, 'qc_flags')), null=True, blank=True, default=list) + reference_registry_id = CharField(max_length=30, blank=True, null=True, + help_text=rec_help(d.EXPERIMENT, 'reference_registry_id')) + qc_flags = ArrayField(CharField(max_length=100, help_text=rec_help(d.EXPERIMENT, 'qc_flags')), + blank=True, default=list) experiment_type = CharField(max_length=30, help_text=rec_help(d.EXPERIMENT, 'experiment_type')) - experiment_ontology = JSONField(blank=True, null=True, validators=[ontology_list_validator], help_text=rec_help(d.EXPERIMENT, 'experiment_ontology')) - molecule_ontology = JSONField(blank=True, null=True, validators=[ontology_list_validator], help_text=rec_help(d.EXPERIMENT, 'molecule_ontology')) - molecule = CharField(choices=MOLECULE, max_length=20, blank=True, null=True, help_text=rec_help(d.EXPERIMENT, 'molecule')) - library_strategy = CharField(choices=LIBRARY_STRATEGY, max_length=25, help_text=rec_help(d.EXPERIMENT, 'library_strategy')) - - other_fields = JSONField(blank=True, null=True, validators=[key_value_validator], help_text=rec_help(d.EXPERIMENT, 'other_fields')) + experiment_ontology = JSONField(blank=True, default=list, validators=[ontology_list_validator], + help_text=rec_help(d.EXPERIMENT, 'experiment_ontology')) + molecule_ontology = JSONField(blank=True, default=list, validators=[ontology_list_validator], + help_text=rec_help(d.EXPERIMENT, 'molecule_ontology')) + molecule = CharField(choices=MOLECULE, max_length=20, blank=True, null=True, + help_text=rec_help(d.EXPERIMENT, 'molecule')) + library_strategy = CharField(choices=LIBRARY_STRATEGY, max_length=25, + help_text=rec_help(d.EXPERIMENT, 'library_strategy')) - biosample = models.ForeignKey(Biosample, on_delete=models.SET_NULL, blank=True, null=True, help_text=rec_help(d.EXPERIMENT, 'biosample')) - individual = models.ForeignKey(Individual, on_delete=models.SET_NULL, blank=True, null=True, help_text=rec_help(d.EXPERIMENT, 'individual')) + other_fields = JSONField(blank=True, default=dict, validators=[key_value_validator], + help_text=rec_help(d.EXPERIMENT, 'other_fields')) - def clean(self): - if not (self.biosample or self.individual): - raise ValidationError('Either Biosamples or Individual must be specified') + biosample = models.ForeignKey(Biosample, on_delete=models.CASCADE, help_text=rec_help(d.EXPERIMENT, 'biosample')) + table = models.ForeignKey("chord.Table", on_delete=models.CASCADE, blank=True, null=True) # TODO: Help text def __str__(self): return str(self.id) diff --git a/chord_metadata_service/experiments/schemas.py b/chord_metadata_service/experiments/schemas.py new file mode 100644 index 000000000..dc39041c1 --- /dev/null +++ b/chord_metadata_service/experiments/schemas.py @@ -0,0 +1,67 @@ +from .descriptions import EXPERIMENT +from chord_metadata_service.restapi.description_utils import describe_schema +from chord_metadata_service.restapi.schemas import ONTOLOGY_CLASS_LIST, KEY_VALUE_OBJECT + + +__all__ = ["EXPERIMENT_SCHEMA"] + + +EXPERIMENT_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:experiment_schema", + "title": "Experiment schema", + "description": "Schema for describing an experiment.", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reference_registry_id": { + "type": "string" + }, + "qc_flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "experiment_type": { + "type": "string" + }, + "experiment_ontology": ONTOLOGY_CLASS_LIST, + "molecule": { + "type": "string", + "enum": [ + "total RNA", + "polyA RNA", + "cytoplasmic RNA", + "nuclear RNA", + "small RNA", + "genomic DNA", + "protein", + "other", + ] + }, + "molecule_ontology": ONTOLOGY_CLASS_LIST, + "library_strategy": { + "type": "string", + "enum": [ + "DNase-Hypersensitivity", + "ATAC-seq", + "NOME-Seq", + "Bisulfite-Seq", + "MeDIP-Seq", + "MRE-Seq", + "ChIP-Seq", + "RNA-Seq", + "miRNA-Seq", + "WGS", + ] + }, + "other_fields": KEY_VALUE_OBJECT, + "biosample": { + "type": "string" + }, + }, + "required": ["id", "experiment_type", "library_strategy"] +}, EXPERIMENT) diff --git a/chord_metadata_service/experiments/search_schemas.py b/chord_metadata_service/experiments/search_schemas.py new file mode 100644 index 000000000..f86e635f5 --- /dev/null +++ b/chord_metadata_service/experiments/search_schemas.py @@ -0,0 +1,55 @@ +from . import models, schemas +from chord_metadata_service.restapi.schema_utils import ( + search_optional_eq, + search_optional_str, + tag_schema_with_search_properties, +) +from chord_metadata_service.restapi.search_schemas import ONTOLOGY_SEARCH_SCHEMA + + +__all__ = ["EXPERIMENT_SEARCH_SCHEMA"] + + +EXPERIMENT_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.EXPERIMENT_SCHEMA, { + "properties": { + "id": { + "search": {"order": 0, "database": {"field": models.Experiment._meta.pk.column}} + }, + "reference_registry_id": { + "search": search_optional_str(1, queryable="internal"), + }, + "qc_flags": { + "items": { + "search": search_optional_str(0), + }, + "search": {"order": 2, "database": {"type": "array"}} + }, + "experiment_type": { + "search": search_optional_str(3), + }, + "experiment_ontology": { + "items": ONTOLOGY_SEARCH_SCHEMA, # TODO: Specific ontology? + "search": {"order": 4, "database": {"type": "jsonb"}} + }, + "molecule": { + "search": search_optional_eq(5), + }, + "molecule_ontology": { + "items": ONTOLOGY_SEARCH_SCHEMA, # TODO: Specific ontology? + "search": {"order": 6, "database": {"type": "jsonb"}} + }, + "library_strategy": { + "search": search_optional_eq(7), + }, + # TODO: other_fields: ? + "biosample": { + "search": search_optional_eq(8, queryable="internal"), + }, + }, + "search": { + "database": { + "relation": models.Experiment._meta.db_table, + "primary_key": models.Experiment._meta.pk.column, + } + } +}) diff --git a/chord_metadata_service/experiments/serializers.py b/chord_metadata_service/experiments/serializers.py index 22a0d5014..23ac230b6 100644 --- a/chord_metadata_service/experiments/serializers.py +++ b/chord_metadata_service/experiments/serializers.py @@ -2,6 +2,9 @@ from .models import Experiment +__all__ = ["ExperimentSerializer"] + + class ExperimentSerializer(GenericSerializer): class Meta: model = Experiment diff --git a/chord_metadata_service/experiments/tests/test_models.py b/chord_metadata_service/experiments/tests/test_models.py index cd5e5b947..fefd29891 100644 --- a/chord_metadata_service/experiments/tests/test_models.py +++ b/chord_metadata_service/experiments/tests/test_models.py @@ -2,14 +2,18 @@ from django.core.exceptions import ValidationError from rest_framework import serializers from chord_metadata_service.patients.models import Individual +from chord_metadata_service.phenopackets.models import Biosample, Procedure from ..models import Experiment +from chord_metadata_service.phenopackets.tests.constants import VALID_PROCEDURE_1, VALID_INDIVIDUAL_1, valid_biosample_1 class ExperimentTest(TestCase): """ Test module for Experiment model """ def setUp(self): - Individual.objects.create(id='patient:1', sex='FEMALE', age={"age": "P25Y3M2D"}) + i = Individual.objects.create(**VALID_INDIVIDUAL_1) + p = Procedure.objects.create(**VALID_PROCEDURE_1) + self.biosample = Biosample.objects.create(**valid_biosample_1(i, p)) Experiment.objects.create( id='experiment:1', reference_registry_id='some_id', @@ -19,47 +23,50 @@ def setUp(self): molecule_ontology=[{"id": "ontology:1", "label": "Ontology term 1"}], molecule='total RNA', library_strategy='Bisulfite-Seq', - other_fields={"some_field": "value"} + other_fields={"some_field": "value"}, + biosample=self.biosample ) def create(self, **kwargs): - e = Experiment(**kwargs) + e = Experiment(id="experiment:2", **kwargs) e.full_clean() e.save() def test_validation(self): - individual_one = Individual.objects.get(id='patient:1') - # Invalid experiment_ontology - self.assertRaises(serializers.ValidationError, self.create, - id='experiment:2', - library_strategy='Bisulfite-Seq', - experiment_type='Chromatin Accessibility', - experiment_ontology=["invalid_value"], - individual=individual_one + self.assertRaises( + serializers.ValidationError, + self.create, + library_strategy='Bisulfite-Seq', + experiment_type='Chromatin Accessibility', + experiment_ontology=["invalid_value"], + biosample=self.biosample ) # Invalid molecule_ontology - self.assertRaises(serializers.ValidationError, self.create, - id='experiment:2', - library_strategy='Bisulfite-Seq', - experiment_type='Chromatin Accessibility', - molecule_ontology=[{"id": "some_id"}], - individual=individual_one + self.assertRaises( + serializers.ValidationError, + self.create, + library_strategy='Bisulfite-Seq', + experiment_type='Chromatin Accessibility', + molecule_ontology=[{"id": "some_id"}], + biosample=self.biosample ) # Invalid value in other_fields - self.assertRaises(serializers.ValidationError, self.create, - id='experiment:2', - library_strategy='Bisulfite-Seq', - experiment_type='Chromatin Accessibility', - other_fields={"some_field": "value", "invalid_value": 42}, - individual=individual_one + self.assertRaises( + serializers.ValidationError, + self.create, + library_strategy='Bisulfite-Seq', + experiment_type='Chromatin Accessibility', + other_fields={"some_field": "value", "invalid_value": 42}, + biosample=self.biosample ) - # Missing individual or biosamples - self.assertRaises(ValidationError, self.create, - id='experiment:2', - library_strategy='Bisulfite-Seq', - experiment_type='Chromatin Accessibility' + # Missing biosample + self.assertRaises( + ValidationError, + self.create, + library_strategy='Bisulfite-Seq', + experiment_type='Chromatin Accessibility' ) diff --git a/chord_metadata_service/mcode/admin.py b/chord_metadata_service/mcode/admin.py index d103f5229..78355dfcb 100644 --- a/chord_metadata_service/mcode/admin.py +++ b/chord_metadata_service/mcode/admin.py @@ -1,44 +1,52 @@ from django.contrib import admin -from .models import * +from . import models as m -@admin.register(GeneticVariantTested) -class GeneticVariantTestedAdmin(admin.ModelAdmin): +@admin.register(m.GeneticSpecimen) +class GeneticSpecimenAdmin(admin.ModelAdmin): pass -@admin.register(GeneticVariantFound) -class GeneticVariantFoundAdmin(admin.ModelAdmin): +@admin.register(m.CancerGeneticVariant) +class CancerGeneticVariantAdmin(admin.ModelAdmin): pass -@admin.register(GenomicsReport) +@admin.register(m.GenomicRegionStudied) +class GenomicRegionStudiedAdmin(admin.ModelAdmin): + pass + + +@admin.register(m.GenomicsReport) class GenomicsReportAdmin(admin.ModelAdmin): pass -@admin.register(LabsVital) +@admin.register(m.LabsVital) class LabsVitalAdmin(admin.ModelAdmin): pass -@admin.register(CancerCondition) +@admin.register(m.CancerCondition) class CancerConditionAdmin(admin.ModelAdmin): pass -@admin.register(TNMStaging) +@admin.register(m.TNMStaging) class TNMStagingAdmin(admin.ModelAdmin): pass -@admin.register(CancerRelatedProcedure) +@admin.register(m.CancerRelatedProcedure) class CancerRelatedProcedureAdmin(admin.ModelAdmin): pass -@admin.register(MedicationStatement) +@admin.register(m.MedicationStatement) class MedicationStatementAdmin(admin.ModelAdmin): pass +@admin.register(m.MCodePacket) +class MCodePacketAdmin(admin.ModelAdmin): + pass diff --git a/chord_metadata_service/mcode/api_views.py b/chord_metadata_service/mcode/api_views.py index 4e3c26105..67fb3df1d 100644 --- a/chord_metadata_service/mcode/api_views.py +++ b/chord_metadata_service/mcode/api_views.py @@ -1,9 +1,11 @@ from rest_framework import viewsets from rest_framework.settings import api_settings -from .serializers import * -from chord_metadata_service.restapi.api_renderers import ( - PhenopacketsRenderer -) +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from .schemas import MCODE_SCHEMA +from . import models as m, serializers as s +from chord_metadata_service.restapi.api_renderers import PhenopacketsRenderer from chord_metadata_service.restapi.pagination import LargeResultsSetPagination @@ -12,46 +14,61 @@ class McodeModelViewSet(viewsets.ModelViewSet): renderer_classes = (*api_settings.DEFAULT_RENDERER_CLASSES, PhenopacketsRenderer) -class GeneticVariantTestedViewSet(McodeModelViewSet): - queryset = GeneticVariantTested.objects.all() - serializer_class = GeneticVariantTestedSerializer +class GeneticSpecimenViewSet(McodeModelViewSet): + queryset = m.GeneticSpecimen.objects.all() + serializer_class = s.GeneticSpecimenSerializer -class GeneticVariantFoundViewSet(McodeModelViewSet): - queryset = GeneticVariantFound.objects.all() - serializer_class = GeneticVariantFoundSerializer +class CancerGeneticVariantViewSet(McodeModelViewSet): + queryset = m.CancerGeneticVariant.objects.all() + serializer_class = s.CancerGeneticVariantSerializer + + +class GenomicRegionStudiedViewSet(McodeModelViewSet): + queryset = m.GenomicRegionStudied.objects.all() + serializer_class = s.GenomicRegionStudiedSerializer class GenomicsReportViewSet(McodeModelViewSet): - queryset = GenomicsReport.objects.all() - serializer_class = GenomicsReportSerializer + queryset = m.GenomicsReport.objects.all() + serializer_class = s.GenomicsReportSerializer class LabsVitalViewSet(McodeModelViewSet): - queryset = LabsVital.objects.all() - serializer_class = LabsVitalSerializer + queryset = m.LabsVital.objects.all() + serializer_class = s.LabsVitalSerializer class CancerConditionViewSet(McodeModelViewSet): - queryset = CancerCondition.objects.all() - serializer_class = CancerConditionSerializer + queryset = m.CancerCondition.objects.all() + serializer_class = s.CancerConditionSerializer class TNMStagingViewSet(McodeModelViewSet): - queryset = TNMStaging.objects.all() - serializer_class = TNMStagingSerializer + queryset = m.TNMStaging.objects.all() + serializer_class = s.TNMStagingSerializer class CancerRelatedProcedureViewSet(McodeModelViewSet): - queryset = CancerRelatedProcedure.objects.all() - serializer_class = CancerRelatedProcedureSerializer + queryset = m.CancerRelatedProcedure.objects.all() + serializer_class = s.CancerRelatedProcedureSerializer class MedicationStatementViewSet(McodeModelViewSet): - queryset = MedicationStatement.objects.all() - serializer_class = MedicationStatementSerializer + queryset = m.MedicationStatement.objects.all() + serializer_class = s.MedicationStatementSerializer class MCodePacketViewSet(McodeModelViewSet): - queryset = MCodePacket.objects.all() - serializer_class = MCodePacketSerializer + queryset = m.MCodePacket.objects.all() + serializer_class = s.MCodePacketSerializer + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_mcode_schema(_request): + """ + get: + Mcodepacket schema + """ + return Response(MCODE_SCHEMA) diff --git a/chord_metadata_service/mcode/descriptions.py b/chord_metadata_service/mcode/descriptions.py index 550c39ef6..3f4d9b447 100644 --- a/chord_metadata_service/mcode/descriptions.py +++ b/chord_metadata_service/mcode/descriptions.py @@ -1,44 +1,58 @@ # Most parts of this text are taken from the mCODE:Minimal Common Oncology Data Elements Data Dictionary. -# The mCODE is made available under the Creative Commons 0 "No Rights Reserved" license https://creativecommons.org/share-your-work/public-domain/cc0/ +# The mCODE is made available under the Creative Commons 0 "No Rights Reserved" license +# https://creativecommons.org/share-your-work/public-domain/cc0/ # Portions of this text copyright (c) 2019-2020 the Canadian Centre for Computational Genomics; licensed under the # GNU Lesser General Public License version 3. from chord_metadata_service.restapi.description_utils import EXTRA_PROPERTIES -GENETIC_VARIANT_TESTED = { - "description": "A description of an alteration in the most common DNA nucleotide sequence.", +GENETIC_SPECIMEN = { + "description": "Class to describe a biosample used for genomics testing or analysis.", "properties": { - "id": "An arbitrary identifier for the genetic variant tested.", - "gene_studied": "A gene targeted for mutation analysis, identified in HUGO Gene Nomenclature Committee " - "(HGNC) notation.", - "method": "An ontology or controlled vocabulary term to identify the method used to perform the genetic test. " - "Accepted value set: NCIT.", - "variant_tested_identifier": "The variation ID assigned by HGVS, for example, 360448 is the identifier for " - "NM_005228.4(EGFR):c.-237A>G (single nucleotide variant in EGFR).", - "variant_tested_hgvs_name": "Symbolic representation of the variant used in HGVS, for example, " - "NM_005228.4(EGFR):c.-237A>G for HVGS variation ID 360448.", - "variant_tested_description": "Description of the variant.", - "data_value": "An ontology or controlled vocabulary term to identify positive or negative value for" - "the mutation. Accepted value set: SNOMED CT.", + "id": "An arbitrary identifier for the genetic specimen.", + "specimen_type": "The kind of material that forms the specimen.", + "collection_body": "The anatomical collection site.", + "laterality": "Body side of the collection site, if needed to distinguish from a similar " + "location on the other side of the body.", **EXTRA_PROPERTIES } } -GENETIC_VARIANT_FOUND = { - "description": "Description of single discrete variant tested.", +CANCER_GENETIC_VARIANT = { + "description": "Class to record an alteration in DNA.", "properties": { - "id": "An arbitrary identifier for the genetic variant found.", - "method": "An ontology or controlled vocabulary term to identify the method used to perform the genetic test. " - "Accepted value set: NCIT.", - "variant_found_identifier": "The variation ID assigned by HGVS, for example, 360448 is the identifier for " - "NM_005228.4(EGFR):c.-237A>G (single nucleotide variant in EGFR). " - "Accepted value set: ClinVar.", - "variant_found_hgvs_name": "Symbolic representation of the variant used in HGVS, for example, " - "NM_005228.4(EGFR):c.-237A>G for HVGS variation ID 360448.", - "variant_found_description": "Description of the variant.", - "genomic_source_class": "An ontology or controlled vocabulary term to identify the genomic class of the " - "specimen being analyzed.", + "id": "An arbitrary identifier for the cancer genetic variant.", + "data_value": "The overall result of the genetic test; specifically, whether a variant is present, " + "absent, no call, or indeterminant.", + "method": "The method used to perform the genetic test.", + "amino_acid_change": "The symbolic representation of an amino acid variant reported using " + "HGVS nomenclature (pHGVS).", + "amino_acid_change_type": "The type of change related to the amino acid variant.", + "cytogenetic_location": "The cytogenetic (chromosome) location.", + "cytogenetic_nomenclature": "The cytogenetic (chromosome) location, represented using the International " + "System for Human Cytogenetic Nomenclature (ISCN).", + "gene_studied": "A gene targeted for mutation analysis, identified in " + "HUGO Gene Nomenclature Committee (HGNC) notation.", + "genomic_dna_change": "The symbolic representation of a genetic structural variant reported " + "using HGVS nomenclature (gHGVS).", + "genomic_source_class": "The genomic class of the specimen being analyzed, for example, germline for " + "inherited genome, somatic for cancer genome, and prenatal for fetal genome.", + "variation_code": "The variation ID assigned by ClinVar.", + **EXTRA_PROPERTIES + } +} + +GENOMIC_REGION_STUDIED = { + "description": "Class to describe the area of the genome region referenced in testing for variants.", + "properties": { + "id": "An arbitrary identifier for the genomic region studied.", + "dna_ranges_examined": "The range(s) of the DNA sequence examined.", + "dna_region_description": "The description for the DNA region studied in the genomics report.", + "gene_mutation": "The gene mutations tested for in blood or tissue by molecular genetics methods.", + "gene_studied": "The ID for the gene studied.", + "genomic_reference_sequence_id": "Range(s) of DNA sequence examined.", + "genomic_region_coordinate_system": "The method of counting along the genome.", **EXTRA_PROPERTIES } } @@ -47,13 +61,13 @@ "description": "Genetic Analysis Summary.", "properties": { "id": "An arbitrary identifier for the genetics report.", - "test_name": "An ontology or controlled vocabulary term to identify the laboratory test. " - "Accepted value sets: LOINC, GTR.", + "code": "An ontology or controlled vocabulary term to identify the laboratory test. " + "Accepted value sets: LOINC, GTR.", "performing_organization_name": "The name of the organization producing the genomics report.", - "specimen_type": "An ontology or controlled vocabulary term to identify the type of material the specimen " - "contains or consists of. Accepted value set: HL7 Version 2 and Specimen Type.", - "genetic_variant_tested": "A test for a specific mutation on a particular gene.", - "genetic_variant_found": "Records an alteration in the most common DNA nucleotide sequence.", + "issued": "The date/time this report was issued.", + "genetic_specimen": "List of related genetic specimens.", + "genetic_variant": "Related genetic variant.", + "genomic_region_studied": "Related genomic region studied.", **EXTRA_PROPERTIES } } @@ -63,16 +77,8 @@ "properties": { "id": "An arbitrary identifier for the labs/vital tests.", "individual": "The individual who is the subject of the tests.", - "body_height": "The patient\'s height.", - "body_weight": "The patient\'s weight.", - "cbc_with_auto_differential_panel": "Reference to a laboratory observation in the CBC with Auto Differential" - "Panel test.", - "comprehensive_metabolic_2000": "Reference to a laboratory observation in the CMP 2000 test.", - "blood_pressure_diastolic": "The blood pressure after the contraction of the heart while the chambers of " - "the heart refill with blood, when the pressure is lowest.", - "blood_pressure_systolic": "The blood pressure during the contraction of the left ventricle of the heart, " - "when blood pressure is at its highest.", - "tumor_marker_test": "An ontology or controlled vocabulary term to identify tumor marker test.", + "tumor_marker_code": "A code identifying the type of tumor marker test.", + "tumor_marker_data_value": "The result of a tumor marker test.", **EXTRA_PROPERTIES } } @@ -82,16 +88,20 @@ "properties": { "id": "An arbitrary identifier for the cancer condition.", "condition_type": "Cancer condition type: primary or secondary.", - "body_location_code": "Code for the body location, optionally pre-coordinating laterality or direction. " - "Accepted ontologies: SNOMED CT, ICD-O-3 and others.", + "body_site": "Code for the body location, optionally pre-coordinating laterality or direction. " + "Accepted ontologies: SNOMED CT, ICD-O-3 and others.", + "laterality": "Body side of the body location, if needed to distinguish from a similar location " + "on the other side of the body.", "clinical_status": "A flag indicating whether the condition is active or inactive, recurring, in remission, " "or resolved (as of the last update of the Condition). Accepted code system: " "http://terminology.hl7.org/CodeSystem/condition-clinical", - "condition_code": "A code describing the type of primary or secondary malignant neoplastic disease.", + "code": "A code describing the type of primary or secondary malignant neoplastic disease.", "date_of_diagnosis": "The date the disease was first clinically recognized with sufficient certainty, " "regardless of whether it was fully characterized at that time.", "histology_morphology_behavior": "A description of the morphologic and behavioral characteristics of " "the cancer. Accepted ontologies: SNOMED CT, ICD-O-3 and others.", + "verification_status": "A flag indicating whether the condition is unconfirmed, provisional, differential, " + "confirmed, refuted, or entered-in-error.", **EXTRA_PROPERTIES } } @@ -118,11 +128,14 @@ "description": "Description of radiological treatment or surgical action addressing a cancer condition.", "properties": { "id": "An arbitrary identifier for the procedure.", - "procedure_type": "Type of cancer related procedure: radion or surgical.", + "procedure_type": "Type of cancer related procedure: radiation or surgical.", "code": "Code for the procedure performed.", - "occurence_time_or_period": "The date/time that a procedure was performed.", - "target_body_site": "The body location(s) where the procedure was performed.", + "body_site": "The body location(s) where the procedure was performed.", + "laterality": "Body side of the body location, if needed to distinguish from a similar location " + "on the other side of the body.", "treatment_intent": "The purpose of a treatment.", + "reason_code": "The explanation or justification for why the surgical procedure was performed.", + "reason_reference": "Reference to a primary or secondary cancer condition.", **EXTRA_PROPERTIES } } @@ -137,7 +150,6 @@ "treatment_intent": "The purpose of a treatment. Accepted ontologies: SNOMED CT.", "start_date": "The start date/time of the medication.", "end_date": "The end date/time of the medication.", - "date_time": "The date/time the medication was administered.", **EXTRA_PROPERTIES } } @@ -151,6 +163,9 @@ "cancer_condition": "An Individual's cancer condition.", "cancer_related_procedures": "A radiological or surgical procedures addressing a cancer condition.", "medication_statement": "Medication treatment addressed to an Individual.", + "date_of_death": "An indication that the patient is no longer living, given by a date of death or boolean.", + "cancer_disease_status": "A clinician's qualitative judgment on the current trend of the cancer, e.g., " + "whether it is stable, worsening (progressing), or improving (responding).", **EXTRA_PROPERTIES } } diff --git a/chord_metadata_service/mcode/mappings/__init__.py b/chord_metadata_service/mcode/mappings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/chord_metadata_service/mcode/mappings/mappings.py b/chord_metadata_service/mcode/mappings/mappings.py new file mode 100644 index 000000000..8e9ef3d3c --- /dev/null +++ b/chord_metadata_service/mcode/mappings/mappings.py @@ -0,0 +1,77 @@ +from . import mcode_profiles as mp + +MCODE_PROFILES_MAPPING = { + "patient": { + "profile": mp.MCODE_PATIENT, + "properties_profile": { + "comorbid_condition": mp.MCODE_COMORBID_CONDITION, + "ecog_performance_status": mp.MCODE_ECOG_PERFORMANCE_STATUS, + "karnofsky": mp.MCODE_KARNOFSKY + } + }, + "genetic_specimen": { + "profile": mp.MCODE_GENETIC_SPECIMEN, + "properties_profile": { + "laterality": mp.MCODE_LATERALITY + } + }, + "cancer_genetic_variant": { + "profile": mp.MCODE_CANCER_GENETIC_VARIANT + }, + "genomic_region_studied": { + "profile": mp.MCODE_GENOMIC_REGION_STUDIED + }, + "genomics_report": { + "profile": mp.MCODE_GENOMICS_REPORT + }, + "labs_vital": { + "profile": mp.MCODE_TUMOR_MARKER + }, + "cancer_condition": { + "profile": { + "primary": mp.MCODE_PRIMARY_CANCER_CONDITION, + "secondary": mp.MCODE_SECONDARY_CANCER_CONDITION + }, + "properties_profile": { + "histology_morphology_behavior": mp.MCODE_HISTOLOGY_MORPHOLOGY_BEHAVIOR + } + }, + "tnm_staging": { + "properties_profile": { + "stage_group": { + "clinical": mp.MCODE_TNM_CLINICAL_STAGE_GROUP, + "pathologic": mp.MCODE_TNM_PATHOLOGIC_STAGE_GROUP + }, + "primary_tumor_category": { + "clinical": mp.MCODE_TNM_CLINICAL_PRIMARY_TUMOR_CATEGORY, + "pathologic": mp.MCODE_TNM_PATHOLOGIC_PRIMARY_TUMOR_CATEGORY + }, + "regional_nodes_category": { + "clinical": mp.MCODE_TNM_CLINICAL_REGIONAL_NODES_CATEGORY, + "pathologic": mp.MCODE_TNM_PATHOLOGIC_REGIONAL_NODES_CATEGORY + }, + "distant_metastases_category": { + "clinical": mp.MCODE_TNM_CLINICAL_DISTANT_METASTASES_CATEGORY, + "pathologic": mp.MCODE_TNM_PATHOLOGIC_DISTANT_METASTASES_CATEGORY + } + } + }, + "cancer_related_procedure": { + "profile": { + "radiation": mp.MCODE_CANCER_RELATED_RADIATION_PROCEDURE, + "surgical": mp.MCODE_CANCER_RELATED_SURGICAL_PROCEDURE + } + }, + "medication_statement": { + "profile": mp.MCODE_MEDICATION_STATEMENT, + "properties_profile": { + "termination_reason": mp.MCODE_TERMINATION_REASON, + "treatment_intent": mp.MCODE_TREATMENT_INTENT + } + }, + "mcodepacket": { + "properties_profile": { + "cancer_disease_status": mp.MCODE_CANCER_DISEASE_STATUS + } + } +} diff --git a/chord_metadata_service/mcode/mappings/mcode_profiles.py b/chord_metadata_service/mcode/mappings/mcode_profiles.py new file mode 100644 index 000000000..24a09522a --- /dev/null +++ b/chord_metadata_service/mcode/mappings/mcode_profiles.py @@ -0,0 +1,64 @@ +def mcode_structure(structure: str): + return f"http://hl7.org/fhir/us/mcode/StructureDefinition/{structure}" + + +# Individual +MCODE_PATIENT = mcode_structure("mcode-cancer-patient") +MCODE_COMORBID_CONDITION = mcode_structure("mcode-comorbid-condition") +MCODE_ECOG_PERFORMANCE_STATUS = mcode_structure("mcode-ecog-performance-status") +MCODE_KARNOFSKY = mcode_structure("mcode-karnofsky-performance-status") + +# GeneticSpecimen +MCODE_GENETIC_SPECIMEN = mcode_structure("mcode-genetic-specimen") + +# GeneticVariant +MCODE_CANCER_GENETIC_VARIANT = mcode_structure("mcode-cancer-genetic-variant") + +# GenomicRegionStudied +MCODE_GENOMIC_REGION_STUDIED = mcode_structure("mcode-genomic-region-studied") + +# GenomicsReport +MCODE_GENOMICS_REPORT = mcode_structure("mcode-cancer-genomics-report") + +# LabsVital +# the following are present in Ballout 1 version but not in 1.0.0 version +MCODE_TUMOR_MARKER = mcode_structure("mcode-tumor-marker") + +# CancerCondition +MCODE_PRIMARY_CANCER_CONDITION = mcode_structure("mcode-primary-cancer-condition") +MCODE_SECONDARY_CANCER_CONDITION = mcode_structure("mcode-secondary-cancer-condition") + +# TNMStaging +# CLINICAL +MCODE_TNM_CLINICAL_STAGE_GROUP = mcode_structure("mcode-tnm-clinical-stage-group") +MCODE_TNM_CLINICAL_PRIMARY_TUMOR_CATEGORY = mcode_structure("mcode-tnm-clinical-primary-tumor-category") +MCODE_TNM_CLINICAL_REGIONAL_NODES_CATEGORY = mcode_structure("mcode-tnm-clinical-regional-nodes-category") +MCODE_TNM_CLINICAL_DISTANT_METASTASES_CATEGORY = mcode_structure("mcode-tnm-clinical-distant-metastases-category") + +# PATHOLOGIC +MCODE_TNM_PATHOLOGIC_STAGE_GROUP = mcode_structure("mcode-tnm-pathological-stage-group") +MCODE_TNM_PATHOLOGIC_PRIMARY_TUMOR_CATEGORY = mcode_structure("mcode-tnm-pathological-primary-tumor-category") +MCODE_TNM_PATHOLOGIC_REGIONAL_NODES_CATEGORY = mcode_structure("mcode-tnm-pathological-regional-nodes-category") +MCODE_TNM_PATHOLOGIC_DISTANT_METASTASES_CATEGORY = mcode_structure("mcode-tnm-pathological-distant-metastases-category") + +# CancerRelatedProcedure +# CancerRelatedRadiationProcedure +MCODE_CANCER_RELATED_RADIATION_PROCEDURE = mcode_structure("mcode-cancer-related-radiation-procedure") +# CancerRelatedSurgicalProcedure +MCODE_CANCER_RELATED_SURGICAL_PROCEDURE = mcode_structure("mcode-cancer-related-surgical-procedure") + +# MedicationStatement +MCODE_MEDICATION_STATEMENT = mcode_structure("mcode-cancer-related-medication-statement") + +# mCodePacket +MCODE_CANCER_DISEASE_STATUS = mcode_structure("mcode-cancer-disease-status") + +# Extension definitions +MCODE_LATERALITY = mcode_structure("mcode-laterality") + +# CancerCondition histology_morphology_behavior +MCODE_HISTOLOGY_MORPHOLOGY_BEHAVIOR = mcode_structure("mcode-histology-morphology-behavior") + +# MedicationStatement +MCODE_TERMINATION_REASON = mcode_structure("mcode-termination-reason") +MCODE_TREATMENT_INTENT = mcode_structure("mcode-treatment-intent") diff --git a/chord_metadata_service/mcode/mcode_ingest.py b/chord_metadata_service/mcode/mcode_ingest.py new file mode 100644 index 000000000..ee1cab1bd --- /dev/null +++ b/chord_metadata_service/mcode/mcode_ingest.py @@ -0,0 +1,161 @@ +import logging +from chord_metadata_service.patients.models import Individual +from . import models as m + + +logger = logging.getLogger("mcode_ingest") +logger.setLevel(logging.INFO) + + +def _logger_message(created, obj): + if created: + logger.info(f"New {obj.__class__.__name__} {obj.id} created") + else: + logger.info(f"Existing {obj.__class__.__name__} {obj.id} retrieved") + + +def ingest_mcodepacket(mcodepacket_data, table_id): + """ Ingests a single mcodepacket in mcode app and patients' metadata into patients app.""" + + new_mcodepacket = {"id": mcodepacket_data["id"]} + subject = mcodepacket_data["subject"] + genomics_report_data = mcodepacket_data.get("genomics_report", None) + cancer_condition_data = mcodepacket_data.get("cancer_condition", None) + cancer_related_procedures = mcodepacket_data.get("cancer_related_procedures", None) + medication_statement_data = mcodepacket_data.get("medication_statement", None) + date_of_death_data = mcodepacket_data.get("date_of_death", None) + cancer_disease_status_data = mcodepacket_data.get("cancer_disease_status", None) + tumor_markers = mcodepacket_data.get("tumor_marker", None) + + # get and create Patient + if subject: + subject, s_created = m.Individual.objects.get_or_create( + id=subject["id"], + defaults={ + "alternate_ids": subject.get("alternate_ids", None), + "sex": subject.get("sex", ""), + "date_of_birth": subject.get("date_of_birth", None), + "active": subject.get("active", False), + "deceased": subject.get("deceased", False) + } + ) + _logger_message(s_created, subject) + new_mcodepacket["subject"] = subject.id + + if genomics_report_data: + # don't have data for genomics report yet + pass + + # get and create CancerCondition + cancer_conditions = [] + if cancer_condition_data: + for cc in cancer_condition_data: + + cancer_condition, cc_created = m.CancerCondition.objects.get_or_create( + id=cc["id"], + defaults={ + "code": cc["code"], + "condition_type": cc["condition_type"], + "clinical_status": cc.get("clinical_status", None), + "verification_status": cc.get("verification_status", None), + "date_of_diagnosis": cc.get("date_of_diagnosis", None), + "body_site": cc.get("body_site", None), + "laterality": cc.get("laterality", None), + "histology_morphology_behavior": cc.get("histology_morphology_behavior", None) + } + ) + _logger_message(cc_created, cancer_condition) + cancer_conditions.append(cancer_condition.id) + if "tnm_staging" in cc: + for tnms in cc["tnm_staging"]: + tnm_staging, tnms_created = m.TNMStaging.objects.get_or_create( + id=tnms["id"], + defaults={ + "cancer_condition": cancer_condition, + "stage_group": tnms["stage_group"], + "tnm_type": tnms["tnm_type"], + "primary_tumor_category": tnms.get("primary_tumor_category", None), + "regional_nodes_category": tnms.get("regional_nodes_category", None), + "distant_metastases_category": tnms.get("distant_metastases_category", None) + + } + ) + _logger_message(tnms_created, tnm_staging) + + # get and create Cancer Related Procedure + crprocedures = [] + if cancer_related_procedures: + for crp in cancer_related_procedures: + cancer_related_procedure, crp_created = m.CancerRelatedProcedure.objects.get_or_create( + id=crp["id"], + defaults={ + "code": crp["code"], + "procedure_type": crp["procedure_type"], + "body_site": crp.get("body_site", None), + "laterality": crp.get("laterality", None), + "treatment_intent": crp.get("treatment_intent", None), + "reason_code": crp.get("reason_code", None), + "extra_properties": crp.get("extra_properties", None) + } + ) + _logger_message(crp_created, cancer_related_procedure) + crprocedures.append(cancer_related_procedure.id) + if "reason_reference" in crp: + related_cancer_conditions = [] + for rr_id in crp["reason_reference"]: + condition = m.CancerCondition.objects.get(id=rr_id) + related_cancer_conditions.append(condition) + cancer_related_procedure.reason_reference.set(related_cancer_conditions) + + # get and create MedicationStatements + medication_statements = [] + if medication_statement_data: + for ms in medication_statement_data: + medication_statement, ms_created = m.MedicationStatement.objects.get_or_create( + id=ms["id"], + defaults={ + "medication_code": ms["medication_code"] + } + ) + _logger_message(ms_created, medication_statement) + medication_statements.append(medication_statement.id) + + # get date of death + if date_of_death_data: + new_mcodepacket["date_of_death"] = date_of_death_data + + # get cancer disease status + if cancer_disease_status_data: + new_mcodepacket["cancer_disease_status"] = cancer_disease_status_data + + # get tumor marker + if tumor_markers: + for tm in tumor_markers: + tumor_marker, tm_created = m.LabsVital.objects.get_or_create( + id=tm["id"], + defaults={ + "tumor_marker_code": tm["tumor_marker_code"], + "tumor_marker_data_value": tm.get("tumor_marker_data_value", None), + "individual": m.Individual.objects.get(id=tm["individual"]) + } + ) + _logger_message(tm_created, tumor_marker) + + mcodepacket = m.MCodePacket( + id=new_mcodepacket["id"], + subject=Individual.objects.get(id=new_mcodepacket["subject"]), + genomics_report=new_mcodepacket.get("genomics_report", None), + date_of_death=new_mcodepacket.get("date_of_death", ""), + cancer_disease_status=new_mcodepacket.get("cancer_disease_status", None), + table_id=table_id + ) + mcodepacket.save() + logger.info(f"New Mcodepacket {mcodepacket.id} created") + if cancer_conditions: + mcodepacket.cancer_condition.set(cancer_conditions) + if crprocedures: + mcodepacket.cancer_related_procedures.set(crprocedures) + if medication_statements: + mcodepacket.medication_statement.set(medication_statements) + + return mcodepacket diff --git a/chord_metadata_service/mcode/migrations/0001_initial.py b/chord_metadata_service/mcode/migrations/0001_initial.py deleted file mode 100644 index e44b7d689..000000000 --- a/chord_metadata_service/mcode/migrations/0001_initial.py +++ /dev/null @@ -1,163 +0,0 @@ -# Generated by Django 2.2.10 on 2020-03-31 22:39 - -import chord_metadata_service.restapi.models -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('phenopackets', '0004_auto_20200129_1537'), - ('patients', '0005_auto_20200311_1610'), - ] - - operations = [ - migrations.CreateModel( - name='CancerCondition', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the cancer condition.', max_length=200, primary_key=True, serialize=False)), - ('condition_type', models.CharField(choices=[('primary', 'primary'), ('secondary', 'secondary')], help_text='Cancer condition type: primary or secondary.', max_length=200)), - ('body_location_code', django.contrib.postgres.fields.jsonb.JSONField(help_text='Code for the body location, optionally pre-coordinating laterality or direction. Accepted ontologies: SNOMED CT, ICD-O-3 and others.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})])), - ('clinical_status', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A flag indicating whether the condition is active or inactive, recurring, in remission, or resolved (as of the last update of the Condition). Accepted code system: http://terminology.hl7.org/CodeSystem/condition-clinical', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('condition_code', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code describing the type of primary or secondary malignant neoplastic disease.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('date_of_diagnosis', models.DateTimeField(blank=True, help_text='The date the disease was first clinically recognized with sufficient certainty, regardless of whether it was fully characterized at that time.', null=True)), - ('histology_morphology_behavior', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A description of the morphologic and behavioral characteristics of the cancer. Accepted ontologies: SNOMED CT, ICD-O-3 and others.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='CancerRelatedProcedure', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the procedure.', max_length=200, primary_key=True, serialize=False)), - ('procedure_type', models.CharField(choices=[('radiation', 'radiation'), ('surgical', 'surgical')], help_text='Type of cancer related procedure: radion or surgical.', max_length=200)), - ('code', django.contrib.postgres.fields.jsonb.JSONField(help_text='Code for the procedure performed.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('occurence_time_or_period', django.contrib.postgres.fields.jsonb.JSONField(help_text='The date/time that a procedure was performed.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:time_or_period', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Time of Period schema.', 'properties': {'value': {'anyOf': [{'format': 'date-time', 'type': 'string'}, {'$id': 'chord_metadata_service:period_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Period schema.', 'properties': {'end': {'format': 'date-time', 'type': 'string'}, 'start': {'format': 'date-time', 'type': 'string'}}, 'title': 'Period', 'type': 'object'}]}}, 'title': 'Time of Period', 'type': 'object'})])), - ('target_body_site', django.contrib.postgres.fields.jsonb.JSONField(help_text='The body location(s) where the procedure was performed.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})])), - ('treatment_intent', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The purpose of a treatment.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='GeneticVariantFound', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the genetic variant found.', max_length=200, primary_key=True, serialize=False)), - ('method', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology or controlled vocabulary term to identify the method used to perform the genetic test. Accepted value set: NCIT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('variant_found_identifier', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The variation ID assigned by HGVS, for example, 360448 is the identifier for NM_005228.4(EGFR):c.-237A>G (single nucleotide variant in EGFR). Accepted value set: ClinVar.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('variant_found_hgvs_name', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, help_text='Symbolic representation of the variant used in HGVS, for example, NM_005228.4(EGFR):c.-237A>G for HVGS variation ID 360448.', null=True, size=None)), - ('variant_found_description', models.CharField(blank=True, help_text='Description of the variant.', max_length=200)), - ('genomic_source_class', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology or controlled vocabulary term to identify the genomic class of the specimen being analyzed.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='GeneticVariantTested', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the genetic variant tested.', max_length=200, primary_key=True, serialize=False)), - ('method', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology or controlled vocabulary term to identify the method used to perform the genetic test. Accepted value set: NCIT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('variant_tested_identifier', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The variation ID assigned by HGVS, for example, 360448 is the identifier for NM_005228.4(EGFR):c.-237A>G (single nucleotide variant in EGFR).', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('variant_tested_hgvs_name', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, help_text='Symbolic representation of the variant used in HGVS, for example, NM_005228.4(EGFR):c.-237A>G for HVGS variation ID 360448.', null=True, size=None)), - ('variant_tested_description', models.CharField(blank=True, help_text='Description of the variant.', max_length=200)), - ('data_value', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology or controlled vocabulary term to identify positive or negative value forthe mutation. Accepted value set: SNOMED CT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('gene_studied', models.ForeignKey(blank=True, help_text='A gene targeted for mutation analysis, identified in HUGO Gene Nomenclature Committee (HGNC) notation.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='phenopackets.Gene')), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='GenomicsReport', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the genetics report.', max_length=200, primary_key=True, serialize=False)), - ('test_name', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology or controlled vocabulary term to identify the laboratory test. Accepted value sets: LOINC, GTR.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('performing_organization_name', models.CharField(blank=True, help_text='The name of the organization producing the genomics report.', max_length=200)), - ('specimen_type', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology or controlled vocabulary term to identify the type of material the specimen contains or consists of. Accepted value set: HL7 Version 2 and Specimen Type.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('genetic_variant_found', models.ManyToManyField(blank=True, help_text='Records an alteration in the most common DNA nucleotide sequence.', to='mcode.GeneticVariantFound')), - ('genetic_variant_tested', models.ManyToManyField(blank=True, help_text='A test for a specific mutation on a particular gene.', to='mcode.GeneticVariantTested')), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='MedicationStatement', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the medication statement.', max_length=200, primary_key=True, serialize=False)), - ('medication_code', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code for medication. Accepted code systems: Medication Clinical Drug (RxNorm) and other.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('termination_reason', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code explaining unplanned or premature termination of a course of medication. Accepted ontologies: SNOMED CT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'ONTOLOGY_CLASS_LIST', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'})])), - ('treatment_intent', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The purpose of a treatment. Accepted ontologies: SNOMED CT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})])), - ('start_date', models.DateTimeField(blank=True, help_text='The start date/time of the medication.', null=True)), - ('end_date', models.DateTimeField(blank=True, help_text='The end date/time of the medication.', null=True)), - ('date_time', models.DateTimeField(blank=True, help_text='The date/time the medication was administered.', null=True)), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='TNMStaging', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the TNM staging.', max_length=200, primary_key=True, serialize=False)), - ('tnm_type', models.CharField(choices=[('clinical', 'clinical'), ('pathologic', 'pathologic')], help_text='TNM type: clinical or pathological.', max_length=200)), - ('stage_group', django.contrib.postgres.fields.jsonb.JSONField(help_text='The extent of the cancer in the body, according to the TNM classification system.Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'})])), - ('primary_tumor_category', django.contrib.postgres.fields.jsonb.JSONField(help_text='Category of the primary tumor, based on its size and extent. Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'})])), - ('regional_nodes_category', django.contrib.postgres.fields.jsonb.JSONField(help_text='Category of the presence or absence of metastases in regional lymph nodes. Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'})])), - ('distant_metastases_category', django.contrib.postgres.fields.jsonb.JSONField(help_text='Category describing the presence or absence of metastases in remote anatomical locations. Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'})])), - ('cancer_condition', models.ForeignKey(help_text='Cancer condition.', on_delete=django.db.models.deletion.CASCADE, to='mcode.CancerCondition')), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='MCodePacket', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the mcodepacket.', max_length=200, primary_key=True, serialize=False)), - ('cancer_condition', models.ForeignKey(blank=True, help_text="An Individual's cancer condition.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='mcode.CancerCondition')), - ('cancer_related_procedures', models.ManyToManyField(blank=True, help_text='A radiological or surgical procedures addressing a cancer condition.', to='mcode.CancerRelatedProcedure')), - ('genomics_report', models.ForeignKey(blank=True, help_text='A genomics report associated with an Individual.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='mcode.GenomicsReport')), - ('medication_statement', models.ForeignKey(blank=True, help_text='Medication treatment addressed to an Individual.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='mcode.MedicationStatement')), - ('subject', models.ForeignKey(help_text='An individual who is a subject of mcodepacket.', on_delete=django.db.models.deletion.CASCADE, to='patients.Individual')), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - migrations.CreateModel( - name='LabsVital', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the labs/vital tests.', max_length=200, primary_key=True, serialize=False)), - ('body_height', django.contrib.postgres.fields.jsonb.JSONField(help_text="The patient's height.", validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'})])), - ('body_weight', django.contrib.postgres.fields.jsonb.JSONField(help_text="The patient's weight.", validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'})])), - ('cbc_with_auto_differential_panel', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, help_text='Reference to a laboratory observation in the CBC with Auto DifferentialPanel test.', null=True, size=None)), - ('comprehensive_metabolic_2000', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, help_text='Reference to a laboratory observation in the CMP 2000 test.', null=True, size=None)), - ('blood_pressure_diastolic', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The blood pressure after the contraction of the heart while the chambers of the heart refill with blood, when the pressure is lowest.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'})])), - ('blood_pressure_systolic', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The blood pressure during the contraction of the left ventricle of the heart, when blood pressure is at its highest.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'})])), - ('tumor_marker_test', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology or controlled vocabulary term to identify tumor marker test.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:tumor_marker_test', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Tumor marker test schema.', 'properties': {'code': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'data_value': {'anyOf': [{'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, {'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'}, {'$id': 'chord_metadata_service:ratio', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Ratio schema.', 'properties': {'denominator': {'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'}, 'numerator': {'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'}}, 'title': 'Ratio', 'type': 'object'}]}}, 'required': ['code'], 'title': 'Tumor marker test', 'type': 'object'})])), - ('individual', models.ForeignKey(help_text='The individual who is the subject of the tests.', on_delete=django.db.models.deletion.CASCADE, to='patients.Individual')), - ], - options={ - 'ordering': ['id'], - }, - bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), - ), - ] diff --git a/chord_metadata_service/mcode/migrations/0001_v1_0_0.py b/chord_metadata_service/mcode/migrations/0001_v1_0_0.py new file mode 100644 index 000000000..06d1fd76d --- /dev/null +++ b/chord_metadata_service/mcode/migrations/0001_v1_0_0.py @@ -0,0 +1,215 @@ +# Generated by Django 2.2.13 on 2020-07-06 14:55 + +import chord_metadata_service.restapi.models +import chord_metadata_service.restapi.validators +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('phenopackets', '0001_v1_0_0'), + ('chord', '0001_v1_0_0'), + ('patients', '0001_v1_0_0'), + ] + + operations = [ + migrations.CreateModel( + name='CancerCondition', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the cancer condition.', max_length=200, primary_key=True, serialize=False)), + ('condition_type', models.CharField(choices=[('primary', 'primary'), ('secondary', 'secondary')], help_text='Cancer condition type: primary or secondary.', max_length=200)), + ('body_site', django.contrib.postgres.fields.jsonb.JSONField(help_text='Code for the body location, optionally pre-coordinating laterality or direction. Accepted ontologies: SNOMED CT, ICD-O-3 and others.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('laterality', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Body side of the body location, if needed to distinguish from a similar location on the other side of the body.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('clinical_status', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A flag indicating whether the condition is active or inactive, recurring, in remission, or resolved (as of the last update of the Condition). Accepted code system: http://terminology.hl7.org/CodeSystem/condition-clinical', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('code', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code describing the type of primary or secondary malignant neoplastic disease.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('date_of_diagnosis', models.DateTimeField(blank=True, help_text='The date the disease was first clinically recognized with sufficient certainty, regardless of whether it was fully characterized at that time.', null=True)), + ('histology_morphology_behavior', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A description of the morphologic and behavioral characteristics of the cancer. Accepted ontologies: SNOMED CT, ICD-O-3 and others.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('verification_status', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A flag indicating whether the condition is unconfirmed, provisional, differential, confirmed, refuted, or entered-in-error.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='CancerGeneticVariant', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the cancer genetic variant.', max_length=200, primary_key=True, serialize=False)), + ('data_value', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The overall result of the genetic test; specifically, whether a variant is present, absent, no call, or indeterminant.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('method', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The method used to perform the genetic test.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('amino_acid_change', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The symbolic representation of an amino acid variant reported using HGVS nomenclature (pHGVS).', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('amino_acid_change_type', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The type of change related to the amino acid variant.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('cytogenetic_location', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The cytogenetic (chromosome) location.', null=True)), + ('cytogenetic_nomenclature', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The cytogenetic (chromosome) location, represented using the International System for Human Cytogenetic Nomenclature (ISCN).', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('genomic_dna_change', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The symbolic representation of a genetic structural variant reported using HGVS nomenclature (gHGVS).', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('genomic_source_class', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The genomic class of the specimen being analyzed, for example, germline for inherited genome, somatic for cancer genome, and prenatal for fetal genome.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('variation_code', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The variation ID assigned by ClinVar.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('gene_studied', models.ManyToManyField(blank=True, help_text='A gene targeted for mutation analysis, identified in HUGO Gene Nomenclature Committee (HGNC) notation.', to='phenopackets.Gene')), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='CancerRelatedProcedure', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the procedure.', max_length=200, primary_key=True, serialize=False)), + ('procedure_type', models.CharField(choices=[('radiation', 'radiation'), ('surgical', 'surgical')], help_text='Type of cancer related procedure: radiation or surgical.', max_length=200)), + ('code', django.contrib.postgres.fields.jsonb.JSONField(help_text='Code for the procedure performed.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('body_site', django.contrib.postgres.fields.jsonb.JSONField(help_text='The body location(s) where the procedure was performed.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('laterality', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Body side of the body location, if needed to distinguish from a similar location on the other side of the body.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('treatment_intent', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The purpose of a treatment.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('reason_code', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The explanation or justification for why the surgical procedure was performed.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('reason_reference', models.ManyToManyField(blank=True, help_text='Reference to a primary or secondary cancer condition.', to='mcode.CancerCondition')), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='GeneticSpecimen', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the genetic specimen.', max_length=200, primary_key=True, serialize=False)), + ('specimen_type', django.contrib.postgres.fields.jsonb.JSONField(help_text='The kind of material that forms the specimen.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('collection_body', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The anatomical collection site.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('laterality', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Body side of the collection site, if needed to distinguish from a similar location on the other side of the body.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='GenomicRegionStudied', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the genomic region studied.', max_length=200, primary_key=True, serialize=False)), + ('dna_ranges_examined', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The range(s) of the DNA sequence examined.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('dna_region_description', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(help_text='The description for the DNA region studied in the genomics report.', max_length=100), blank=True, default=list, size=None)), + ('gene_mutation', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The gene mutations tested for in blood or tissue by molecular genetics methods.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('gene_studied', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The ID for the gene studied.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('genomic_reference_sequence_id', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Range(s) of DNA sequence examined.', null=True)), + ('genomic_region_coordinate_system', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The method of counting along the genome.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='GenomicsReport', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the genetics report.', max_length=200, primary_key=True, serialize=False)), + ('code', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology or controlled vocabulary term to identify the laboratory test. Accepted value sets: LOINC, GTR.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('performing_organization_name', models.CharField(blank=True, help_text='The name of the organization producing the genomics report.', max_length=200)), + ('issued', models.DateTimeField(default=django.utils.timezone.now, help_text='The date/time this report was issued.')), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('genetic_specimen', models.ManyToManyField(blank=True, help_text='List of related genetic specimens.', to='mcode.GeneticSpecimen')), + ('genetic_variant', models.ForeignKey(blank=True, help_text='Related genetic variant.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='mcode.CancerGeneticVariant')), + ('genomic_region_studied', models.ForeignKey(blank=True, help_text='Related genomic region studied.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='mcode.GenomicRegionStudied')), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='MedicationStatement', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the medication statement.', max_length=200, primary_key=True, serialize=False)), + ('medication_code', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code for medication. Accepted code systems: Medication Clinical Drug (RxNorm) and other.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('termination_reason', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code explaining unplanned or premature termination of a course of medication. Accepted ontologies: SNOMED CT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('treatment_intent', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The purpose of a treatment. Accepted ontologies: SNOMED CT.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('start_date', models.DateTimeField(blank=True, help_text='The start date/time of the medication.', null=True)), + ('end_date', models.DateTimeField(blank=True, help_text='The end date/time of the medication.', null=True)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='TNMStaging', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the TNM staging.', max_length=200, primary_key=True, serialize=False)), + ('tnm_type', models.CharField(choices=[('clinical', 'clinical'), ('pathologic', 'pathologic')], help_text='TNM type: clinical or pathological.', max_length=200)), + ('stage_group', django.contrib.postgres.fields.jsonb.JSONField(help_text='The extent of the cancer in the body, according to the TNM classification system.Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'}, formats=['uri'])])), + ('primary_tumor_category', django.contrib.postgres.fields.jsonb.JSONField(help_text='Category of the primary tumor, based on its size and extent. Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'}, formats=['uri'])])), + ('regional_nodes_category', django.contrib.postgres.fields.jsonb.JSONField(help_text='Category of the presence or absence of metastases in regional lymph nodes. Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'}, formats=['uri'])])), + ('distant_metastases_category', django.contrib.postgres.fields.jsonb.JSONField(help_text='Category describing the presence or absence of metastases in remote anatomical locations. Accepted ontologies: SNOMED CT, AJCC and others.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:complex_ontology_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Complex object to combine data value and staging system.', 'properties': {'data_value': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'staging_system': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': ['data_value'], 'title': 'Complex ontology', 'type': 'object'}, formats=['uri'])])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('cancer_condition', models.ForeignKey(help_text='Cancer condition.', on_delete=django.db.models.deletion.CASCADE, to='mcode.CancerCondition')), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='MCodePacket', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the mcodepacket.', max_length=200, primary_key=True, serialize=False)), + ('date_of_death', models.CharField(blank=True, help_text='An indication that the patient is no longer living, given by a date of death or boolean.', max_length=200)), + ('cancer_disease_status', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text="A clinician's qualitative judgment on the current trend of the cancer, e.g., whether it is stable, worsening (progressing), or improving (responding).", null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('cancer_condition', models.ManyToManyField(blank=True, help_text="An Individual's cancer condition.", to='mcode.CancerCondition')), + ('cancer_related_procedures', models.ManyToManyField(blank=True, help_text='A radiological or surgical procedures addressing a cancer condition.', to='mcode.CancerRelatedProcedure')), + ('genomics_report', models.ForeignKey(blank=True, help_text='A genomics report associated with an Individual.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='mcode.GenomicsReport')), + ('medication_statement', models.ManyToManyField(blank=True, help_text='Medication treatment addressed to an Individual.', to='mcode.MedicationStatement')), + ('subject', models.ForeignKey(help_text='An individual who is a subject of mcodepacket.', on_delete=django.db.models.deletion.CASCADE, to='patients.Individual')), + ('table', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chord.Table')), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='LabsVital', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the labs/vital tests.', max_length=200, primary_key=True, serialize=False)), + ('tumor_marker_code', django.contrib.postgres.fields.jsonb.JSONField(help_text='A code identifying the type of tumor marker test.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('tumor_marker_data_value', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The result of a tumor marker test.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:tumor_marker_data_value', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Tumor marker data value schema.', 'properties': {'value': {'anyOf': [{'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, {'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'}, {'$id': 'chord_metadata_service:ratio', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Ratio schema.', 'properties': {'denominator': {'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'}, 'numerator': {'$id': 'chord_metadata_service:quantity_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the datatype Quantity.', 'properties': {'code': {'type': 'string'}, 'comparator': {'enum': ['<', '>', '<=', '>=', '=']}, 'system': {'format': 'uri', 'type': 'string'}, 'unit': {'type': 'string'}, 'value': {'type': 'number'}}, 'title': 'Quantity schema', 'type': 'object'}}, 'title': 'Ratio', 'type': 'object'}]}}, 'title': 'Tumor marker data value', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('individual', models.ForeignKey(help_text='The individual who is the subject of the tests.', on_delete=django.db.models.deletion.CASCADE, to='patients.Individual')), + ], + options={ + 'ordering': ['id'], + }, + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + ] diff --git a/chord_metadata_service/mcode/migrations/0002_auto_20200401_1008.py b/chord_metadata_service/mcode/migrations/0002_auto_20200401_1008.py deleted file mode 100644 index 9756f449e..000000000 --- a/chord_metadata_service/mcode/migrations/0002_auto_20200401_1008.py +++ /dev/null @@ -1,159 +0,0 @@ -# Generated by Django 2.2.10 on 2020-04-01 14:08 - -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('mcode', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='cancercondition', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='cancercondition', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='cancercondition', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='cancerrelatedprocedure', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='cancerrelatedprocedure', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='cancerrelatedprocedure', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='geneticvariantfound', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='geneticvariantfound', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='geneticvariantfound', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='geneticvarianttested', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='geneticvarianttested', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='geneticvarianttested', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='genomicsreport', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='genomicsreport', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='genomicsreport', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='labsvital', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='labsvital', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='labsvital', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='mcodepacket', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='mcodepacket', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='mcodepacket', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='medicationstatement', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='medicationstatement', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='medicationstatement', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='tnmstaging', - name='created', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='tnmstaging', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AddField( - model_name='tnmstaging', - name='updated', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - ] diff --git a/chord_metadata_service/mcode/models.py b/chord_metadata_service/mcode/models.py index a38e5d1ab..911d08893 100644 --- a/chord_metadata_service/mcode/models.py +++ b/chord_metadata_service/mcode/models.py @@ -1,41 +1,30 @@ +from django.utils import timezone from django.db import models from django.contrib.postgres.fields import JSONField, ArrayField from chord_metadata_service.restapi.models import IndexableMixin from chord_metadata_service.phenopackets.models import Gene from chord_metadata_service.patients.models import Individual -from django.core.exceptions import ValidationError from chord_metadata_service.restapi.description_utils import rec_help import chord_metadata_service.mcode.descriptions as d -from chord_metadata_service.restapi.validators import ( - ontology_validator, quantity_validator, tumor_marker_test_validator, - complex_ontology_validator, time_or_period_validator, ontology_list_validator +from chord_metadata_service.restapi.validators import ontology_validator, ontology_list_validator +from .validators import ( + tumor_marker_data_value_validator, + complex_ontology_validator ) -class GeneticVariantTested(models.Model, IndexableMixin): +class GeneticSpecimen(models.Model, IndexableMixin): """ - Class to record an alteration in the most common DNA nucleotide sequence. + Class to describe a biosample used for genomics testing or analysis. """ - - # TODO Discuss: Connection to Gene from Phenopackets - id = models.CharField(primary_key=True, max_length=200, - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "id")) - # make writable if it doesn't exist - gene_studied = models.ForeignKey(Gene, blank=True, null=True, on_delete=models.SET_NULL, - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "gene_studied")) - method = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "method")) - variant_tested_identifier = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "variant_tested_identifier")) - variant_tested_hgvs_name = ArrayField(models.CharField(max_length=200), blank=True, null=True, - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "variant_tested_hgvs_name")) - variant_tested_description = models.CharField(max_length=200, blank=True, - help_text=rec_help(d.GENETIC_VARIANT_TESTED, - "variant_tested_description")) - data_value = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "data_value")) + id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.GENETIC_SPECIMEN, "id")) + specimen_type = JSONField(validators=[ontology_validator], help_text=rec_help(d.GENETIC_SPECIMEN, "specimen_type")) + collection_body = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.GENETIC_SPECIMEN, "collection_body")) + laterality = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.GENETIC_SPECIMEN, "laterality")) extra_properties = JSONField(blank=True, null=True, - help_text=rec_help(d.GENETIC_VARIANT_TESTED, "extra_properties")) + help_text=rec_help(d.GENETIC_SPECIMEN, "extra_properties")) created = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now_add=True) @@ -45,34 +34,34 @@ class Meta: def __str__(self): return str(self.id) - def clean(self): - if not (self.variant_tested_identifier or self.variant_tested_hgvs_name or self.variant_tested_description): - raise ValidationError('At least one element out of the following must be reported: ' - 'Variant Tested Identifier, Variant Tested HGVS Name, and Variant Tested Description') - -class GeneticVariantFound(models.Model, IndexableMixin): +class CancerGeneticVariant(models.Model, IndexableMixin): """ - Class to record whether a single discrete variant tested is present - or absent (denoted as positive or negative respectively). + Class to record an alteration in DNA. """ - - # TODO Discuss: Connection to Gene from Phenopackets - id = models.CharField(primary_key=True, max_length=200, - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "id")) + id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.CANCER_GENETIC_VARIANT, "id")) + data_value = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "data_value")) method = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "method")) - variant_found_identifier = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "variant_found_identifier")) - variant_found_hgvs_name = ArrayField(models.CharField(max_length=200), blank=True, null=True, - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "variant_found_hgvs_name")) - variant_found_description = models.CharField(max_length=200, blank=True, - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "variant_found_description")) - # loinc value set https://loinc.org/48002-0/ + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "method")) + amino_acid_change = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "amino_acid_change")) + amino_acid_change_type = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "amino_acid_change_type")) + cytogenetic_location = JSONField(blank=True, null=True, + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "cytogenetic_location")) + cytogenetic_nomenclature = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "cytogenetic_nomenclature")) + gene_studied = models.ManyToManyField(Gene, blank=True, + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "gene_studied")) + genomic_dna_change = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "genomic_dna_change")) genomic_source_class = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "genomic_source_class")) + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "genomic_source_class")) + variation_code = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "variation_code")) extra_properties = JSONField(blank=True, null=True, - help_text=rec_help(d.GENETIC_VARIANT_FOUND, "extra_properties")) + help_text=rec_help(d.CANCER_GENETIC_VARIANT, "extra_properties")) created = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now_add=True) @@ -82,27 +71,58 @@ class Meta: def __str__(self): return str(self.id) - def clean(self): - if not (self.variant_found_identifier or self.variant_found_hgvs_name or self.variant_found_description): - raise ValidationError('At least one element out of the following must be reported: ' - 'Variant Found Identifier, Variant Found HGVS Name, and Variant Found Description') + +class GenomicRegionStudied(models.Model, IndexableMixin): + """ + Class to describe the area of the genome region referenced in testing for variants. + """ + id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.GENOMIC_REGION_STUDIED, "id")) + # TODO schema Range list + dna_ranges_examined = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.GENOMIC_REGION_STUDIED, "dna_ranges_examined")) + dna_region_description = ArrayField(models.CharField(max_length=100, + help_text=rec_help(d.GENOMIC_REGION_STUDIED, + 'dna_region_description')), + blank=True, default=list) + gene_mutation = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.GENOMIC_REGION_STUDIED, "gene_mutation")) + # TODO check: thisis not a Reference in mcode data dictionary why not? + gene_studied = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.GENOMIC_REGION_STUDIED, "gene_studied")) + genomic_reference_sequence_id = JSONField(blank=True, null=True, + help_text=rec_help(d.GENOMIC_REGION_STUDIED, + "genomic_reference_sequence_id")) + genomic_region_coordinate_system = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.GENOMIC_REGION_STUDIED, + "genomic_region_coordinate_system")) + extra_properties = JSONField(blank=True, null=True, + help_text=rec_help(d.GENOMIC_REGION_STUDIED, "extra_properties")) + created = models.DateTimeField(auto_now=True) + updated = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['id'] + + def __str__(self): + return str(self.id) class GenomicsReport(models.Model, IndexableMixin): """ - Genetic Analysis Summary + Genetic Analysis Summary. """ id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.GENOMICS_REPORT, "id")) - test_name = JSONField(validators=[ontology_validator], help_text=rec_help(d.GENOMICS_REPORT, "test_name")) - performing_organization_name = models.CharField(max_length=200, blank=True, - help_text=rec_help(d.GENOMICS_REPORT, "performing_organization_name")) - specimen_type = JSONField(blank=True, null=True, validators=[ontology_validator], - help_text=rec_help(d.GENOMICS_REPORT, "specimen_type")) - genetic_variant_tested = models.ManyToManyField(GeneticVariantTested, blank=True, - help_text=rec_help(d.GENOMICS_REPORT, "genetic_variant_tested")) - genetic_variant_found = models.ManyToManyField(GeneticVariantFound, blank=True, - help_text=rec_help(d.GENOMICS_REPORT, "genetic_variant_found")) + code = JSONField(validators=[ontology_validator], help_text=rec_help(d.GENOMICS_REPORT, "code")) + performing_organization_name = models.CharField( + max_length=200, blank=True, help_text=rec_help(d.GENOMICS_REPORT, "performing_organization_name")) + issued = models.DateTimeField(default=timezone.now, help_text=rec_help(d.GENOMICS_REPORT, "issued")) + genetic_specimen = models.ManyToManyField(GeneticSpecimen, blank=True, + help_text=rec_help(d.GENOMICS_REPORT, "genetic_specimen")) + genetic_variant = models.ForeignKey(CancerGeneticVariant, blank=True, null=True, on_delete=models.SET_NULL, + help_text=rec_help(d.GENOMICS_REPORT, "genetic_variant")) + genomic_region_studied = models.ForeignKey(GenomicRegionStudied, blank=True, null=True, on_delete=models.SET_NULL, + help_text=rec_help(d.GENOMICS_REPORT, "genomic_region_studied")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.GENOMICS_REPORT, "extra_properties")) created = models.DateTimeField(auto_now=True) @@ -115,7 +135,7 @@ def __str__(self): return str(self.id) -################################# Labs/Vital ################################# +# ================================ Labs/Vital ================================ class LabsVital(models.Model, IndexableMixin): @@ -127,20 +147,11 @@ class LabsVital(models.Model, IndexableMixin): help_text=rec_help(d.LABS_VITAL, "id")) individual = models.ForeignKey(Individual, on_delete=models.CASCADE, help_text=rec_help(d.LABS_VITAL, "individual")) - body_height = JSONField(validators=[quantity_validator], help_text=rec_help(d.LABS_VITAL, "body_height")) - body_weight = JSONField(validators=[quantity_validator], help_text=rec_help(d.LABS_VITAL, "body_weight")) - # corresponds to DiagnosticReport.result - complex element, probably should be changed to Array of json - cbc_with_auto_differential_panel = ArrayField(models.CharField(max_length=200), blank=True, null=True, - help_text=rec_help(d.LABS_VITAL, "cbc_with_auto_differential_panel")) - comprehensive_metabolic_2000 = ArrayField(models.CharField(max_length=200), blank=True, null=True, - help_text=rec_help(d.LABS_VITAL, "comprehensive_metabolic_2000")) - blood_pressure_diastolic = JSONField(blank=True, null=True, validators=[quantity_validator], - help_text=rec_help(d.LABS_VITAL, "blood_pressure_diastolic")) - blood_pressure_systolic = JSONField(blank=True, null=True, validators=[quantity_validator], - help_text=rec_help(d.LABS_VITAL, "blood_pressure_systolic")) - #TODO Change CodeableConcept to Ontology class - tumor_marker_test = JSONField(validators=[tumor_marker_test_validator], - help_text=rec_help(d.LABS_VITAL, "tumor_marker_test")) + # TODO Change CodeableConcept to Ontology class + tumor_marker_code = JSONField(validators=[ontology_validator], + help_text=rec_help(d.LABS_VITAL, "tumor_marker_code")) + tumor_marker_data_value = JSONField(blank=True, null=True, validators=[tumor_marker_data_value_validator], + help_text=rec_help(d.LABS_VITAL, "tumor_marker_data_value")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.LABS_VITAL, "extra_properties")) created = models.DateTimeField(auto_now=True) @@ -152,13 +163,8 @@ class Meta: def __str__(self): return str(self.id) - def clean(self): - if not (self.blood_pressure_diastolic or self.blood_pressure_systolic): - raise ValidationError('At least one of the following must be reported: Systolic Blood Pressure or' - 'Diastolic Blood Pressure.') - -################################# Disease ################################# +# ================================== Disease ================================== class CancerCondition(models.Model, IndexableMixin): """ @@ -171,17 +177,20 @@ class CancerCondition(models.Model, IndexableMixin): id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.CANCER_CONDITION, "id")) condition_type = models.CharField(choices=CANCER_CONDITION_TYPE, max_length=200, help_text=rec_help(d.CANCER_CONDITION, "condition_type")) - # TODO add body_location_code validator array of json - body_location_code = JSONField(null=True, validators=[ontology_list_validator], - help_text=rec_help(d.CANCER_CONDITION, 'body_location_code')) + body_site = JSONField(null=True, validators=[ontology_list_validator], + help_text=rec_help(d.CANCER_CONDITION, 'body_site')) + laterality = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_CONDITION, "laterality")) clinical_status = JSONField(blank=True, null=True, validators=[ontology_validator], help_text=rec_help(d.CANCER_CONDITION, "clinical_status")) - condition_code = JSONField(validators=[ontology_validator], - help_text=rec_help(d.CANCER_CONDITION, "condition_code")) + code = JSONField(validators=[ontology_validator], + help_text=rec_help(d.CANCER_CONDITION, "code")) date_of_diagnosis = models.DateTimeField(blank=True, null=True, help_text=rec_help(d.CANCER_CONDITION, "date_of_diagnosis")) histology_morphology_behavior = JSONField(blank=True, null=True, validators=[ontology_validator], help_text=rec_help(d.CANCER_CONDITION, "histology_morphology_behavior")) + verification_status = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_CONDITION, "verification_status")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.CANCER_CONDITION, "extra_properties")) created = models.DateTimeField(auto_now=True) @@ -227,9 +236,9 @@ def __str__(self): return str(self.id) -################################# Treatment ################################# +# ================================= Treatment ================================= -###### Procedure ###### +# ==== Procedure ==== class CancerRelatedProcedure(models.Model, IndexableMixin): """ @@ -244,12 +253,18 @@ class CancerRelatedProcedure(models.Model, IndexableMixin): procedure_type = models.CharField(choices=PROCEDURE_TYPES, max_length=200, help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "procedure_type")) code = JSONField(validators=[ontology_validator], help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "code")) - occurence_time_or_period = JSONField(validators=[time_or_period_validator], - help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "occurence_time_or_period")) - target_body_site = JSONField(null=True, validators=[ontology_list_validator], - help_text=rec_help(d.CANCER_RELATED_PROCEDURE, 'target_body_site')) + body_site = JSONField(null=True, validators=[ontology_list_validator], + help_text=rec_help(d.CANCER_RELATED_PROCEDURE, 'body_site')) + laterality = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "laterality")) treatment_intent = JSONField(blank=True, null=True, validators=[ontology_validator], help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "treatment_intent")) + # Only for Surgical Procedure + # TODO CHANGE to ontology list validator + reason_code = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "reason_code")) + reason_reference = models.ManyToManyField(CancerCondition, blank=True, + help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "reason_reference")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.CANCER_RELATED_PROCEDURE, "extra_properties")) created = models.DateTimeField(auto_now=True) @@ -261,7 +276,9 @@ class Meta: def __str__(self): return str(self.id) -###### Medication Statement ###### + +# ==== Medication Statement ==== + class MedicationStatement(models.Model, IndexableMixin): """ @@ -278,7 +295,6 @@ class MedicationStatement(models.Model, IndexableMixin): help_text=rec_help(d.MEDICATION_STATEMENT, "treatment_intent")) start_date = models.DateTimeField(blank=True, null=True, help_text=rec_help(d.MEDICATION_STATEMENT, "start_date")) end_date = models.DateTimeField(blank=True, null=True, help_text=rec_help(d.MEDICATION_STATEMENT, "end_date")) - date_time = models.DateTimeField(blank=True, null=True, help_text=rec_help(d.MEDICATION_STATEMENT, "date_time")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.MEDICATION_STATEMENT, "extra_properties")) created = models.DateTimeField(auto_now=True) @@ -301,12 +317,17 @@ class MCodePacket(models.Model, IndexableMixin): help_text=rec_help(d.MCODEPACKET, "subject")) genomics_report = models.ForeignKey(GenomicsReport, blank=True, null=True, on_delete=models.SET_NULL, help_text=rec_help(d.MCODEPACKET, "genomics_report")) - cancer_condition = models.ForeignKey(CancerCondition, blank=True, null=True, on_delete=models.SET_NULL, - help_text=rec_help(d.MCODEPACKET, "cancer_condition")) + cancer_condition = models.ManyToManyField(CancerCondition, blank=True, + help_text=rec_help(d.MCODEPACKET, "cancer_condition")) cancer_related_procedures = models.ManyToManyField(CancerRelatedProcedure, blank=True, help_text=rec_help(d.MCODEPACKET, "cancer_related_procedures")) - medication_statement = models.ForeignKey(MedicationStatement, blank=True, null=True, on_delete=models.SET_NULL, - help_text=rec_help(d.MCODEPACKET, "medication_statement")) + medication_statement = models.ManyToManyField(MedicationStatement, blank=True, + help_text=rec_help(d.MCODEPACKET, "medication_statement")) + date_of_death = models.CharField(max_length=200, blank=True, help_text=rec_help(d.MCODEPACKET, "date_of_death")) + cancer_disease_status = JSONField(blank=True, null=True, validators=[ontology_validator], + help_text=rec_help(d.MCODEPACKET, "cancer_disease_status")) + # link to dataset via the table + table = models.ForeignKey("chord.Table", on_delete=models.CASCADE, blank=True, null=True) # TODO: Help text extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.MCODEPACKET, "extra_properties")) created = models.DateTimeField(auto_now=True) diff --git a/chord_metadata_service/mcode/parse_fhir_mcode.py b/chord_metadata_service/mcode/parse_fhir_mcode.py new file mode 100644 index 000000000..95e8afa2c --- /dev/null +++ b/chord_metadata_service/mcode/parse_fhir_mcode.py @@ -0,0 +1,308 @@ +import uuid + +from .mappings.mappings import MCODE_PROFILES_MAPPING +from .mappings import mcode_profiles as p +from chord_metadata_service.restapi.schemas import FHIR_BUNDLE_SCHEMA +from chord_metadata_service.restapi.fhir_ingest import check_schema + + +def get_ontology_value(resource, codeable_concept_property): + """ + The function covers the most encountered use cases. + """ + try: + ontology_value = {} + if "system" in resource[codeable_concept_property]['coding'][0]: + ontology_value["id"] = f"{resource[codeable_concept_property]['coding'][0]['system']}:" \ + f"{resource[codeable_concept_property]['coding'][0]['code']}" + else: + ontology_value["id"] = f"{resource[codeable_concept_property]['coding'][0]['code']}" + + if "display" in resource[codeable_concept_property]['coding'][0]: + ontology_value["label"] = f"{resource[codeable_concept_property]['coding'][0]['display']}" + else: + ontology_value["label"] = f"{resource[codeable_concept_property]['coding'][0]['code']}" + return ontology_value + # will be raised if there is no "code" in Coding element + except KeyError as e: + raise e + + +def patient_to_individual(resource): + """ Patient to Individual. """ + + # TODO when smart-on-fhir for fhir 4.0 is issued change it for function form fhir-utils + individual = { + "id": resource["id"] + } + if "identifier" in resource: + individual["alternate_ids"] = [alternate_id["value"] for alternate_id in resource["identifier"]] + gender_to_sex = { + "male": "MALE", + "female": "FEMALE", + "other": "OTHER_SEX", + "unknown": "UNKNOWN_SEX" + } + if "gender" in resource: + individual["sex"] = gender_to_sex[resource["gender"]] + if "birthDate" in resource: + individual["date_of_birth"] = resource["birthDate"] + if "active" in resource: + if resource["active"]: + individual["active"] = True + if "deceasedDateTime" in resource: + individual["deceased"] = True + elif "deceasedBoolean" in resource: + individual["deceased"] = resource["deceasedBoolean"] + else: + individual["deceased"] = False + return individual + + +def observation_to_labs_vital(resource): + """ Observation with tumor marker to LabsVital. """ + labs_vital = { + "id": resource["id"] + } + if "code" in resource: + labs_vital["tumor_marker_code"] = get_ontology_value(resource, "code") + if "valueCodeableConcept" in resource: + labs_vital["tumor_marker_data_value"] = get_ontology_value(resource, "valueCodeableConcept") + if "subject": + labs_vital["individual"] = resource["subject"]["reference"].split("uuid:")[-1] + return labs_vital + + +def observation_to_tnm_staging(resource): + """ Observation with tnm staging to TNMStaging. """ + tnm_staging = { + "id": resource["id"], + "tnm_staging_value": {} + } + if "valueCodeableConcept" in resource: + tnm_staging["tnm_staging_value"]["data_value"] = get_ontology_value(resource, "valueCodeableConcept") + if "method" in resource: + tnm_staging["tnm_staging_value"]["staging_system"] = get_ontology_value(resource, "method") + # reference to Condition + if "focus" in resource: + tnm_staging["cancer_condition"] = resource["focus"][0]["reference"].split("/")[-1] + return tnm_staging + + +def procedure_to_crprocedure(resource): + """ Procedure to Cancer related Procedure. """ + + cancer_related_procedure = { + "id": resource["id"] + } + if "code" in resource: + cancer_related_procedure["code"] = get_ontology_value(resource, "code") + if "bodySite" in resource: + cancer_related_procedure["body_site"] = get_ontology_value(resource, "bodySite") + if "reasonCode" in resource: + codes = [{"id": f"{code['system']}:{code['code']}", "label": f"{code['display']}"} + for code in resource["reasonCode"]["coding"]] + cancer_related_procedure["reason_code"] = codes + if "reasonReference" in resource: + cancer_conditions = [cc["reference"].split("uuid:")[-1] for cc in resource["reasonReference"]] + cancer_related_procedure["reason_reference"] = cancer_conditions + # TODO add laterality + if "performedPeriod" in resource: + cancer_related_procedure["extra_properties"] = { + "performed_period": resource["performedPeriod"] + } + return cancer_related_procedure + + +def get_medication_statement(resource): + """ Medication Statement to Medication Statement. """ + medication_statement = { + "id": resource["id"] + } + if "medicationCodeableConcept" in resource: + medication_statement["medication_code"] = get_ontology_value(resource, "medicationCodeableConcept") + # TODO the rest + return medication_statement + + +def _get_tnm_staging_property(resource: dict, profile_urls: list, category_type=None): + """" Retrieve Observation based on its profile. """ + for profile in profile_urls: + if profile in resource["meta"]["profile"]: + property_value = observation_to_tnm_staging(resource) + if category_type: + property_value["category_type"] = category_type + return property_value + + +def _get_profiles(resource: dict, profile_urls: list): + # Can raise a KeyError in some cases + resource_profiles = resource["meta"]["profile"] + for p_url in profile_urls: + if p_url in resource_profiles: + return True + + +def condition_to_cancer_condition(resource): + """ FHIR Condition to Mcode Cancer Condition. """ + + cancer_condition = { + "id": resource["id"] + } + # condition = cond.Condition(resource) + if "clinicalStatus" in resource: + cancer_condition["clinical_status"] = get_ontology_value(resource, "clinicalStatus") + if "verificationStatus" in resource: + cancer_condition["verification_status"] = get_ontology_value(resource, "verificationStatus") + if "code" in resource: + cancer_condition["code"] = get_ontology_value(resource, "code") + if "recordedDate" in resource: + cancer_condition["date_of_diagnosis"] = resource["recordedDate"] + if "bodySite" in resource: + cancer_condition["body_site"] = [] + for item in resource["bodySite"]['coding']: + coding = { + "id": f"{item['system']}:{item['code']}", + "label": f"{item['display']}", + } + cancer_condition["body_site"].append(coding) + if "laterality" in resource: + cancer_condition["laterality"] = get_ontology_value(resource, "laterality") + if "histologyMorphologyBehavior" in resource: + cancer_condition["histology_morphology_behavior"] = get_ontology_value(resource, "histologyMorphologyBehavior") + return cancer_condition + + +def parse_bundle(bundle): + """ + Parse fhir Bundle and extract all relevant profiles. + :param bundle: FHIR resourceType Bundle object + :return: mcodepacket object + """ + check_schema(FHIR_BUNDLE_SCHEMA, bundle, 'bundle') + mcodepacket = { + "id": str(uuid.uuid4()) + } + tumor_markers = [] + # all tnm_stagings + tnm_stagings = [] + # dict that maps tnm_staging value to its member + staging_to_members = {} + # all tnm staging members + tnm_staging_members = [] + # all procedure + cancer_related_procedures = [] + # all cancer conditions + cancer_conditions = [] + # all medication statements + medication_statements = [] + for item in bundle["entry"]: + resource = item["resource"] + # get Patient data + if resource["resourceType"] == "Patient": + mcodepacket["subject"] = patient_to_individual(resource) + + # get Patient's Cancer Condition + if resource["resourceType"] == "Condition": + resource_profiles = resource["meta"]["profile"] + cancer_conditions_profiles = [p.MCODE_PRIMARY_CANCER_CONDITION, p.MCODE_SECONDARY_CANCER_CONDITION] + for cc in cancer_conditions_profiles: + if cc in resource_profiles: + cancer_condition = condition_to_cancer_condition(resource) + for key, value in MCODE_PROFILES_MAPPING["cancer_condition"]["profile"].items(): + if cc == value: + cancer_condition["condition_type"] = key + cancer_conditions.append(cancer_condition) + + # get TNM staging stage category + if resource["resourceType"] == "Observation" and "meta" in resource: + resource_profiles = resource["meta"]["profile"] + stage_groups = [p.MCODE_TNM_CLINICAL_STAGE_GROUP, p.MCODE_TNM_PATHOLOGIC_STAGE_GROUP] + for sg in stage_groups: + if sg in resource_profiles: + tnm_staging = {"id": resource["id"]} + tnm_stage_group = observation_to_tnm_staging(resource) + tnm_staging["cancer_condition"] = tnm_stage_group["cancer_condition"] + tnm_staging["stage_group"] = tnm_stage_group["tnm_staging_value"] + for k, v in MCODE_PROFILES_MAPPING["tnm_staging"]["properties_profile"]["stage_group"].items(): + if sg == v: + tnm_staging["tnm_type"] = k + if "hasMember" in resource: + members = [member["reference"].split('/')[-1] for member in resource["hasMember"]] + # collect all members of this staging in a dict + staging_to_members[resource["id"]] = members + tnm_stagings.append(tnm_staging) + + # get all TNM staging members + if resource["resourceType"] == "Observation" and "meta" in resource: + primary_tumor_category = _get_tnm_staging_property(resource, + [p.MCODE_TNM_CLINICAL_PRIMARY_TUMOR_CATEGORY, + p.MCODE_TNM_PATHOLOGIC_PRIMARY_TUMOR_CATEGORY], + 'primary_tumor_category') + regional_nodes_category = _get_tnm_staging_property(resource, + [p.MCODE_TNM_CLINICAL_REGIONAL_NODES_CATEGORY, + p.MCODE_TNM_PATHOLOGIC_REGIONAL_NODES_CATEGORY], + 'regional_nodes_category') + distant_metastases_category = _get_tnm_staging_property(resource, + [p.MCODE_TNM_CLINICAL_REGIONAL_NODES_CATEGORY, + p.MCODE_TNM_PATHOLOGIC_REGIONAL_NODES_CATEGORY], + 'distant_metastases_category') + + for category in [primary_tumor_category, regional_nodes_category, distant_metastases_category]: + if category: + tnm_staging_members.append(category) + + # get Cancer Related Procedure + if resource["resourceType"] == "Procedure" and "meta" in resource: + resource_profiles = resource["meta"]["profile"] + procedure_profiles = [p.MCODE_CANCER_RELATED_RADIATION_PROCEDURE, p.MCODE_CANCER_RELATED_SURGICAL_PROCEDURE] + for pp in procedure_profiles: + if pp in resource_profiles: + procedure = procedure_to_crprocedure(resource) + for key, value in MCODE_PROFILES_MAPPING["cancer_related_procedure"]["profile"].items(): + if pp == value: + procedure["procedure_type"] = key + cancer_related_procedures.append(procedure) + + # get tumor marker + if resource["resourceType"] == "Observation" and "meta" in resource: + if p.MCODE_TUMOR_MARKER in resource["meta"]["profile"]: + labs_vital = observation_to_labs_vital(resource) + tumor_markers.append(labs_vital) + + # get Medication Statement + if resource["resourceType"] == "MedicationStatement" and "meta" in resource: + if p.MCODE_MEDICATION_STATEMENT in resource["meta"]["profile"]: + medication_statements.append(get_medication_statement(resource)) + + # get Cancer Disease Status + if resource["resourceType"] == "Observation" and "meta" in resource: + if p.MCODE_CANCER_DISEASE_STATUS in resource["meta"]["profile"]: + # TODO change refactor observation conversion + mcodepacket["cancer_disease_status"] = observation_to_labs_vital(resource)["tumor_marker_data_value"] + + # annotate tnm staging with its members + for tnm_staging_item in tnm_stagings: + for member in tnm_staging_members: + if member["id"] in staging_to_members[tnm_staging_item["id"]]: + tnm_staging_item[member["category_type"]] = member["tnm_staging_value"] + + if cancer_conditions: + mcodepacket["cancer_condition"] = cancer_conditions + + if medication_statements: + mcodepacket["medication_statement"] = medication_statements + + mcodepacket["tumor_marker"] = tumor_markers + mcodepacket["cancer_related_procedures"] = cancer_related_procedures + + for tnms in tnm_stagings: + if tnms["cancer_condition"] in [cc_id["id"] for cc_id in mcodepacket["cancer_condition"]]: + for cc in mcodepacket["cancer_condition"]: + if cc["id"] == tnms["cancer_condition"]: + if "tnm_staging" in cc: + cc["tnm_staging"].append(tnms) + else: + cc["tnm_staging"] = [tnms] + + return mcodepacket diff --git a/chord_metadata_service/mcode/schemas.py b/chord_metadata_service/mcode/schemas.py new file mode 100644 index 000000000..da71334df --- /dev/null +++ b/chord_metadata_service/mcode/schemas.py @@ -0,0 +1,412 @@ +from chord_metadata_service.restapi.schema_utils import customize_schema +from chord_metadata_service.restapi.schemas import ONTOLOGY_CLASS, ONTOLOGY_CLASS_LIST, EXTRA_PROPERTIES_SCHEMA +from chord_metadata_service.restapi.description_utils import describe_schema +from chord_metadata_service.patients.schemas import INDIVIDUAL_SCHEMA +from . import descriptions as d + +# ========================= mCode/FHIR based schemas ========================= + +# === FHIR datatypes === + +# FHIR Quantity https://www.hl7.org/fhir/datatypes.html#Quantity +QUANTITY = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:quantity_schema", + "title": "Quantity schema", + "description": "Schema for the datatype Quantity.", + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "comparator": { + "enum": ["<", ">", "<=", ">=", "="] + }, + "unit": { + "type": "string" + }, + "system": { + "type": "string", + "format": "uri" + }, + "code": { + "type": "string" + } + }, + "additionalProperties": False +} + +# FHIR CodeableConcept https://www.hl7.org/fhir/datatypes.html#CodeableConcept +CODEABLE_CONCEPT = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:codeable_concept_schema", + "title": "Codeable Concept schema", + "description": "Schema for the datatype Concept.", + "type": "object", + "properties": { + "coding": { + "type": "array", + "items": { + "type": "object", + "properties": { + "system": {"type": "string", "format": "uri"}, + "version": {"type": "string"}, + "code": {"type": "string"}, + "display": {"type": "string"}, + "user_selected": {"type": "boolean"} + } + } + }, + "text": { + "type": "string" + } + }, + "additionalProperties": False +} + +# FHIR Period https://www.hl7.org/fhir/datatypes.html#Period +PERIOD = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:period_schema", + "title": "Period", + "description": "Period schema.", + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": False +} + +# FHIR Ratio https://www.hl7.org/fhir/datatypes.html#Ratio +RATIO = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:ratio", + "title": "Ratio", + "description": "Ratio schema.", + "type": "object", + "properties": { + "numerator": QUANTITY, + "denominator": QUANTITY + }, + "additionalProperties": False +} + +# === FHIR based mCode elements === + +TIME_OR_PERIOD = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:time_or_period", + "title": "Time of Period", + "description": "Time of Period schema.", + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "string", "format": "date-time"}, + PERIOD + ] + } + }, + "additionalProperties": False +} + +TUMOR_MARKER_DATA_VALUE = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:tumor_marker_data_value", + "title": "Tumor marker data value", + "description": "Tumor marker data value schema.", + "type": "object", + "properties": { + "value": { + "anyOf": [ + ONTOLOGY_CLASS, + QUANTITY, + RATIO + ] + } + }, + "additionalProperties": False +} + +# TODO this is definitely should be changed, fhir datatypes are too complex use Ontology_ class +COMPLEX_ONTOLOGY = customize_schema( + first_typeof=ONTOLOGY_CLASS, + second_typeof=ONTOLOGY_CLASS, + first_property="data_value", + second_property="staging_system", + schema_id="chord_metadata_service:complex_ontology_schema", + title="Complex ontology", + description="Complex object to combine data value and staging system.", + required=["data_value"] +) + + +# =================== Metadata service mCode based schemas =================== + + +MCODE_GENETIC_SPECIMEN_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "specimen_type": ONTOLOGY_CLASS, + "collection_body": ONTOLOGY_CLASS, + "laterality": ONTOLOGY_CLASS, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "specimen_type"] +}, d.GENETIC_SPECIMEN) + + +MCODE_CANCER_GENETIC_VARIANT_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data_value": ONTOLOGY_CLASS, + "method": ONTOLOGY_CLASS, + "amino_acid_change": ONTOLOGY_CLASS, + "amino_acid_change_type": ONTOLOGY_CLASS, + "cytogenetic_location": { + "type": "object" + }, + "cytogenetic_nomenclature": ONTOLOGY_CLASS, + "gene_studied": { + "type": "array", + "items": { + "type": "string" + } + }, + "genomic_dna_change": ONTOLOGY_CLASS, + "genomic_source_class": ONTOLOGY_CLASS, + "variation_code": ONTOLOGY_CLASS_LIST, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "specimen_type"] +}, d.CANCER_GENETIC_VARIANT) + + +MCODE_GENOMIC_REGION_STUDIED_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "dna_ranges_examined": ONTOLOGY_CLASS_LIST, + "dna_region_description": { + "type": "array", + "items": { + "type": "string" + } + }, + "gene_mutation": ONTOLOGY_CLASS_LIST, + "gene_studied": ONTOLOGY_CLASS_LIST, + "genomic_reference_sequence_id": { + "type": "object" + }, + "genomic_region_coordinate_system": ONTOLOGY_CLASS, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "specimen_type"] +}, d.GENOMIC_REGION_STUDIED) + + +MCODE_GENOMICS_REPORT_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": ONTOLOGY_CLASS, + "performing_organization_name": { + "type": "string" + }, + "issued": { + "type": "string", + "format": "date-time" + }, + "genetic_specimen": { + "type": "array", + "items": MCODE_GENETIC_SPECIMEN_SCHEMA + }, + "genetic_variant": MCODE_CANCER_GENETIC_VARIANT_SCHEMA, + "genomic_region_studied": MCODE_GENOMIC_REGION_STUDIED_SCHEMA, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "code", "issued"] +}, d.GENOMICS_REPORT) + + +MCODE_LABS_VITAL_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "individual": { + "type": "string" + }, + "tumor_marker_code": ONTOLOGY_CLASS, + "tumor_marker_data_value": TUMOR_MARKER_DATA_VALUE, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "individual", "tumor_marker_code"] +}, d.LABS_VITAL) + + +MCODE_TNM_STAGING_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tnm_type": { + "type": "string", + "enum": [ + "clinical", + "pathologic" + ] + }, + "stage_group": COMPLEX_ONTOLOGY, + "primary_tumor_category": COMPLEX_ONTOLOGY, + "regional_nodes_category": COMPLEX_ONTOLOGY, + "distant_metastases_category": COMPLEX_ONTOLOGY, + "cancer_condition": { + "type": "string" + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": [ + "id", + "tnm_type", + "stage_group", + "primary_tumor_category", + "regional_nodes_category", + "distant_metastases_category", + "cancer_condition" + ] +}, d.TNM_STAGING) + + +MCODE_CANCER_CONDITION_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "condition_type": { + "type": "string", + "enum": [ + "primary", + "secondary" + ] + }, + "body_site": ONTOLOGY_CLASS_LIST, + "clinical_status": ONTOLOGY_CLASS, + "code": ONTOLOGY_CLASS, + "date_of_diagnosis": { + "type": "string", + "format": "date-time" + }, + "histology_morphology_behavior": ONTOLOGY_CLASS, + "tnm_staging": { + "type": "array", + "items": MCODE_TNM_STAGING_SCHEMA + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "condition_type", "code"] +}, d.LABS_VITAL) + + +MCODE_CANCER_RELATED_PROCEDURE_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "procedure_type": { + "type": "string", + "enum": [ + "radiation", + "surgical" + ] + }, + "code": ONTOLOGY_CLASS, + "body_site": ONTOLOGY_CLASS_LIST, + "laterality": ONTOLOGY_CLASS, + "treatment_intent": ONTOLOGY_CLASS, + "reason_code": ONTOLOGY_CLASS, + "reason_reference": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "procedure_type", "code"] +}, d.CANCER_RELATED_PROCEDURE) + + +MCODE_MEDICATION_STATEMENT_SCHEMA = describe_schema({ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "medication_code": ONTOLOGY_CLASS, + "termination_reason": ONTOLOGY_CLASS_LIST, + "treatment_intent": ONTOLOGY_CLASS, + "start_date": { + "type": "string", + "format": "date-time" + }, + "end_date": { + "type": "string", + "format": "date-time" + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "medication_code"] +}, d.MEDICATION_STATEMENT) + + +MCODE_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:mcode_schema", + "title": "Metadata service customized mcode schema", + "description": "Schema for describe mcode data elements in metadata service.", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "subject": INDIVIDUAL_SCHEMA, + "genomics_report": MCODE_GENOMICS_REPORT_SCHEMA, + "cancer_condition": MCODE_CANCER_CONDITION_SCHEMA, + "cancer_related_procedures": MCODE_CANCER_RELATED_PROCEDURE_SCHEMA, + "medication_statement": { + "type": "array", + "items": MCODE_MEDICATION_STATEMENT_SCHEMA + }, + "date_of_death": { + "type": "string" + }, + "tumor_marker": { + "type": "array", + "items": MCODE_LABS_VITAL_SCHEMA + }, + "cancer_disease_status": ONTOLOGY_CLASS, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + } +}, d.MCODEPACKET) diff --git a/chord_metadata_service/mcode/serializers.py b/chord_metadata_service/mcode/serializers.py index 0ddc501ae..477b48bb7 100644 --- a/chord_metadata_service/mcode/serializers.py +++ b/chord_metadata_service/mcode/serializers.py @@ -1,26 +1,47 @@ from chord_metadata_service.restapi.serializers import GenericSerializer from chord_metadata_service.patients.serializers import IndividualSerializer -from .models import * +from . import models as m -class GeneticVariantTestedSerializer(GenericSerializer): +__all__ = [ + "GeneticSpecimenSerializer", + "CancerGeneticVariantSerializer", + "GenomicRegionStudiedSerializer", + "GenomicsReportSerializer", + "LabsVitalSerializer", + "TNMStagingSerializer", + "CancerConditionSerializer", + "CancerRelatedProcedureSerializer", + "MedicationStatementSerializer", + "MCodePacketSerializer", +] + + +class GeneticSpecimenSerializer(GenericSerializer): + + class Meta: + model = m.GeneticSpecimen + fields = '__all__' + + +class CancerGeneticVariantSerializer(GenericSerializer): class Meta: - model = GeneticVariantTested + model = m.CancerGeneticVariant fields = '__all__' -class GeneticVariantFoundSerializer(GenericSerializer): - +class GenomicRegionStudiedSerializer(GenericSerializer): + class Meta: - model = GeneticVariantFound + model = m.GenomicRegionStudied fields = '__all__' class GenomicsReportSerializer(GenericSerializer): class Meta: - model = GenomicsReport + model = m.GenomicsReport fields = '__all__' def to_representation(self, instance): @@ -29,24 +50,20 @@ def to_representation(self, instance): objects and return their nested serialization. """ response = super().to_representation(instance) - response['genetic_variant_tested'] = GeneticVariantTestedSerializer(instance.genetic_variant_tested, - many=True, required=False).data - response['genetic_variant_found'] = GeneticVariantFoundSerializer(instance.genetic_variant_found, - many=True, required=False).data return response class LabsVitalSerializer(GenericSerializer): class Meta: - model = LabsVital + model = m.LabsVital fields = '__all__' class TNMStagingSerializer(GenericSerializer): class Meta: - model = TNMStaging + model = m.TNMStaging fields = '__all__' @@ -54,21 +71,21 @@ class CancerConditionSerializer(GenericSerializer): tnm_staging = TNMStagingSerializer(source='tnmstaging_set', read_only=True, many=True) class Meta: - model = CancerCondition + model = m.CancerCondition fields = '__all__' class CancerRelatedProcedureSerializer(GenericSerializer): class Meta: - model = CancerRelatedProcedure + model = m.CancerRelatedProcedure fields = '__all__' class MedicationStatementSerializer(GenericSerializer): class Meta: - model = MedicationStatement + model = m.MedicationStatement fields = '__all__' @@ -82,13 +99,15 @@ def to_representation(self, instance): response = super().to_representation(instance) response['subject'] = IndividualSerializer(instance.subject).data response['genomics_report'] = GenomicsReportSerializer(instance.genomics_report, required=False).data - response['cancer_condition'] = CancerConditionSerializer(instance.cancer_condition, required=False).data + response['cancer_condition'] = CancerConditionSerializer(instance.cancer_condition, many=True, + required=False).data response['cancer_related_procedures'] = CancerRelatedProcedureSerializer(instance.cancer_related_procedures, many=True, required=False).data response['medication_statement'] = MedicationStatementSerializer(instance.medication_statement, - required=False).data + many=True, required=False).data + # TODO add tumor marker return response class Meta: - model = MCodePacket + model = m.MCodePacket fields = '__all__' diff --git a/chord_metadata_service/mcode/tests/constants.py b/chord_metadata_service/mcode/tests/constants.py index 7136643e0..33ec199c1 100644 --- a/chord_metadata_service/mcode/tests/constants.py +++ b/chord_metadata_service/mcode/tests/constants.py @@ -19,145 +19,25 @@ "active": True } -VALID_GENETIC_VARIANT_TESTED = { - "id": "variant_tested:01", - "method": { - "id": "C17003", - "label": "Polymerase Chain Reaction" - }, - "variant_tested_identifier": { - "id": "360448", - "label": "360448", - }, - "variant_tested_hgvs_name": [ - "NC_000007.13:g.55086734A>G", - "NC_000007.14:g.55019041A>G", - "NM_001346897.2:c.-237A>G", - "NM_001346898.2:c.-237A>G", - "NM_001346899.1:c.-237A>G", - "NM_001346941.2:c.-237A>G", - "NM_005228.5:c.-237A>G", - "NM_201282.2:c.-237A>G", - "NM_201283.1:c.-237A>G", - "NM_201284.2:c.-237A>G", - "LRG_304t1:c.-237A>G", - "LRG_304:g.5010A>G", - "NG_007726.3:g.5010A>G" - ], - "variant_tested_description": "single nucleotide variant", - "data_value": { - "id": "LA6576-8", - "label": "Positive", - } -} - -INVALID_GENETIC_VARIANT_TESTED = { - "id": "variant_tested:02", - "method": ["invalid_value"], - "variant_tested_identifier": { - "id": "360448", - "label": "360448", - }, - "variant_tested_hgvs_name": [ - "NC_000007.13:g.55086734A>G", - "NC_000007.14:g.55019041A>G", - "NM_001346897.2:c.-237A>G", - "NM_001346898.2:c.-237A>G", - "NM_001346899.1:c.-237A>G", - "NM_001346941.2:c.-237A>G", - "NM_005228.5:c.-237A>G", - "NM_201282.2:c.-237A>G", - "NM_201283.1:c.-237A>G", - "NM_201284.2:c.-237A>G", - "LRG_304t1:c.-237A>G", - "LRG_304:g.5010A>G", - "NG_007726.3:g.5010A>G" - ], - "variant_tested_description": "single nucleotide variant", - "data_value": { - "id": "LA6576-8", - "label": "Positive", - } -} - -VALID_GENETIC_VARIANT_FOUND = { - "id": "variant_found:01", - "method": { - "id": "C17003", - "label": "Polymerase Chain Reaction" - }, - "variant_found_identifier": { - "id": "360448", - "label": "360448", - }, - "variant_found_hgvs_name": [ - "NC_000007.13:g.55086734A>G", - "NC_000007.14:g.55019041A>G", - "NM_001346897.2:c.-237A>G", - "NM_001346898.2:c.-237A>G", - "NM_001346899.1:c.-237A>G", - "NM_001346941.2:c.-237A>G", - "NM_005228.5:c.-237A>G", - "NM_201282.2:c.-237A>G", - "NM_201283.1:c.-237A>G", - "NM_201284.2:c.-237A>G", - "LRG_304t1:c.-237A>G", - "LRG_304:g.5010A>G", - "NG_007726.3:g.5010A>G" - ], - "variant_found_description": "single nucleotide variant", - "genomic_source_class": { - "id": "LA6684-0", - "label": "Somatic", - } -} - def valid_genetic_report(): return { "id": "genomics_report:01", - "test_name": { + "code": { "id": "GTR000567625.2", "label": "PREVENTEST", }, - "performing_organization_name": "Test organization", - "specimen_type": { - "id": "119342007 ", - "label": "SAL (Saliva)", - } + "issued": "2018-11-13T20:20:39+00:00", + "performing_organization_name": "Test organization" } def valid_labs_vital(individual): return { "id": "labs_vital:01", - "body_height": { - "value": 1.70, - "unit": "m" - }, - "body_weight": { - "value": 60, - "unit": "kg" - }, - "cbc_with_auto_differential_panel": ["Test"], - "comprehensive_metabolic_2000": ["Test"], - "blood_pressure_diastolic": { - "value": 80, - "unit": "mmHg" - }, - "blood_pressure_systolic": { - "value": 120, - "unit": "mmHg" - }, - "tumor_marker_test": { - "code": { - "id": "50610-5", - "label": "Alpha-1-Fetoprotein" - }, - "data_value": { - "value": 10, - "unit": "ng/mL" - } + "tumor_marker_code": { + "id": "50610-5", + "label": "Alpha-1-Fetoprotein" }, "individual": individual, } @@ -167,7 +47,7 @@ def valid_cancer_condition(): return { "id": "cancer_condition:01", "condition_type": "primary", - "body_location_code": [ + "body_site": [ { "id": "442083009", "label": "Anatomical or acquired body structure (body structure)" @@ -177,7 +57,7 @@ def valid_cancer_condition(): "id": "active", "label": "Active" }, - "condition_code": { + "code": { "id": "404087009", "label": "Carcinosarcoma of skin (disorder)" }, @@ -248,13 +128,7 @@ def valid_cancer_related_procedure(): "id": "33356009", "label": "Betatron teleradiotherapy (procedure)" }, - "occurence_time_or_period": { - "value": { - "start": "2018-11-13T20:20:39+00:00", - "end": "2019-04-13T20:20:39+00:00" - } - }, - "target_body_site": [ + "body_site": [ { "id": "74805009", "label": "Mammary gland sinus" @@ -285,6 +159,5 @@ def valid_medication_statement(): "label": "Curative - procedure intent" }, "start_date": "2018-11-13T20:20:39+00:00", - "end_date": "2019-04-13T20:20:39+00:00", - "date_time": "2019-04-13T20:20:39+00:00" + "end_date": "2019-04-13T20:20:39+00:00" } diff --git a/chord_metadata_service/mcode/tests/test_models.py b/chord_metadata_service/mcode/tests/test_models.py index 8fd4c93b9..ede3ac6a0 100644 --- a/chord_metadata_service/mcode/tests/test_models.py +++ b/chord_metadata_service/mcode/tests/test_models.py @@ -1,100 +1,37 @@ import datetime from django.test import TestCase from chord_metadata_service.patients.models import Individual -from ..models import * -from .constants import * +from .. import models as m +from . import constants as c from rest_framework import serializers -from django.core.validators import ValidationError - -class GeneticVariantTestedTest(TestCase): - """ Test module for GeneticVariantTested model """ - - def setUp(self): - GeneticVariantTested.objects.create(**VALID_GENETIC_VARIANT_TESTED) - - def test_variant_tested(self): - variant_tested = GeneticVariantTested.objects.get(id='variant_tested:01') - self.assertIsInstance(variant_tested.method, dict) - self.assertEqual(variant_tested.method['label'], 'Polymerase Chain Reaction') - self.assertEqual(variant_tested.variant_tested_identifier['id'], - variant_tested.variant_tested_identifier['label']) - self.assertIsInstance(variant_tested.variant_tested_hgvs_name, list) - self.assertIn("NM_001346897.2:c.-237A>G", variant_tested.variant_tested_hgvs_name) - self.assertIsInstance(variant_tested.variant_tested_description, str) - self.assertEqual(variant_tested.variant_tested_description, 'single nucleotide variant') - self.assertEqual(variant_tested.data_value['id'], 'LA6576-8') - self.assertEqual(variant_tested.data_value['label'], 'Positive') - - def create(self, **kwargs): - e = GeneticVariantTested(**kwargs) - e.full_clean() - e.save() - - def test_validation(self): - self.assertRaises(serializers.ValidationError, self.create, **INVALID_GENETIC_VARIANT_TESTED) - - -class GeneticVariantFoundTest(TestCase): - """ Test module for GeneticVariantFound model """ - - def setUp(self): - GeneticVariantFound.objects.create(**VALID_GENETIC_VARIANT_FOUND) - - def test_variant_found(self): - variant_found = GeneticVariantFound.objects.get(id='variant_found:01') - self.assertIsInstance(variant_found.method, dict) - self.assertEqual(variant_found.method['label'], 'Polymerase Chain Reaction') - self.assertEqual(variant_found.variant_found_identifier['id'], - variant_found.variant_found_identifier['label']) - self.assertIsInstance(variant_found.variant_found_hgvs_name, list) - self.assertIn("NM_001346897.2:c.-237A>G", variant_found.variant_found_hgvs_name) - self.assertIsInstance(variant_found.variant_found_description, str) - self.assertEqual(variant_found.variant_found_description, 'single nucleotide variant') - self.assertEqual(variant_found.genomic_source_class['id'], 'LA6684-0') - self.assertEqual(variant_found.genomic_source_class['label'], 'Somatic') class GenomicsReportTest(TestCase): """ Test module for Genomics Report model """ def setUp(self): - self.variant_tested = GeneticVariantTested.objects.create(**VALID_GENETIC_VARIANT_TESTED) - self.variant_found = GeneticVariantFound.objects.create(**VALID_GENETIC_VARIANT_FOUND) - self.genomics_report = GenomicsReport.objects.create(**valid_genetic_report()) - self.genomics_report.genetic_variant_tested.set([self.variant_tested]) - self.genomics_report.genetic_variant_found.set([self.variant_found]) + self.genomics_report = m.GenomicsReport.objects.create(**c.valid_genetic_report()) def test_genomics_report(self): - genomics_report = GenomicsReport.objects.get(id='genomics_report:01') - self.assertEqual(genomics_report.test_name['id'], 'GTR000567625.2') - self.assertIsInstance(genomics_report.specimen_type, dict) - self.assertIsNotNone(genomics_report.genetic_variant_tested) - self.assertEqual(genomics_report.genetic_variant_tested.count(), 1) - self.assertEqual(genomics_report.genetic_variant_found.count(), 1) + genomics_report = m.GenomicsReport.objects.get(id='genomics_report:01') + self.assertEqual(genomics_report.code['id'], 'GTR000567625.2') class LabsVitalTest(TestCase): """ Test module for LabsVital model """ def setUp(self): - self.individual = Individual.objects.create(**VALID_INDIVIDUAL) - self.labs_vital = LabsVital.objects.create(**valid_labs_vital(self.individual)) + self.individual = Individual.objects.create(**c.VALID_INDIVIDUAL) + self.labs_vital = m.LabsVital.objects.create(**c.valid_labs_vital(self.individual)) def test_labs_vital(self): - labs_vital = LabsVital.objects.get(id='labs_vital:01') - self.assertEqual(labs_vital.body_height['value'], 1.70) - self.assertEqual(labs_vital.body_height['unit'], 'm') - self.assertEqual(labs_vital.body_weight['value'], 60) - self.assertEqual(labs_vital.blood_pressure_diastolic['value'], 80) - self.assertEqual(labs_vital.blood_pressure_systolic['value'], 120) - self.assertIsInstance(labs_vital.tumor_marker_test, dict) - self.assertIsInstance(labs_vital.tumor_marker_test['code'], dict) - self.assertEqual(labs_vital.tumor_marker_test['data_value']['value'], 10) + labs_vital = m.LabsVital.objects.get(id='labs_vital:01') + self.assertIsInstance(labs_vital.tumor_marker_code, dict) def test_validation(self): - invalid_obj = valid_labs_vital(self.individual) + invalid_obj = c.valid_labs_vital(self.individual) invalid_obj["id"] = "labs_vital:02" - invalid_obj["tumor_marker_test"]["code"] = { + invalid_obj["tumor_marker_code"] = { "coding": [ { "code": "50610-5", @@ -103,7 +40,7 @@ def test_validation(self): } ] } - invalid = LabsVital.objects.create(**invalid_obj) + invalid = m.LabsVital.objects.create(**invalid_obj) with self.assertRaises(serializers.ValidationError): invalid.full_clean() @@ -112,16 +49,16 @@ class CancerConditionTest(TestCase): """ Test module for CancerCondition model """ def setUp(self): - self.cancer_condition = CancerCondition.objects.create(**valid_cancer_condition()) + self.cancer_condition = m.CancerCondition.objects.create(**c.valid_cancer_condition()) def test_cancer_condition(self): - cancer_condition = CancerCondition.objects.get(id='cancer_condition:01') + cancer_condition = m.CancerCondition.objects.get(id='cancer_condition:01') self.assertEqual(cancer_condition.condition_type, 'primary') - self.assertIsInstance(cancer_condition.body_location_code, list) - self.assertEqual(cancer_condition.body_location_code[0]['id'], '442083009') + self.assertIsInstance(cancer_condition.body_site, list) + self.assertEqual(cancer_condition.body_site[0]['id'], '442083009') self.assertEqual(cancer_condition.clinical_status['id'], 'active') - condition_code_keys = [key for key in cancer_condition.condition_code.keys()] - self.assertEqual(condition_code_keys, ['id', 'label']) + code_keys = [key for key in cancer_condition.code.keys()] + self.assertEqual(code_keys, ['id', 'label']) self.assertEqual(cancer_condition.histology_morphology_behavior['id'], '372147008') @@ -131,19 +68,19 @@ class TNMStagingTest(TestCase): # TODO URI syntax examples for tests https://tools.ietf.org/html/rfc3986 def setUp(self): - self.cancer_condition = CancerCondition.objects.create(**valid_cancer_condition()) - self.tnm_staging = TNMStaging.objects.create(**invalid_tnm_staging(self.cancer_condition)) + self.cancer_condition = m.CancerCondition.objects.create(**c.valid_cancer_condition()) + self.tnm_staging = m.TNMStaging.objects.create(**c.invalid_tnm_staging(self.cancer_condition)) def test_tnm_staging(self): - tnm_staging = TNMStaging.objects.get(id='tnm_staging:01') + tnm_staging = m.TNMStaging.objects.get(id='tnm_staging:01') self.assertEqual(tnm_staging.tnm_type, 'clinical') # this should fails in validation below self.assertIsInstance(tnm_staging.stage_group['data_value']['coding'], list) def test_validation(self): - invalid_obj = invalid_tnm_staging(self.cancer_condition) + invalid_obj = c.invalid_tnm_staging(self.cancer_condition) invalid_obj["id"] = "tnm_staging:02" - invalid = TNMStaging.objects.create(**invalid_obj) + invalid = m.TNMStaging.objects.create(**invalid_obj) with self.assertRaises(serializers.ValidationError): invalid.full_clean() @@ -152,16 +89,15 @@ class CancerRelatedProcedureTest(TestCase): """ Test module for CancerRelatedProcedure model """ def setUp(self): - self.cancer_related_procedure = CancerRelatedProcedure.objects.create( - **valid_cancer_related_procedure() + self.cancer_related_procedure = m.CancerRelatedProcedure.objects.create( + **c.valid_cancer_related_procedure() ) def test_cancer_related_procedure(self): - cancer_related_procedure = CancerRelatedProcedure.objects.get(id='cancer_related_procedure:01') + cancer_related_procedure = m.CancerRelatedProcedure.objects.get(id='cancer_related_procedure:01') self.assertEqual(cancer_related_procedure.procedure_type, 'radiation') self.assertEqual(cancer_related_procedure.code['id'], '33356009') - self.assertEqual(cancer_related_procedure.occurence_time_or_period['value']['start'], '2018-11-13T20:20:39+00:00') - self.assertIsInstance(cancer_related_procedure.target_body_site, list) + self.assertIsInstance(cancer_related_procedure.body_site, list) self.assertEqual(cancer_related_procedure.treatment_intent['label'], 'Curative - procedure intent') @@ -169,14 +105,14 @@ class MedicationStatementTest(TestCase): """ Test module for MedicationStatement model """ def setUp(self): - self.cancer_related_procedure = MedicationStatement.objects.create( - **valid_medication_statement() + self.cancer_related_procedure = m.MedicationStatement.objects.create( + **c.valid_medication_statement() ) def test_cancer_related_procedure(self): - medication_statement = MedicationStatement.objects.get(id='medication_statement:01') + medication_statement = m.MedicationStatement.objects.get(id='medication_statement:01') self.assertEqual(medication_statement.medication_code['id'], '92052') self.assertIsInstance(medication_statement.termination_reason, list) self.assertEqual(medication_statement.treatment_intent['label'], 'Curative - procedure intent') - for date in [medication_statement.start_date, medication_statement.end_date, medication_statement.date_time]: + for date in [medication_statement.start_date, medication_statement.end_date]: self.assertIsInstance(date, datetime.datetime) diff --git a/chord_metadata_service/mcode/validators.py b/chord_metadata_service/mcode/validators.py new file mode 100644 index 000000000..9f1d26b12 --- /dev/null +++ b/chord_metadata_service/mcode/validators.py @@ -0,0 +1,9 @@ +from chord_metadata_service.restapi.validators import JsonSchemaValidator +from . import schemas as s + + +quantity_validator = JsonSchemaValidator(schema=s.QUANTITY, formats=['uri']) +tumor_marker_data_value_validator = JsonSchemaValidator(schema=s.TUMOR_MARKER_DATA_VALUE) +complex_ontology_validator = JsonSchemaValidator(schema=s.COMPLEX_ONTOLOGY, formats=['uri']) +# TODO delete? +time_or_period_validator = JsonSchemaValidator(schema=s.TIME_OR_PERIOD, formats=['date-time']) diff --git a/chord_metadata_service/mcode/views.py b/chord_metadata_service/mcode/views.py deleted file mode 100644 index 91ea44a21..000000000 --- a/chord_metadata_service/mcode/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/chord_metadata_service/metadata/settings.py b/chord_metadata_service/metadata/settings.py index e490b99cd..250215048 100644 --- a/chord_metadata_service/metadata/settings.py +++ b/chord_metadata_service/metadata/settings.py @@ -14,6 +14,8 @@ import sys import logging +from urllib.parse import urlparse + from .. import __version__ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -29,28 +31,33 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.environ.get("CHORD_DEBUG", "true").lower() == "true" -ALLOWED_HOSTS = [os.environ.get("CHORD_HOST", "localhost")] -if DEBUG: - ALLOWED_HOSTS = list(set(ALLOWED_HOSTS + ["localhost", "127.0.0.1", "[::1]"])) - -APPEND_SLASH = False - # CHORD-specific settings -CHORD_URL = os.environ.get("CHORD_URL", None) # Leave None if not specified, for running in other contexts +CHORD_URL = os.environ.get("CHORD_URL") # Leave None if not specified, for running in other contexts # SECURITY WARNING: Don't run with CHORD_PERMISSIONS turned off in production, # unless an alternative permissions system is in place. CHORD_PERMISSIONS = os.environ.get("CHORD_PERMISSIONS", str(not DEBUG)).lower() == "true" -CHORD_SERVICE_TYPE = "ca.c3g.chord:metadata:{}".format(__version__) +CHORD_SERVICE_ARTIFACT = "metadata" +CHORD_SERVICE_TYPE = f"ca.c3g.chord:{CHORD_SERVICE_ARTIFACT}:{__version__}" CHORD_SERVICE_ID = os.environ.get("SERVICE_ID", CHORD_SERVICE_TYPE) # SECURITY WARNING: don't run with AUTH_OVERRIDE turned on in production! AUTH_OVERRIDE = not CHORD_PERMISSIONS +# Allowed hosts - TODO: Derive from CHORD_URL + +CHORD_HOST = urlparse(CHORD_URL or "").netloc +ALLOWED_HOSTS = [CHORD_HOST or "localhost"] +if DEBUG: + ALLOWED_HOSTS = list(set(ALLOWED_HOSTS + ["localhost", "127.0.0.1", "[::1]"])) + +APPEND_SLASH = False + + # Application definition INSTALLED_APPS = [ @@ -61,12 +68,13 @@ 'django.contrib.messages', 'django.contrib.staticfiles', - 'chord_metadata_service.chord', + 'chord_metadata_service.chord.apps.ChordConfig', 'chord_metadata_service.experiments.apps.ExperimentsConfig', 'chord_metadata_service.patients.apps.PatientsConfig', 'chord_metadata_service.phenopackets.apps.PhenopacketsConfig', 'chord_metadata_service.mcode.apps.McodeConfig', - 'chord_metadata_service.restapi', + 'chord_metadata_service.resources.apps.ResourcesConfig', + 'chord_metadata_service.restapi.apps.RestapiConfig', 'rest_framework', 'django_nose', @@ -79,7 +87,7 @@ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'chord_lib.auth.django_remote_user.CHORDRemoteUserMiddleware', + 'bento_lib.auth.django_remote_user.BentoRemoteUserMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -160,7 +168,7 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'chord_lib.auth.django_remote_user.CHORDRemoteUserAuthentication' + 'bento_lib.auth.django_remote_user.BentoRemoteUserAuthentication' ], 'DEFAULT_PARSER_CLASSES': ( # allows serializers to use snake_case field names, but parse incoming data as camelCase @@ -191,7 +199,7 @@ ] -AUTHENTICATION_BACKENDS = ["chord_lib.auth.django_remote_user.CHORDRemoteUserBackend"] + ( +AUTHENTICATION_BACKENDS = ["bento_lib.auth.django_remote_user.BentoRemoteUserBackend"] + ( ["django.contrib.auth.backends.ModelBackend"] if DEBUG else []) diff --git a/chord_metadata_service/metadata/urls.py b/chord_metadata_service/metadata/urls.py index 11203bdf9..7f67b6a8a 100644 --- a/chord_metadata_service/metadata/urls.py +++ b/chord_metadata_service/metadata/urls.py @@ -16,7 +16,7 @@ from django.contrib import admin from django.urls import path, include from chord_metadata_service.restapi import api_views, urls as restapi_urls -from chord_metadata_service.chord import views_ingest, views_search +from chord_metadata_service.chord import urls as chord_urls from rest_framework.schemas import get_schema_view from rest_framework_swagger.views import get_swagger_view @@ -24,38 +24,18 @@ from .settings import DEBUG swagger_schema_view = get_swagger_view(title="Metadata Service API") +schema_view = get_schema_view( + title="Metadata Service API", + description="Metadata Service provides a phenotypic description of an Individual in the context of biomedical " + "research.", + version="0.1" +) urlpatterns = [ - path('api/schema', get_schema_view( - title="Metadata Service API", - description="Metadata Service provides a phenotypic description of an Individual " - "in the context of biomedical research.", - version="0.1" - ), - name='openapi-schema'), path('', swagger_schema_view), path('api/', include(restapi_urls)), + path('api/schema', schema_view, name='openapi-schema'), path('service-info', api_views.service_info, name="service-info"), - - path('workflows', views_ingest.workflow_list, name="workflows"), - path('workflows/', views_ingest.workflow_item, name="workflow-detail"), - path('workflows/.wdl', views_ingest.workflow_file, name="workflow-file"), - - path('private/ingest', views_ingest.ingest, name="ingest"), - - path('data-types', views_search.data_type_list, name="data-type-list"), - path('data-types/phenopacket', views_search.data_type_phenopacket, name="data-type-detail"), - path('data-types/phenopacket/schema', views_search.data_type_phenopacket_schema, name="data-type-schema"), - # TODO: Consistent snake or kebab - path('data-types/phenopacket/metadata_schema', views_search.data_type_phenopacket_metadata_schema, - name="data-type-metadata-schema"), - path('tables', views_search.table_list, name="table-list"), - path('tables/', views_search.table_detail, name="table-detail"), - path('tables//summary', views_search.chord_table_summary, name="table-summary"), - path('tables//search', views_search.chord_public_table_search, name="public-table-search"), - path('search', views_search.chord_search, name="search"), - path('fhir-search', views_search.fhir_public_search, name="fhir-search"), - path('private/fhir-search', views_search.fhir_private_search, name="fhir-private-search"), - path('private/search', views_search.chord_private_search, name="private-search"), - path('private/tables//search', views_search.chord_private_table_search, name="private-table-search"), -] + ([path('admin/', admin.site.urls)] if DEBUG else []) + *chord_urls.urlpatterns, # TODO: Use include? can we double up? + *([path('admin/', admin.site.urls)] if DEBUG else []), +] diff --git a/chord_metadata_service/package.cfg b/chord_metadata_service/package.cfg index b60d66a92..0fce38394 100644 --- a/chord_metadata_service/package.cfg +++ b/chord_metadata_service/package.cfg @@ -1,4 +1,4 @@ [package] name = chord_metadata_service -version = 0.6.0 +version = 0.7.0 authors = Ksenia Zaytseva, David Lougheed, Simon Chénard diff --git a/chord_metadata_service/patients/api_views.py b/chord_metadata_service/patients/api_views.py index 35a3f6d4d..bcd3e8228 100644 --- a/chord_metadata_service/patients/api_views.py +++ b/chord_metadata_service/patients/api_views.py @@ -3,10 +3,7 @@ from .serializers import IndividualSerializer from .models import Individual from chord_metadata_service.phenopackets.api_views import BIOSAMPLE_PREFETCH, PHENOPACKET_PREFETCH -from chord_metadata_service.restapi.api_renderers import ( - FHIRRenderer, - PhenopacketsRenderer -) +from chord_metadata_service.restapi.api_renderers import FHIRRenderer, PhenopacketsRenderer from chord_metadata_service.restapi.pagination import LargeResultsSetPagination @@ -25,4 +22,4 @@ class IndividualViewSet(viewsets.ModelViewSet): ).order_by("id") serializer_class = IndividualSerializer pagination_class = LargeResultsSetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (FHIRRenderer, PhenopacketsRenderer) + renderer_classes = (*api_settings.DEFAULT_RENDERER_CLASSES, FHIRRenderer, PhenopacketsRenderer) diff --git a/chord_metadata_service/patients/descriptions.py b/chord_metadata_service/patients/descriptions.py index c895b5835..f66bdf961 100644 --- a/chord_metadata_service/patients/descriptions.py +++ b/chord_metadata_service/patients/descriptions.py @@ -12,23 +12,43 @@ "items": "One of possibly many alternative identifiers for an individual." }, "date_of_birth": "A timestamp representing an individual's date of birth; either exactly or imprecisely.", - "age": None, # TODO: Age or AgeRange + "age": "The age or age range of the individual.", "sex": "The phenotypic sex of an individual, as would be determined by a midwife or physician at birth.", "karyotypic_sex": "The karyotypic sex of an individual.", "taxonomy": ontology_class("specified when more than one organism may be studied. It is advised that codes" "from the NCBI Taxonomy resource are used, e.g. NCBITaxon:9606 for humans"), # FHIR-specific - "active": "Whether a patient's record is in active use.", - "deceased": "Whether a patient is deceased.", + "active": { + "description": "Whether a patient's record is in active use.", + "help": "FHIR-specific property." + }, + "deceased": { + "description": "Whether a patient is deceased.", + "help": "FHIR-specific property." + }, # mCode-specific - "race": "A code for a person's race (mCode).", - "ethnicity": "A code for a person's ethnicity (mCode).", - "comorbid_condition": "One or more conditions that occur with primary condition.", - "ecog_performance_status": "Value representing the Eastern Cooperative Oncology Group performance status.", - "karnofsky": "Value representing the Karnofsky Performance status.", - + "race": { + "description": "A code for a person's race (mCode).", + "help": "mCode-specific property." + }, + "ethnicity": { + "description": "A code for a person's ethnicity (mCode).", + "help": "mCode-specific property." + }, + "comorbid_condition": { + "description": "One or more conditions that occur with primary condition.", + "help": "mCode-specific property." + }, + "ecog_performance_status": { + "description": "Value representing the Eastern Cooperative Oncology Group performance status.", + "help": "mCode-specific property." + }, + "karnofsky": { + "description": "Value representing the Karnofsky Performance status.", + "help": "mCode-specific property." + }, **EXTRA_PROPERTIES } } diff --git a/chord_metadata_service/patients/management/commands/patients_build_index.py b/chord_metadata_service/patients/management/commands/patients_build_index.py index 3fc27f136..44206a81a 100644 --- a/chord_metadata_service/patients/management/commands/patients_build_index.py +++ b/chord_metadata_service/patients/management/commands/patients_build_index.py @@ -15,6 +15,7 @@ class Command(BaseCommand): Takes every individual in the DB, port them over to FHIR-compliant JSON and upload them into elasticsearch """ + def handle(self, *args, **options): # TODO: currently only place we create the index, will have to review if es: diff --git a/chord_metadata_service/patients/migrations/0001_initial.py b/chord_metadata_service/patients/migrations/0001_initial.py deleted file mode 100644 index e08f37220..000000000 --- a/chord_metadata_service/patients/migrations/0001_initial.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 2.2.8 on 2019-12-10 21:04 - -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Individual', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the individual.', max_length=200, primary_key=True, serialize=False)), - ('alternate_ids', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, help_text='A list of alternative identifiers for the individual.', null=True, size=None)), - ('date_of_birth', models.DateField(blank=True, help_text='A timestamp either exact or imprecise.', null=True)), - ('age', models.CharField(blank=True, help_text='The age or age range of the individual.', max_length=200)), - ('sex', models.CharField(blank=True, choices=[('UNKNOWN_SEX', 'UNKNOWN_SEX'), ('FEMALE', 'FEMALE'), ('MALE', 'MALE'), ('OTHER_SEX', 'OTHER_SEX')], help_text='Observed apparent sex of the individual.', max_length=200, null=True)), - ('karyotypic_sex', models.CharField(choices=[('UNKNOWN_KARYOTYPE', 'UNKNOWN_KARYOTYPE'), ('XX', 'XX'), ('XY', 'XY'), ('XO', 'XO'), ('XXY', 'XXY'), ('XXX', 'XXX'), ('XXYY', 'XXYY'), ('XXXY', 'XXXY'), ('XXXX', 'XXXX'), ('XYY', 'XYY'), ('OTHER_KARYOTYPE', 'OTHER_KARYOTYPE')], default='UNKNOWN_KARYOTYPE', help_text='The karyotypic sex of the individual.', max_length=200)), - ('taxonomy', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Ontology resource representing the species (e.g., NCBITaxon:9615).', null=True)), - ('active', models.BooleanField(default=False, help_text="Whether this patient's record is in active use.")), - ('deceased', models.BooleanField(default=False, help_text='Indicates if the individual is deceased or not.')), - ('race', models.CharField(blank=True, help_text="A code for the person's race.", max_length=200)), - ('ethnicity', models.CharField(blank=True, help_text="A code for the person's ethnicity.", max_length=200)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - ] diff --git a/chord_metadata_service/patients/migrations/0001_v1_0_0.py b/chord_metadata_service/patients/migrations/0001_v1_0_0.py new file mode 100644 index 000000000..65e28e48c --- /dev/null +++ b/chord_metadata_service/patients/migrations/0001_v1_0_0.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.13 on 2020-07-06 14:55 + +import chord_metadata_service.restapi.models +import chord_metadata_service.restapi.validators +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Individual', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the individual.', max_length=200, primary_key=True, serialize=False)), + ('alternate_ids', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, help_text='A list of alternative identifiers for the individual.', null=True, size=None)), + ('date_of_birth', models.DateField(blank=True, help_text='A timestamp either exact or imprecise.', null=True)), + ('age', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The age or age range of the individual.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:age_or_age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'An age object describing the age of the individual at the time of collection of biospecimens or phenotypic observations.', 'oneOf': [{'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': "Age range of a subject (e.g. when a subject's age falls into a bin.)", 'help': "Age range of a subject (e.g. when a subject's age falls into a bin.)", 'properties': {'end': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string representing the end of the age range bin.', 'help': 'An ISO8601 duration string representing the end of the age range bin.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, 'start': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string representing the start of the age range bin.', 'help': 'An ISO8601 duration string representing the start of the age range bin.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age range schema', 'type': 'object'}], 'title': 'Age schema', 'type': 'object'}, formats=None)])), + ('sex', models.CharField(blank=True, choices=[('UNKNOWN_SEX', 'UNKNOWN_SEX'), ('FEMALE', 'FEMALE'), ('MALE', 'MALE'), ('OTHER_SEX', 'OTHER_SEX')], help_text='Observed apparent sex of the individual.', max_length=200, null=True)), + ('karyotypic_sex', models.CharField(choices=[('UNKNOWN_KARYOTYPE', 'UNKNOWN_KARYOTYPE'), ('XX', 'XX'), ('XY', 'XY'), ('XO', 'XO'), ('XXY', 'XXY'), ('XXX', 'XXX'), ('XXYY', 'XXYY'), ('XXXY', 'XXXY'), ('XXXX', 'XXXX'), ('XYY', 'XYY'), ('OTHER_KARYOTYPE', 'OTHER_KARYOTYPE')], default='UNKNOWN_KARYOTYPE', help_text='The karyotypic sex of the individual.', max_length=200)), + ('taxonomy', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Ontology resource representing the species (e.g., NCBITaxon:9615).', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('active', models.BooleanField(default=False, help_text="Whether this patient's record is in active use.")), + ('deceased', models.BooleanField(default=False, help_text='Indicates if the individual is deceased or not.')), + ('comorbid_condition', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='One or more conditions that occur with primary condition.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:comorbid_condition_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Comorbid condition schema.', 'properties': {'clinical_status': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'code': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': [], 'title': 'Comorbid Condition schema', 'type': 'object'}, formats=None)])), + ('ecog_performance_status', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Value representing the Eastern Cooperative Oncology Group performance status.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('karnofsky', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Value representing the Karnofsky Performance status.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('race', models.CharField(blank=True, help_text="A code for the person's race.", max_length=200)), + ('ethnicity', models.CharField(blank=True, help_text="A code for the person's ethnicity.", max_length=200)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + ] diff --git a/chord_metadata_service/patients/migrations/0002_remove_individual_age.py b/chord_metadata_service/patients/migrations/0002_remove_individual_age.py deleted file mode 100644 index 65edbd807..000000000 --- a/chord_metadata_service/patients/migrations/0002_remove_individual_age.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.2.7 on 2019-12-16 22:04 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='individual', - name='age', - ), - ] diff --git a/chord_metadata_service/patients/migrations/0003_individual_age.py b/chord_metadata_service/patients/migrations/0003_individual_age.py deleted file mode 100644 index ef375de5e..000000000 --- a/chord_metadata_service/patients/migrations/0003_individual_age.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.7 on 2019-12-16 22:04 - -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0002_remove_individual_age'), - ] - - operations = [ - migrations.AddField( - model_name='individual', - name='age', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The age or age range of the individual.', null=True), - ), - ] diff --git a/chord_metadata_service/patients/migrations/0004_auto_20200129_1537.py b/chord_metadata_service/patients/migrations/0004_auto_20200129_1537.py deleted file mode 100644 index 7689a5273..000000000 --- a/chord_metadata_service/patients/migrations/0004_auto_20200129_1537.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-29 15:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0003_individual_age'), - ] - - operations = [ - migrations.AlterField( - model_name='individual', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='individual', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/chord_metadata_service/patients/migrations/0005_auto_20200311_1610.py b/chord_metadata_service/patients/migrations/0005_auto_20200311_1610.py deleted file mode 100644 index c763dc652..000000000 --- a/chord_metadata_service/patients/migrations/0005_auto_20200311_1610.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 2.2.10 on 2020-03-11 20:10 - -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0004_auto_20200129_1537'), - ] - - operations = [ - migrations.AddField( - model_name='individual', - name='comorbid_condition', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='One or more conditions that occur with primary condition.', null=True), - ), - migrations.AddField( - model_name='individual', - name='ecog_performance_status', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Value representing the Eastern Cooperative Oncology Group performance status.', null=True), - ), - migrations.AddField( - model_name='individual', - name='karnofsky', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Value representing the Karnofsky Performance status.', null=True), - ), - ] diff --git a/chord_metadata_service/patients/migrations/0006_auto_20200401_1504.py b/chord_metadata_service/patients/migrations/0006_auto_20200401_1504.py deleted file mode 100644 index b45c7ab94..000000000 --- a/chord_metadata_service/patients/migrations/0006_auto_20200401_1504.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 2.2.10 on 2020-04-01 19:04 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0005_auto_20200311_1610'), - ] - - operations = [ - migrations.AlterField( - model_name='individual', - name='age', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The age or age range of the individual.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:age_or_age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age object describing the age of the individual at the time of collection of biospecimens or phenotypic observations.', 'properties': {'age': {'anyOf': [{'description': 'An ISO8601 string represent age.', 'type': 'string'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age range of a subject.', 'properties': {'end': {'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'type': 'object'}, 'start': {'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age schema', 'type': 'object'}]}}, 'title': 'Age schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='individual', - name='comorbid_condition', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='One or more conditions that occur with primary condition.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:comorbid_condition_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Comorbid condition schema.', 'properties': {'clinical_status': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'code': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}}, 'required': [], 'title': 'Comorbid Condition schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='individual', - name='ecog_performance_status', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Value representing the Eastern Cooperative Oncology Group performance status.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='individual', - name='karnofsky', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Value representing the Karnofsky Performance status.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='individual', - name='taxonomy', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Ontology resource representing the species (e.g., NCBITaxon:9615).', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - ] diff --git a/chord_metadata_service/patients/migrations/0007_auto_20200430_1444.py b/chord_metadata_service/patients/migrations/0007_auto_20200430_1444.py deleted file mode 100644 index cc0ddae7b..000000000 --- a/chord_metadata_service/patients/migrations/0007_auto_20200430_1444.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2.12 on 2020-04-30 14:44 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('patients', '0006_auto_20200401_1504'), - ] - - operations = [ - migrations.AlterField( - model_name='individual', - name='age', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The age or age range of the individual.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:age_or_age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'An age object describing the age of the individual at the time of collection of biospecimens or phenotypic observations.', 'oneOf': [{'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age range of a subject.', 'properties': {'end': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, 'start': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age range schema', 'type': 'object'}], 'title': 'Age schema', 'type': 'object'})]), - ), - ] diff --git a/chord_metadata_service/patients/models.py b/chord_metadata_service/patients/models.py index a2fcb4b5e..7c473614b 100644 --- a/chord_metadata_service/patients/models.py +++ b/chord_metadata_service/patients/models.py @@ -1,9 +1,8 @@ from django.db import models from django.contrib.postgres.fields import JSONField, ArrayField from chord_metadata_service.restapi.models import IndexableMixin -from chord_metadata_service.restapi.validators import ( - ontology_validator, age_or_age_range_validator, comorbid_condition_validator -) +from chord_metadata_service.restapi.validators import ontology_validator, age_or_age_range_validator +from .validators import comorbid_condition_validator class Individual(models.Model, IndexableMixin): @@ -48,11 +47,12 @@ class Individual(models.Model, IndexableMixin): active = models.BooleanField(default=False, help_text='Whether this patient\'s record is in active use.') deceased = models.BooleanField(default=False, help_text='Indicates if the individual is deceased or not.') # mCode specific - # this field should be complex Ontology - clinical status and code - two Codeable concept - single, cl status has enum list of values + # this field should be complex Ontology - clinical status and code - two Codeable concept - single, cl status has + # enum list of values # TODO add these fields to FHIR converter ? comorbid_condition = JSONField(blank=True, null=True, validators=[comorbid_condition_validator], help_text='One or more conditions that occur with primary condition.') - #TODO decide use ONTOLOGY_CLASS vs. CODEABLE_CONCEPT - currently Ontology class + # TODO decide use ONTOLOGY_CLASS vs. CODEABLE_CONCEPT - currently Ontology class ecog_performance_status = JSONField(blank=True, null=True, validators=[ontology_validator], help_text='Value representing the Eastern Cooperative ' 'Oncology Group performance status.') diff --git a/chord_metadata_service/patients/schemas.py b/chord_metadata_service/patients/schemas.py new file mode 100644 index 000000000..922684d34 --- /dev/null +++ b/chord_metadata_service/patients/schemas.py @@ -0,0 +1,80 @@ +from chord_metadata_service.restapi.schema_utils import customize_schema +from chord_metadata_service.restapi.schemas import ONTOLOGY_CLASS, AGE_OR_AGE_RANGE, EXTRA_PROPERTIES_SCHEMA +from chord_metadata_service.restapi.description_utils import describe_schema + +from .descriptions import INDIVIDUAL + + +COMORBID_CONDITION = customize_schema( + first_typeof=ONTOLOGY_CLASS, + second_typeof=ONTOLOGY_CLASS, + first_property="clinical_status", + second_property="code", + schema_id="chord_metadata_service:comorbid_condition_schema", + title="Comorbid Condition schema", + description="Comorbid condition schema." +) + + +INDIVIDUAL_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique researcher-specified identifier for the individual.", + }, + "alternate_ids": { + "type": "array", + "items": { + "type": "string", + }, + "description": "A list of alternative identifiers for the individual.", # TODO: More specific + }, + "date_of_birth": { + # TODO: This is a special ISO format... need UI for this + "type": "string", + }, + "age": AGE_OR_AGE_RANGE, + "sex": { + "type": "string", + "enum": ["UNKNOWN_SEX", "FEMALE", "MALE", "OTHER_SEX"], + "description": "An individual's phenotypic sex.", + }, + "karyotypic_sex": { + "type": "string", + "enum": [ + "UNKNOWN_KARYOTYPE", + "XX", + "XY", + "XO", + "XXY", + "XXX", + "XXYY", + "XXXY", + "XXXX", + "XYY", + "OTHER_KARYOTYPE" + ], + "description": "An individual's karyotypic sex.", + }, + "taxonomy": ONTOLOGY_CLASS, + "active": { + "type": "boolean" + }, + "deceased": { + "type": "boolean" + }, + "race": { + "type": "string" + }, + "ethnicity": { + "type": "string" + }, + "comorbid_condition": COMORBID_CONDITION, + "ecog_performance_status": ONTOLOGY_CLASS, + "karnofsky": ONTOLOGY_CLASS, + "extra_properties": EXTRA_PROPERTIES_SCHEMA, + }, + "required": ["id"] +}, INDIVIDUAL) diff --git a/chord_metadata_service/patients/serializers.py b/chord_metadata_service/patients/serializers.py index 2505d1e22..d66863456 100644 --- a/chord_metadata_service/patients/serializers.py +++ b/chord_metadata_service/patients/serializers.py @@ -1,18 +1,12 @@ -from chord_metadata_service.phenopackets.serializers import ( - BiosampleSerializer, - SimplePhenopacketSerializer -) +from chord_metadata_service.phenopackets.serializers import BiosampleSerializer, SimplePhenopacketSerializer from chord_metadata_service.restapi.serializers import GenericSerializer from chord_metadata_service.restapi.fhir_utils import fhir_patient from .models import Individual class IndividualSerializer(GenericSerializer): - biosamples = BiosampleSerializer( - read_only=True, many=True, exclude_when_nested=['individual']) - - phenopackets = SimplePhenopacketSerializer( - read_only=True, many=True, exclude_when_nested=['subject']) + biosamples = BiosampleSerializer(read_only=True, many=True, exclude_when_nested=['individual']) + phenopackets = SimplePhenopacketSerializer(read_only=True, many=True, exclude_when_nested=['subject']) class Meta: model = Individual diff --git a/chord_metadata_service/patients/tests/es_mocks.py b/chord_metadata_service/patients/tests/es_mocks.py index 9581c4c71..f00664ffe 100644 --- a/chord_metadata_service/patients/tests/es_mocks.py +++ b/chord_metadata_service/patients/tests/es_mocks.py @@ -1,5 +1,4 @@ from unittest.mock import Mock -from chord_metadata_service.metadata.elastic import es INDEXING_SUCCESS = { diff --git a/chord_metadata_service/patients/tests/test_api.py b/chord_metadata_service/patients/tests/test_api.py index fb3b0550e..f077984de 100644 --- a/chord_metadata_service/patients/tests/test_api.py +++ b/chord_metadata_service/patients/tests/test_api.py @@ -3,7 +3,7 @@ from rest_framework import status from rest_framework.test import APITestCase from ..models import Individual -from .constants import * +from . import constants as c class CreateIndividualTest(APITestCase): @@ -11,8 +11,8 @@ class CreateIndividualTest(APITestCase): def setUp(self): - self.valid_payload = VALID_INDIVIDUAL - self.invalid_payload = INVALID_INDIVIDUAL + self.valid_payload = c.VALID_INDIVIDUAL + self.invalid_payload = c.INVALID_INDIVIDUAL def test_create_individual(self): """ POST a new individual. """ @@ -42,7 +42,7 @@ class UpdateIndividualTest(APITestCase): """ Test module for updating an existing Individual record. """ def setUp(self): - self.individual_one = Individual.objects.create(**VALID_INDIVIDUAL) + self.individual_one = Individual.objects.create(**c.VALID_INDIVIDUAL) self.put_valid_payload = { "id": "patient:1", @@ -63,7 +63,7 @@ def setUp(self): "active": False } - self.invalid_payload = INVALID_INDIVIDUAL + self.invalid_payload = c.INVALID_INDIVIDUAL def test_update_individual(self): """ PUT new data in an existing Individual record. """ @@ -96,7 +96,7 @@ class DeleteIndividualTest(APITestCase): """ Test module for deleting an existing Individual record. """ def setUp(self): - self.individual_one = Individual.objects.create(**VALID_INDIVIDUAL) + self.individual_one = Individual.objects.create(**c.VALID_INDIVIDUAL) def test_delete_individual(self): """ DELETE an existing Individual record. """ diff --git a/chord_metadata_service/patients/validators.py b/chord_metadata_service/patients/validators.py new file mode 100644 index 000000000..5f22ad29d --- /dev/null +++ b/chord_metadata_service/patients/validators.py @@ -0,0 +1,5 @@ +from chord_metadata_service.restapi.validators import JsonSchemaValidator +from .schemas import COMORBID_CONDITION + + +comorbid_condition_validator = JsonSchemaValidator(COMORBID_CONDITION) diff --git a/chord_metadata_service/phenopackets/admin.py b/chord_metadata_service/phenopackets/admin.py index fee3b0e35..b1f598de1 100644 --- a/chord_metadata_service/phenopackets/admin.py +++ b/chord_metadata_service/phenopackets/admin.py @@ -1,66 +1,62 @@ from django.contrib import admin -from .models import * +from . import models as m -@admin.register(Resource) -class ResourceAdmin(admin.ModelAdmin): - pass - - -@admin.register(MetaData) +@admin.register(m.MetaData) class MetaDataAdmin(admin.ModelAdmin): pass -@admin.register(PhenotypicFeature) +@admin.register(m.PhenotypicFeature) class PhenotypicFeatureAdmin(admin.ModelAdmin): pass -@admin.register(Procedure) +@admin.register(m.Procedure) class ProcedureAdmin(admin.ModelAdmin): pass -@admin.register(HtsFile) +@admin.register(m.HtsFile) class HtsFileAdmin(admin.ModelAdmin): pass -@admin.register(Gene) +@admin.register(m.Gene) class GeneAdmin(admin.ModelAdmin): pass -@admin.register(Variant) +@admin.register(m.Variant) class VariantAdmin(admin.ModelAdmin): pass -@admin.register(Disease) +@admin.register(m.Disease) class DiseaseAdmin(admin.ModelAdmin): pass -@admin.register(Biosample) + +@admin.register(m.Biosample) class BiosampleAdmin(admin.ModelAdmin): pass -@admin.register(Phenopacket) +@admin.register(m.Phenopacket) class PhenopacketAdmin(admin.ModelAdmin): pass -@admin.register(GenomicInterpretation) +@admin.register(m.GenomicInterpretation) class GenomicInterpretationAdmin(admin.ModelAdmin): pass -@admin.register(Diagnosis) +@admin.register(m.Diagnosis) class DiagnosisAdmin(admin.ModelAdmin): pass -@admin.register(Interpretation) +@admin.register(m.Interpretation) class InterpretationAdmin(admin.ModelAdmin): pass diff --git a/chord_metadata_service/phenopackets/api_views.py b/chord_metadata_service/phenopackets/api_views.py index 4ecc99b5d..1b5e72ac3 100644 --- a/chord_metadata_service/phenopackets/api_views.py +++ b/chord_metadata_service/phenopackets/api_views.py @@ -1,10 +1,13 @@ from rest_framework import viewsets from rest_framework.settings import api_settings +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response -from chord_metadata_service.restapi.api_renderers import * +from chord_metadata_service.restapi.api_renderers import PhenopacketsRenderer, FHIRRenderer from chord_metadata_service.restapi.pagination import LargeResultsSetPagination -from .serializers import * -from .models import * +from chord_metadata_service.phenopackets.schemas import PHENOPACKET_SCHEMA +from . import models as m, serializers as s class PhenopacketsModelViewSet(viewsets.ModelViewSet): @@ -25,8 +28,8 @@ class PhenotypicFeatureViewSet(ExtendedPhenopacketsModelViewSet): Create a new phenotypic feature """ - queryset = PhenotypicFeature.objects.all().order_by("id") - serializer_class = PhenotypicFeatureSerializer + queryset = m.PhenotypicFeature.objects.all().order_by("id") + serializer_class = s.PhenotypicFeatureSerializer class ProcedureViewSet(ExtendedPhenopacketsModelViewSet): @@ -38,8 +41,8 @@ class ProcedureViewSet(ExtendedPhenopacketsModelViewSet): Create a new procedure """ - queryset = Procedure.objects.all().order_by("id") - serializer_class = ProcedureSerializer + queryset = m.Procedure.objects.all().order_by("id") + serializer_class = s.ProcedureSerializer class HtsFileViewSet(ExtendedPhenopacketsModelViewSet): @@ -51,8 +54,8 @@ class HtsFileViewSet(ExtendedPhenopacketsModelViewSet): Create a new HTS file """ - queryset = HtsFile.objects.all().order_by("uri") - serializer_class = HtsFileSerializer + queryset = m.HtsFile.objects.all().order_by("uri") + serializer_class = s.HtsFileSerializer class GeneViewSet(ExtendedPhenopacketsModelViewSet): @@ -64,8 +67,8 @@ class GeneViewSet(ExtendedPhenopacketsModelViewSet): Create a new gene """ - queryset = Gene.objects.all().order_by("id") - serializer_class = GeneSerializer + queryset = m.Gene.objects.all().order_by("id") + serializer_class = s.GeneSerializer class VariantViewSet(ExtendedPhenopacketsModelViewSet): @@ -77,8 +80,8 @@ class VariantViewSet(ExtendedPhenopacketsModelViewSet): Create a new variant """ - queryset = Variant.objects.all().order_by("id") - serializer_class = VariantSerializer + queryset = m.Variant.objects.all().order_by("id") + serializer_class = s.VariantSerializer class DiseaseViewSet(ExtendedPhenopacketsModelViewSet): @@ -90,21 +93,8 @@ class DiseaseViewSet(ExtendedPhenopacketsModelViewSet): Create a new disease """ - queryset = Disease.objects.all().order_by("id") - serializer_class = DiseaseSerializer - - -class ResourceViewSet(PhenopacketsModelViewSet): - """ - get: - Return a list of all existing resources - - post: - Create a new resource - - """ - queryset = Resource.objects.all().order_by("id") - serializer_class = ResourceSerializer + queryset = m.Disease.objects.all().order_by("id") + serializer_class = s.DiseaseSerializer META_DATA_PREFETCH = ( @@ -121,8 +111,8 @@ class MetaDataViewSet(PhenopacketsModelViewSet): Create a new metadata record """ - queryset = MetaData.objects.all().prefetch_related(*META_DATA_PREFETCH).order_by("id") - serializer_class = MetaDataSerializer + queryset = m.MetaData.objects.all().prefetch_related(*META_DATA_PREFETCH).order_by("id") + serializer_class = s.MetaDataSerializer BIOSAMPLE_PREFETCH = ( @@ -141,8 +131,8 @@ class BiosampleViewSet(ExtendedPhenopacketsModelViewSet): post: Create a new biosample """ - queryset = Biosample.objects.all().prefetch_related(*BIOSAMPLE_PREFETCH).order_by("id") - serializer_class = BiosampleSerializer + queryset = m.Biosample.objects.all().prefetch_related(*BIOSAMPLE_PREFETCH).order_by("id") + serializer_class = s.BiosampleSerializer PHENOPACKET_PREFETCH = ( @@ -166,8 +156,8 @@ class PhenopacketViewSet(ExtendedPhenopacketsModelViewSet): Create a new phenopacket """ - queryset = Phenopacket.objects.all().prefetch_related(*PHENOPACKET_PREFETCH).order_by("id") - serializer_class = PhenopacketSerializer + queryset = m.Phenopacket.objects.all().prefetch_related(*PHENOPACKET_PREFETCH).order_by("id") + serializer_class = s.PhenopacketSerializer class GenomicInterpretationViewSet(PhenopacketsModelViewSet): @@ -179,8 +169,8 @@ class GenomicInterpretationViewSet(PhenopacketsModelViewSet): Create a new genomic interpretation """ - queryset = GenomicInterpretation.objects.all().order_by("id") - serializer_class = GenomicInterpretationSerializer + queryset = m.GenomicInterpretation.objects.all().order_by("id") + serializer_class = s.GenomicInterpretationSerializer class DiagnosisViewSet(PhenopacketsModelViewSet): @@ -192,8 +182,8 @@ class DiagnosisViewSet(PhenopacketsModelViewSet): Create a new diagnosis """ - queryset = Diagnosis.objects.all().order_by("id") - serializer_class = DiagnosisSerializer + queryset = m.Diagnosis.objects.all().order_by("id") + serializer_class = s.DiagnosisSerializer class InterpretationViewSet(PhenopacketsModelViewSet): @@ -205,5 +195,15 @@ class InterpretationViewSet(PhenopacketsModelViewSet): Create a new interpretation """ - queryset = Interpretation.objects.all().order_by("id") - serializer_class = InterpretationSerializer + queryset = m.Interpretation.objects.all().order_by("id") + serializer_class = s.InterpretationSerializer + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_chord_phenopacket_schema(_request): + """ + get: + Chord phenopacket schema that can be shared with data providers. + """ + return Response(PHENOPACKET_SCHEMA) diff --git a/chord_metadata_service/phenopackets/descriptions.py b/chord_metadata_service/phenopackets/descriptions.py index 1b0299ecf..d39b7b82b 100644 --- a/chord_metadata_service/phenopackets/descriptions.py +++ b/chord_metadata_service/phenopackets/descriptions.py @@ -36,36 +36,13 @@ from chord_metadata_service.patients.descriptions import INDIVIDUAL +from chord_metadata_service.resources.descriptions import RESOURCE from chord_metadata_service.restapi.description_utils import EXTRA_PROPERTIES, ontology_class # If description and help are specified separately, the Django help text differs from the schema description. Otherwise, # the data type is a string which fills both roles. -RESOURCE = { - "description": "A description of an external resource used for referencing an object.", - "properties": { - "id": { - "description": "Unique researcher-specified identifier for the resource.", - "help": "For OBO ontologies, the value of this string MUST always be the official OBO ID, which is always " - "equivalent to the ID prefix in lower case. For other resources use the prefix in " - "identifiers.org." - }, - "name": { - "description": "Human-readable name for the resource.", - "help": "The full name of the resource or ontology referred to by the id element." - }, - "namespace_prefix": "Prefix for objects from this resource. In the case of ontology resources, this should be " - "the CURIE prefix.", - "url": "Resource URL. In the case of ontologies, this should be an OBO or OWL file. Other resources should " - "link to the official or top-level url.", - "version": "The version of the resource or ontology used to make the annotation.", - "iri_prefix": "The IRI prefix, when used with the namespace prefix and an object ID, should resolve the term " - "or object from the resource in question.", - **EXTRA_PROPERTIES - } -} - EXTERNAL_REFERENCE = { "description": "An encoding of information about a reference to an external resource.", "properties": { @@ -81,7 +58,7 @@ "description": "An update event for a record (e.g. a phenopacket.)", "properties": { "timestamp": { - "description": "ISO8601 timestamp specifying when when this update occurred.", + "description": "ISO8601 UTC timestamp specifying when when this update occurred.", "help": "Timestamp specifying when when this update occurred.", }, "updated_by": "Information about the person/organization/network that performed the update.", @@ -191,6 +168,24 @@ def phenotypic_feature(subject="a subject or biosample"): } } +ALLELE = { + "properties": { + "id": "An arbitrary identifier.", + "hgvs": "", + "genome_assembly": "The reference genome identifier e.g. GRCh38.", + "chr": "A chromosome identifier e.g. chr2 or 2.", + "pos": "The 1-based genomic position e.g. 134327882.", + "ref": "The reference base(s).", + "alt": "The alternate base(s).", + "info": "Relevant parts of the INFO field.", + "seq_id": "Sequence ID, e.g. Seq1.", + "position": "Position , a 0-based coordinate for where the Deleted Sequence starts, e.g. 4.", + "deleted_sequence": "Deleted sequence , sequence for the deletion, can be empty, e.g. A", + "inserted_sequence": "Inserted sequence , sequence for the insertion, can be empty, e.g. G", + "iscn": "E.g. t(8;9;11)(q12;p24;p12)." + } +} + VARIANT = { "description": "A representation used to describe candidate or diagnosed causative variants.", # TODO: GA4GH VR "properties": { @@ -225,20 +220,6 @@ def phenotypic_feature(subject="a subject or biosample"): } } -AGE = { - "description": "An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age " - "of a subject.", - "help": "Age of a subject." -} - -AGE_RANGE = "Age range (e.g. when a subject's age falls into a bin)" # TODO - -AGE_NESTED = { - "description": AGE["description"], - "properties": { - "age": AGE - } -} BIOSAMPLE = { "description": ("A unit of biological material from which the substrate molecules (e.g. genomic DNA, RNA, " @@ -263,7 +244,7 @@ def phenotypic_feature(subject="a subject or biosample"): "tumor_progression": ontology_class("representing if the specimen is from a primary tumour, a metastasis, or a " "recurrence. There are multiple ways of representing this using ontology " "terms, and the terms chosen will have a specific meaning that is " - "application specific."), + "application specific"), "tumor_grade": ontology_class("representing the tumour grade. This should be a child term of NCIT:C28076 " "(Disease Grade Qualifier) or equivalent"), "diagnostic_markers": { @@ -271,7 +252,7 @@ def phenotypic_feature(subject="a subject or biosample"): "items": ontology_class("representing a clinically-relevant bio-marker. Most of the assays, such as " "immunohistochemistry (IHC), are covered by the NCIT ontology under the " "sub-hierarchy NCIT:C25294 (Laboratory Procedure), e.g. NCIT:C68748 " - "(HER2/Neu Positive), or NCIT:C131711 Human Papillomavirus-18 Positive).") + "(HER2/Neu Positive), or NCIT:C131711 Human Papillomavirus-18 Positive)") }, "procedure": PROCEDURE, "hts_files": { diff --git a/chord_metadata_service/phenopackets/management/commands/phenopackets_build_index.py b/chord_metadata_service/phenopackets/management/commands/phenopackets_build_index.py index 81495ab10..719e2cfe5 100644 --- a/chord_metadata_service/phenopackets/management/commands/phenopackets_build_index.py +++ b/chord_metadata_service/phenopackets/management/commands/phenopackets_build_index.py @@ -24,9 +24,10 @@ class Command(BaseCommand): help = """ - Takes every phenopacket-related data in the DB, port them over + Takes every phenopacket-related data in the DB, port them over to FHIR-compliant JSON and upload them into elasticsearch """ + def handle(self, *args, **options): # TODO: currently only place we create the index, will have to review if es: @@ -48,18 +49,21 @@ def handle(self, *args, **options): for biosample in biosamples: created_or_updated = build_biosample_index(biosample) - logger.info(f"{created_or_updated} index for biosample ID {biosample.id} indexed id {biosample.index_id}") + logger.info(f"{created_or_updated} index for biosample ID {biosample.id} indexed id " + f"{biosample.index_id}") features = PhenotypicFeature.objects.all() for feature in features: created_or_updated = build_phenotypicfeature_index(feature) - logger.info(f"{created_or_updated} index for phenotypic feature ID {feature.index_id} indexed ID {feature.index_id}") + logger.info(f"{created_or_updated} index for phenotypic feature ID {feature.index_id} indexed ID " + f"{feature.index_id}") phenopackets = Phenopacket.objects.all() for phenopacket in phenopackets: created_or_updated = build_phenopacket_index(phenopacket) - logger.info(f"{created_or_updated} index for phenopacket ID {phenopacket.id} indexed ID {phenopacket.index_id}") + logger.info(f"{created_or_updated} index for phenopacket ID {phenopacket.id} indexed ID " + f"{phenopacket.index_id}") else: logger.error("No connection to elasticsearch") diff --git a/chord_metadata_service/phenopackets/migrations/0001_initial.py b/chord_metadata_service/phenopackets/migrations/0001_initial.py deleted file mode 100644 index 6e4090541..000000000 --- a/chord_metadata_service/phenopackets/migrations/0001_initial.py +++ /dev/null @@ -1,234 +0,0 @@ -# Generated by Django 2.2.8 on 2019-12-10 21:04 - -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('patients', '0001_initial'), - ('chord', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Biosample', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier.', max_length=200, primary_key=True, serialize=False)), - ('description', models.CharField(blank=True, help_text='The biosample’s description.', max_length=200)), - ('sampled_tissue', django.contrib.postgres.fields.jsonb.JSONField(help_text='An Ontology term describing the tissue from which the sample was taken.')), - ('taxonomy', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An Ontology term describing the species of the sampled individual.', null=True)), - ('individual_age_at_collection', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Age of the proband at the time the sample was taken.', null=True)), - ('histological_diagnosis', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An Ontology term describing the disease diagnosis that was inferred from the histological examination.', null=True)), - ('tumor_progression', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An Ontology term describing primary, metastatic, recurrent.', null=True)), - ('tumor_grade', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An Ontology term describing the tumor grade. Potentially a child term of NCIT:C28076 or equivalent.', null=True)), - ('diagnostic_markers', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='List of Ontology terms describing clinically relevant biomarkers.', null=True, size=None)), - ('is_control_sample', models.BooleanField(default=False, help_text='Whether the sample is being used as a normal control.')), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Diagnosis', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Disease', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('term', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term that represents the disease.')), - ('onset', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An element representing the age of onset of the disease.', null=True)), - ('disease_stage', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='List of terms representing the disease stage.', null=True, size=None)), - ('tnm_finding', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='List of terms representing the tumor TNM score.', null=True, size=None)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Gene', - fields=[ - ('id', models.CharField(help_text='Official identifier of the gene.', max_length=200, primary_key=True, serialize=False)), - ('alternate_ids', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=200), blank=True, help_text='Alternative identifier(s) of the gene.', null=True, size=None)), - ('symbol', models.CharField(help_text='Official gene symbol.', max_length=200)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='HtsFile', - fields=[ - ('uri', models.URLField(help_text='A valid URI for the file.', primary_key=True, serialize=False)), - ('description', models.CharField(blank=True, help_text='An arbitrary description of the file contents.', max_length=200)), - ('hts_format', models.CharField(choices=[('UNKNOWN', 'UNKNOWN'), ('SAM', 'SAM'), ('BAM', 'BAM'), ('CRAM', 'CRAM'), ('VCF', 'VCF'), ('BCF', 'BCF'), ('GVCF', 'GVCF')], help_text='A format of the file.', max_length=200)), - ('genome_assembly', models.CharField(help_text='The genome assembly the contents of this file was called against.', max_length=200)), - ('individual_to_sample_identifiers', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='The mapping between the Individual.id or Biosample.id to the sample identifier in the HTS file', null=True)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='MetaData', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this object was created.')), - ('created_by', models.CharField(help_text='Name of person who created the phenopacket.', max_length=200)), - ('submitted_by', models.CharField(blank=True, help_text='Name of person who submitted the phenopacket.', max_length=200)), - ('updates', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='List of updates to the phenopacket.', null=True, size=None)), - ('phenopacket_schema_version', models.CharField(blank=True, help_text='Schema version of the current phenopacket.', max_length=200)), - ('external_references', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='List of external resources from the phenopacket was derived.', null=True, size=None)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Phenopacket', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the phenopacket.', max_length=200, primary_key=True, serialize=False)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ('biosamples', models.ManyToManyField(blank=True, help_text='The biosamples that have been derived from an individual who is the subject of the Phenopacket. Rr a collection of biosamples in isolation.', to='phenopackets.Biosample')), - ('dataset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chord.Dataset')), - ('diseases', models.ManyToManyField(blank=True, help_text='Disease(s) diagnosed in the proband.', to='phenopackets.Disease')), - ('genes', models.ManyToManyField(blank=True, help_text='Gene deemed to be relevant to the case.', to='phenopackets.Gene')), - ('hts_files', models.ManyToManyField(blank=True, help_text='VCF or other high-throughput sequencing files.', to='phenopackets.HtsFile')), - ('meta_data', models.ForeignKey(help_text='Information about ontologies and references used in the phenopacket.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.MetaData')), - ('subject', models.ForeignKey(help_text='The proband.', on_delete=django.db.models.deletion.CASCADE, related_name='phenopackets', to='patients.Individual')), - ], - ), - migrations.CreateModel( - name='Procedure', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', django.contrib.postgres.fields.jsonb.JSONField(help_text='Clinical procedure performed on a subject.')), - ('body_site', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Specific body site if unable to represent this is the code.', null=True)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Resource', - fields=[ - ('id', models.CharField(help_text='For OBO ontologies, the value of this string MUST always be the official OBO ID, which is always equivalent to the ID prefix in lower case. For other resources use the prefix in identifiers.org.', max_length=200, primary_key=True, serialize=False)), - ('name', models.CharField(help_text='The full name of the ontology referred to by the id element.', max_length=200)), - ('namespace_prefix', models.CharField(help_text='The prefix used in the CURIE of an Ontology term.', max_length=200)), - ('url', models.URLField(help_text='For OBO ontologies, this MUST be the PURL. Other resources should link to the official or top-level url.')), - ('version', models.CharField(help_text='The version of the resource or ontology used to make the annotation.', max_length=200)), - ('iri_prefix', models.URLField(help_text='The full IRI prefix which can be used with the namespace_prefix and the Ontology::id to resolve to an IRI for a term.')), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Variant', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('allele_type', models.CharField(choices=[('hgvsAllele', 'hgvsAllele'), ('vcfAllele', 'vcfAllele'), ('spdiAllele', 'spdiAllele'), ('iscnAllele', 'iscnAllele')], help_text='One of four allele types.', max_length=200)), - ('allele', django.contrib.postgres.fields.jsonb.JSONField()), - ('zygosity', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Genotype Ontology (GENO) term representing the zygosity of the variant.', null=True)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='PhenotypicFeature', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('description', models.CharField(blank=True, help_text='Human-readable verbiage NOT for structured text', max_length=200)), - ('pftype', django.contrib.postgres.fields.jsonb.JSONField(help_text='Ontology term that describes the phenotype.', verbose_name='type')), - ('negated', models.BooleanField(default=False, help_text='This element is a flag to indicate whether the phenotype was observed or not.')), - ('severity', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Description of the severity of the featurerepresented by a term from HP:0012824.', null=True)), - ('modifier', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='This element is intended to provide more expressive or precise descriptions of a phenotypic feature, including attributes such as positionality and external factors that tend to trigger or ameliorate the feature.', null=True, size=None)), - ('onset', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='This element can be used to describe the age at which a phenotypic feature was first noticed or diagnosed.', null=True)), - ('evidence', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='This element intends to represent the evidence for an assertion such as an observation of a PhenotypicFeature.', null=True)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ('biosample', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='phenotypic_features', to='phenopackets.Biosample')), - ('phenopacket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='phenotypic_features', to='phenopackets.Phenopacket')), - ], - ), - migrations.AddField( - model_name='phenopacket', - name='variants', - field=models.ManyToManyField(blank=True, help_text='Variants identified in the proband.', to='phenopackets.Variant'), - ), - migrations.AddField( - model_name='metadata', - name='resources', - field=models.ManyToManyField(help_text='This element contains a listing of the ontologies/resources referenced in the phenopacket.', to='phenopackets.Resource'), - ), - migrations.CreateModel( - name='Interpretation', - fields=[ - ('id', models.CharField(help_text='An arbitrary identifier for the interpretation.', max_length=200, primary_key=True, serialize=False)), - ('resolution_status', models.CharField(blank=True, choices=[('UNKNOWN', 'UNKNOWN'), ('SOLVED', 'SOLVED'), ('UNSOLVED', 'UNSOLVED'), ('IN_PROGRESS', 'IN_PROGRESS')], help_text='The current status of work on the case.', max_length=200)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ('diagnosis', models.ManyToManyField(help_text='One or more diagnoses, if made.', to='phenopackets.Diagnosis')), - ('meta_data', models.ForeignKey(help_text='Metadata about this interpretation.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.MetaData')), - ('phenopacket', models.ForeignKey(help_text='The subject of this interpretation.', on_delete=django.db.models.deletion.CASCADE, related_name='interpretations', to='phenopackets.Phenopacket')), - ], - ), - migrations.CreateModel( - name='GenomicInterpretation', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('UNKNOWN', 'UNKNOWN'), ('REJECTED', 'REJECTED'), ('CANDIDATE', 'CANDIDATE'), ('CAUSATIVE', 'CAUSATIVE')], help_text='How the call of this GenomicInterpretation was interpreted.', max_length=200)), - ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), - ('created', models.DateTimeField(auto_now=True)), - ('updated', models.DateTimeField(auto_now_add=True)), - ('gene', models.ForeignKey(blank=True, help_text='The gene contributing to the diagnosis.', null=True, on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Gene')), - ('variant', models.ForeignKey(blank=True, help_text='The variant contributing to the diagnosis.', null=True, on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Variant')), - ], - ), - migrations.AddField( - model_name='diagnosis', - name='disease', - field=models.ForeignKey(help_text='The diagnosed condition.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Disease'), - ), - migrations.AddField( - model_name='diagnosis', - name='genomic_interpretations', - field=models.ManyToManyField(blank=True, help_text='The genomic elements assessed as being responsible for the disease.', to='phenopackets.GenomicInterpretation'), - ), - migrations.AddField( - model_name='biosample', - name='hts_files', - field=models.ManyToManyField(blank=True, help_text='List of high-throughput sequencing files derived from the biosample.', related_name='biosample_hts_files', to='phenopackets.HtsFile'), - ), - migrations.AddField( - model_name='biosample', - name='individual', - field=models.ForeignKey(blank=True, help_text='The id of the Individual this biosample was derived from.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='biosamples', to='patients.Individual'), - ), - migrations.AddField( - model_name='biosample', - name='procedure', - field=models.ForeignKey(help_text='The procedure used to extract the biosample.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Procedure'), - ), - migrations.AddField( - model_name='biosample', - name='variants', - field=models.ManyToManyField(blank=True, help_text='List of variants determined to be present in the biosample.', to='phenopackets.Variant'), - ), - ] diff --git a/chord_metadata_service/phenopackets/migrations/0001_v1_0_0.py b/chord_metadata_service/phenopackets/migrations/0001_v1_0_0.py new file mode 100644 index 000000000..82694129a --- /dev/null +++ b/chord_metadata_service/phenopackets/migrations/0001_v1_0_0.py @@ -0,0 +1,224 @@ +# Generated by Django 2.2.13 on 2020-07-06 14:55 + +import chord_metadata_service.restapi.models +import chord_metadata_service.restapi.validators +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('chord', '0001_v1_0_0'), + ('patients', '0001_v1_0_0'), + ('resources', '0001_v1_0_0'), + ] + + operations = [ + migrations.CreateModel( + name='Biosample', + fields=[ + ('id', models.CharField(help_text='Unique arbitrary, researcher-specified identifier for the biosample.', max_length=200, primary_key=True, serialize=False)), + ('description', models.CharField(blank=True, help_text='Human-readable, unstructured text describing the biosample or providing additional information.', max_length=200)), + ('sampled_tissue', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term describing the tissue from which the specimen was collected. The use of UBERON is recommended.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('taxonomy', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term specified when more than one organism may be studied. It is advised that codesfrom the NCBI Taxonomy resource are used, e.g. NCBITaxon:9606 for humans.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('individual_age_at_collection', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='individual_age_at_collection', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:age_or_age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'An age object describing the age of the individual at the time of collection of biospecimens or phenotypic observations.', 'oneOf': [{'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': "Age range of a subject (e.g. when a subject's age falls into a bin.)", 'help': "Age range of a subject (e.g. when a subject's age falls into a bin.)", 'properties': {'end': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string representing the end of the age range bin.', 'help': 'An ISO8601 duration string representing the end of the age range bin.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, 'start': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string representing the start of the age range bin.', 'help': 'An ISO8601 duration string representing the start of the age range bin.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age range schema', 'type': 'object'}], 'title': 'Age schema', 'type': 'object'}, formats=None)])), + ('histological_diagnosis', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing a refinement of the clinical diagnosis. Normal samples could be tagged with NCIT:C38757, representing a negative finding.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('tumor_progression', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing if the specimen is from a primary tumour, a metastasis, or a recurrence. There are multiple ways of representing this using ontology terms, and the terms chosen will have a specific meaning that is application specific.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('tumor_grade', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing the tumour grade. This should be a child term of NCIT:C28076 (Disease Grade Qualifier) or equivalent.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('diagnostic_markers', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A list of ontology terms representing clinically-relevant bio-markers.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('is_control_sample', models.BooleanField(default=False, help_text='Whether the sample is being used as a normal control.')), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='Diagnosis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='Disease', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('term', django.contrib.postgres.fields.jsonb.JSONField(help_text="An ontology term that represents the disease. It's recommended that one of the OMIM, Orphanet, or MONDO ontologies is used for rare human diseases.", validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('onset', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A representation of the age of onset of the disease', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:disease_onset_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'anyOf': [{'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': "Age range of a subject (e.g. when a subject's age falls into a bin.)", 'help': "Age range of a subject (e.g. when a subject's age falls into a bin.)", 'properties': {'end': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string representing the end of the age range bin.', 'help': 'An ISO8601 duration string representing the end of the age range bin.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, 'start': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ISO8601 duration string representing the start of the age range bin.', 'help': 'An ISO8601 duration string representing the start of the age range bin.', 'properties': {'age': {'description': 'An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age of a subject.', 'help': 'Age of a subject.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age range schema', 'type': 'object'}, {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}], 'description': 'Schema for the age of the onset of the disease.', 'title': 'Onset age', 'type': 'object'}, formats=None)])), + ('disease_stage', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A list of terms representing the disease stage. Elements should be derived from child terms of NCIT:C28108 (Disease Stage Qualifier) or equivalent hierarchy from another ontology.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('tnm_finding', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A list of terms representing the tumour TNM score. Elements should be derived from child terms of NCIT:C48232 (Cancer TNM Finding) or equivalent hierarchy from another ontology.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='Gene', + fields=[ + ('id', models.CharField(help_text='Official identifier of the gene. It SHOULD be a CURIE identifier with a prefix used by the official organism gene nomenclature committee, e.g. HGNC:347 for humans.', max_length=200, primary_key=True, serialize=False)), + ('alternate_ids', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=200), blank=True, default=list, help_text='A list of identifiers for alternative resources where the gene is used or catalogued.', size=None)), + ('symbol', models.CharField(help_text="A gene's official gene symbol as designated by the organism's gene nomenclature committee, e.g. ETF1 from the HUGO Gene Nomenclature committee.", max_length=200)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='HtsFile', + fields=[ + ('uri', models.URLField(help_text='A valid URI to the file', primary_key=True, serialize=False)), + ('description', models.CharField(blank=True, help_text='Human-readable text describing the file.', max_length=200)), + ('hts_format', models.CharField(choices=[('UNKNOWN', 'UNKNOWN'), ('SAM', 'SAM'), ('BAM', 'BAM'), ('CRAM', 'CRAM'), ('VCF', 'VCF'), ('BCF', 'BCF'), ('GVCF', 'GVCF')], help_text="The file's format; one of SAM, BAM, CRAM, VCF, BCF, GVCF, FASTQ, or UNKNOWN.", max_length=200)), + ('genome_assembly', models.CharField(help_text='Genome assembly ID for the file, e.g. GRCh38.', max_length=200)), + ('individual_to_sample_identifiers', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Mapping between individual or biosample IDs and the sample identifier in the HTS file.', null=True)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='MetaData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, help_text='Timestamp specifying when when this object was created.')), + ('created_by', models.CharField(help_text='Name of the person who created the phenopacket.', max_length=200)), + ('submitted_by', models.CharField(blank=True, help_text='Name of the person who submitted the phenopacket.', max_length=200)), + ('updates', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A list of updates to the phenopacket.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:schema_list', '$schema': 'http://json-schema.org/draft-07/schema#', 'items': {'$id': 'chord_metadata_service:update_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An update event for a record (e.g. a phenopacket.)', 'help': 'An update event for a record (e.g. a phenopacket.)', 'properties': {'comment': {'description': 'Free-text comment about the changes made and/or the reason for the update.', 'help': 'Free-text comment about the changes made and/or the reason for the update.', 'type': 'string'}, 'timestamp': {'description': 'ISO8601 UTC timestamp specifying when when this update occurred.', 'format': 'date-time', 'help': 'Timestamp specifying when when this update occurred.', 'type': 'string'}, 'updated_by': {'description': 'Information about the person/organization/network that performed the update.', 'help': 'Information about the person/organization/network that performed the update.', 'type': 'string'}}, 'required': ['timestamp', 'comment'], 'title': 'Updates schema', 'type': 'object'}, 'title': 'Schema list', 'type': 'array'}, formats=['date-time'])])), + ('phenopacket_schema_version', models.CharField(blank=True, help_text='Schema version of the current phenopacket.', max_length=200)), + ('external_references', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A list of external (non-resource) references.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:schema_list', '$schema': 'http://json-schema.org/draft-07/schema#', 'items': {'$id': 'chord_metadata_service:external_reference_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'An encoding of information about a reference to an external resource.', 'help': 'An encoding of information about a reference to an external resource.', 'properties': {'description': {'description': 'An application-specific free-text description.', 'help': 'An application-specific free-text description.', 'type': 'string'}, 'id': {'description': 'An application-specific identifier. It is RECOMMENDED that this is a CURIE that uniquely identifies the evidence source when combined with a resource; e.g. PMID:123456 with a resource `pmid`. It could also be a URI or other relevant identifier.', 'help': 'An application-specific identifier. It is RECOMMENDED that this is a CURIE that uniquely identifies the evidence source when combined with a resource; e.g. PMID:123456 with a resource `pmid`. It could also be a URI or other relevant identifier.', 'type': 'string'}}, 'required': ['id'], 'title': 'External reference schema', 'type': 'object'}, 'title': 'Schema list', 'type': 'array'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('resources', models.ManyToManyField(help_text='A list of resources or ontologies referenced in the phenopacket', to='resources.Resource')), + ], + ), + migrations.CreateModel( + name='Phenopacket', + fields=[ + ('id', models.CharField(help_text='Unique, arbitrary, researcher-specified identifier for the phenopacket.', max_length=200, primary_key=True, serialize=False)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('biosamples', models.ManyToManyField(blank=True, help_text='Samples (e.g. biopsies) taken from the individual, if any.', to='phenopackets.Biosample')), + ('diseases', models.ManyToManyField(blank=True, help_text='A list of diseases diagnosed in the proband.', to='phenopackets.Disease')), + ('genes', models.ManyToManyField(blank=True, help_text='Genes deemed to be relevant to the case; application-specific.', to='phenopackets.Gene')), + ('hts_files', models.ManyToManyField(blank=True, help_text='A list of HTS files derived from the individual.', to='phenopackets.HtsFile')), + ('meta_data', models.ForeignKey(help_text='A structured definition of the resources and ontologies used within a phenopacket.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.MetaData')), + ('subject', models.ForeignKey(help_text='A subject of a phenopacket, representing either a human (typically) or another organism.', on_delete=django.db.models.deletion.CASCADE, related_name='phenopackets', to='patients.Individual')), + ('table', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chord.Table')), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.CreateModel( + name='Procedure', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term that represents a clinical procedure performed on a subject.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('body_site', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that is specified when it is not possible to represent the procedure with a single ontology class.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Variant', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('allele_type', models.CharField(choices=[('hgvsAllele', 'hgvsAllele'), ('vcfAllele', 'vcfAllele'), ('spdiAllele', 'spdiAllele'), ('iscnAllele', 'iscnAllele')], help_text='One of four allele types.', max_length=200)), + ('allele', django.contrib.postgres.fields.jsonb.JSONField(help_text="The variant's corresponding allele", validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:allele_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'dependencies': {'genome_assembly': ['chr', 'pos', 'ref', 'alt', 'info'], 'seq_id': ['position', 'deleted_sequence', 'inserted_sequence']}, 'description': 'Variant allele types', 'oneOf': [{'required': ['hgvs']}, {'required': ['genome_assembly']}, {'required': ['seq_id']}, {'required': ['iscn']}], 'properties': {'alt': {'description': 'The alternate base(s).', 'help': 'The alternate base(s).', 'type': 'string'}, 'chr': {'description': 'A chromosome identifier e.g. chr2 or 2.', 'help': 'A chromosome identifier e.g. chr2 or 2.', 'type': 'string'}, 'deleted_sequence': {'description': 'Deleted sequence , sequence for the deletion, can be empty, e.g. A', 'help': 'Deleted sequence , sequence for the deletion, can be empty, e.g. A', 'type': 'string'}, 'genome_assembly': {'description': 'The reference genome identifier e.g. GRCh38.', 'help': 'The reference genome identifier e.g. GRCh38.', 'type': 'string'}, 'hgvs': {'description': '', 'help': '', 'type': 'string'}, 'id': {'description': 'An arbitrary identifier.', 'help': 'An arbitrary identifier.', 'type': 'string'}, 'info': {'description': 'Relevant parts of the INFO field.', 'help': 'Relevant parts of the INFO field.', 'type': 'string'}, 'inserted_sequence': {'description': 'Inserted sequence , sequence for the insertion, can be empty, e.g. G', 'help': 'Inserted sequence , sequence for the insertion, can be empty, e.g. G', 'type': 'string'}, 'iscn': {'description': 'E.g. t(8;9;11)(q12;p24;p12).', 'help': 'E.g. t(8;9;11)(q12;p24;p12).', 'type': 'string'}, 'pos': {'description': 'The 1-based genomic position e.g. 134327882.', 'help': 'The 1-based genomic position e.g. 134327882.', 'type': 'integer'}, 'position': {'description': 'Position , a 0-based coordinate for where the Deleted Sequence starts, e.g. 4.', 'help': 'Position , a 0-based coordinate for where the Deleted Sequence starts, e.g. 4.', 'type': 'integer'}, 'ref': {'description': 'The reference base(s).', 'help': 'The reference base(s).', 'type': 'string'}, 'seq_id': {'description': 'Sequence ID, e.g. Seq1.', 'help': 'Sequence ID, e.g. Seq1.', 'type': 'string'}}, 'title': 'Allele schema', 'type': 'object'}, formats=None)])), + ('zygosity', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term taken from the Genotype Ontology (GENO) representing the zygosity of the variant.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='PhenotypicFeature', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(blank=True, help_text='Human-readable text describing the phenotypic feature; NOT for structured text.', max_length=200)), + ('pftype', django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term which describes the phenotype.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)], verbose_name='type')), + ('negated', models.BooleanField(default=False, help_text='Whether the feature is present (false) or absent (true, feature is negated); default is false.')), + ('severity', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the severity of the condition.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('modifier', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A list of ontology terms that provide more expressive / precise descriptions of a phenotypic feature, including e.g. positionality or external factors.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_list_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Ontology class list', 'items': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'title': 'Ontology class list', 'type': 'array'}, formats=None)])), + ('onset', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the age at which the phenotypic feature was first noticed or diagnosed, e.g. HP:0003674.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term.', 'help': 'An ontology term.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term.', 'help': 'A CURIE-style identifier for an ontology term.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term.', 'help': 'A human readable class name for an ontology term.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, formats=None)])), + ('evidence', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='One or more pieces of evidence that specify how the phenotype was determined.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:evidence_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'A representation of the evidence for an assertion, such as an observation of a phenotypic feature.', 'help': 'A representation of the evidence for an assertion, such as an observation of a phenotypic feature.', 'properties': {'evidence_code': {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An ontology term that represents the evidence type.', 'help': 'An ontology term that represents the evidence type.', 'properties': {'id': {'description': 'A CURIE-style identifier for an ontology term that represents the evidence type.', 'help': 'A CURIE-style identifier for an ontology term that represents the evidence type.', 'type': 'string'}, 'label': {'description': 'A human readable class name for an ontology term that represents the evidence type.', 'help': 'A human readable class name for an ontology term that represents the evidence type.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}, 'reference': {'$id': 'chord_metadata_service:external_reference_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'An encoding of information about a reference to an external resource.', 'help': 'An encoding of information about a reference to an external resource.', 'properties': {'description': {'description': 'An application-specific free-text description.', 'help': 'An application-specific free-text description.', 'type': 'string'}, 'id': {'description': 'An application-specific identifier. It is RECOMMENDED that this is a CURIE that uniquely identifies the evidence source when combined with a resource; e.g. PMID:123456 with a resource `pmid`. It could also be a URI or other relevant identifier.', 'help': 'An application-specific identifier. It is RECOMMENDED that this is a CURIE that uniquely identifies the evidence source when combined with a resource; e.g. PMID:123456 with a resource `pmid`. It could also be a URI or other relevant identifier.', 'type': 'string'}}, 'required': ['id'], 'title': 'External reference schema', 'type': 'object'}}, 'required': ['evidence_code'], 'title': 'Evidence schema', 'type': 'object'}, formats=None)])), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ('biosample', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='phenotypic_features', to='phenopackets.Biosample')), + ('phenopacket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='phenotypic_features', to='phenopackets.Phenopacket')), + ], + bases=(models.Model, chord_metadata_service.restapi.models.IndexableMixin), + ), + migrations.AddField( + model_name='phenopacket', + name='variants', + field=models.ManyToManyField(blank=True, help_text='A list of variants identified in the proband.', to='phenopackets.Variant'), + ), + migrations.CreateModel( + name='Interpretation', + fields=[ + ('id', models.CharField(help_text='An arbitrary identifier for the interpretation.', max_length=200, primary_key=True, serialize=False)), + ('resolution_status', models.CharField(blank=True, choices=[('UNKNOWN', 'UNKNOWN'), ('SOLVED', 'SOLVED'), ('UNSOLVED', 'UNSOLVED'), ('IN_PROGRESS', 'IN_PROGRESS')], help_text='The current status of work on the case.', max_length=200)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('diagnosis', models.ManyToManyField(help_text='One or more diagnoses, if made.', to='phenopackets.Diagnosis')), + ('meta_data', models.ForeignKey(help_text='Metadata about this interpretation.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.MetaData')), + ('phenopacket', models.ForeignKey(help_text='The subject of this interpretation.', on_delete=django.db.models.deletion.CASCADE, related_name='interpretations', to='phenopackets.Phenopacket')), + ], + ), + migrations.CreateModel( + name='GenomicInterpretation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('UNKNOWN', 'UNKNOWN'), ('REJECTED', 'REJECTED'), ('CANDIDATE', 'CANDIDATE'), ('CAUSATIVE', 'CAUSATIVE')], help_text='How the call of this GenomicInterpretation was interpreted.', max_length=200)), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('gene', models.ForeignKey(blank=True, help_text='The gene contributing to the diagnosis.', null=True, on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Gene')), + ('variant', models.ForeignKey(blank=True, help_text='The variant contributing to the diagnosis.', null=True, on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Variant')), + ], + ), + migrations.AddField( + model_name='diagnosis', + name='disease', + field=models.ForeignKey(help_text='The diagnosed condition.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Disease'), + ), + migrations.AddField( + model_name='diagnosis', + name='genomic_interpretations', + field=models.ManyToManyField(blank=True, help_text='The genomic elements assessed as being responsible for the disease.', to='phenopackets.GenomicInterpretation'), + ), + migrations.AddField( + model_name='biosample', + name='hts_files', + field=models.ManyToManyField(blank=True, help_text='A list of HTS files derived from the biosample.', related_name='biosample_hts_files', to='phenopackets.HtsFile'), + ), + migrations.AddField( + model_name='biosample', + name='individual', + field=models.ForeignKey(blank=True, help_text='Identifier for the individual this biosample was sampled from.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='biosamples', to='patients.Individual'), + ), + migrations.AddField( + model_name='biosample', + name='procedure', + field=models.ForeignKey(help_text='A description of a clinical procedure performed on a subject in order to extract a biosample.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Procedure'), + ), + migrations.AddField( + model_name='biosample', + name='variants', + field=models.ManyToManyField(blank=True, help_text='A list of variants determined to be present in the biosample.', to='phenopackets.Variant'), + ), + ] diff --git a/chord_metadata_service/phenopackets/migrations/0002_auto_20200121_1659.py b/chord_metadata_service/phenopackets/migrations/0002_auto_20200121_1659.py deleted file mode 100644 index 5c991046f..000000000 --- a/chord_metadata_service/phenopackets/migrations/0002_auto_20200121_1659.py +++ /dev/null @@ -1,337 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-21 16:59 - -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('phenopackets', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='biosample', - name='description', - field=models.CharField(blank=True, help_text='Human-readable, unstructured text describing the biosample or providing additional information.', max_length=200), - ), - migrations.AlterField( - model_name='biosample', - name='diagnostic_markers', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A list of ontology terms representing clinically-relevant bio-markers.', null=True, size=None), - ), - migrations.AlterField( - model_name='biosample', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='histological_diagnosis', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing a refinement of the clinical diagnosis. Normal samples could be tagged with NCIT:C38757, representing a negative finding', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='hts_files', - field=models.ManyToManyField(blank=True, help_text='A list of HTS files derived from the biosample.', related_name='biosample_hts_files', to='phenopackets.HtsFile'), - ), - migrations.AlterField( - model_name='biosample', - name='id', - field=models.CharField(help_text='Unique arbitrary, researcher-specified identifier for the biosample.', max_length=200, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='biosample', - name='individual', - field=models.ForeignKey(blank=True, help_text='Identifier for the individual this biosample was sampled from.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='biosamples', to='patients.Individual'), - ), - migrations.AlterField( - model_name='biosample', - name='individual_age_at_collection', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='individual_age_at_collection', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='procedure', - field=models.ForeignKey(help_text='A description of a clinical procedure performed on a subject in order to extract a biosample.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.Procedure'), - ), - migrations.AlterField( - model_name='biosample', - name='sampled_tissue', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term describing the tissue from which the specimen was collected. The use of UBERON is recommended'), - ), - migrations.AlterField( - model_name='biosample', - name='taxonomy', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term specified when more than one organism may be studied. It is advised that codesfrom the NCBI Taxonomy resource are used, e.g. NCBITaxon:9606 for humans', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='tumor_grade', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing the tumour grade. This should be a child term of NCIT:C28076 (Disease Grade Qualifier) or equivalent', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='tumor_progression', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing if the specimen is from a primary tumour, a metastasis, or a recurrence. There are multiple ways of representing this using ontology terms, and the terms chosen will have a specific meaning that is application specific.', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='variants', - field=models.ManyToManyField(blank=True, help_text='A list of variants determined to be present in the biosample.', to='phenopackets.Variant'), - ), - migrations.AlterField( - model_name='disease', - name='disease_stage', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A list of terms representing the disease stage. Elements should be derived from child terms of NCIT:C28108 (Disease Stage Qualifier) or equivalent hierarchy from another ontology.', null=True, size=None), - ), - migrations.AlterField( - model_name='disease', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='disease', - name='onset', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A representation of the age of onset of the disease', null=True), - ), - migrations.AlterField( - model_name='disease', - name='term', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text="An ontology term that represents the disease. It's recommended that one of the OMIM, Orphanet, or MONDO ontologies is used for rare human diseases"), - ), - migrations.AlterField( - model_name='disease', - name='tnm_finding', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A list of terms representing the tumour TNM score. Elements should be derived from child terms of NCIT:C48232 (Cancer TNM Finding) or equivalent hierarchy from another ontology.', null=True, size=None), - ), - migrations.AlterField( - model_name='gene', - name='alternate_ids', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=200), blank=True, help_text='A list of identifiers for alternative resources where the gene is used or catalogued.', null=True, size=None), - ), - migrations.AlterField( - model_name='gene', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='gene', - name='id', - field=models.CharField(help_text='Official identifier of the gene. It SHOULD be a CURIE identifier with a prefix used by the official organism gene nomenclature committee, e.g. HGNC:347 for humans.', max_length=200, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='gene', - name='symbol', - field=models.CharField(help_text="A gene's official gene symbol as designated by the organism's gene nomenclature committee, e.g. ETF1 from the HUGO Gene Nomenclature committee.", max_length=200), - ), - migrations.AlterField( - model_name='htsfile', - name='description', - field=models.CharField(blank=True, help_text='Human-readable text describing the file.', max_length=200), - ), - migrations.AlterField( - model_name='htsfile', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='htsfile', - name='genome_assembly', - field=models.CharField(help_text='Genome assembly ID for the file, e.g. GRCh38.', max_length=200), - ), - migrations.AlterField( - model_name='htsfile', - name='hts_format', - field=models.CharField(choices=[('UNKNOWN', 'UNKNOWN'), ('SAM', 'SAM'), ('BAM', 'BAM'), ('CRAM', 'CRAM'), ('VCF', 'VCF'), ('BCF', 'BCF'), ('GVCF', 'GVCF')], help_text="The file's format; one of SAM, BAM, CRAM, VCF, BCF, GVCF, FASTQ, or UNKNOWN.", max_length=200), - ), - migrations.AlterField( - model_name='htsfile', - name='individual_to_sample_identifiers', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Mapping between individual or biosample IDs and the sample identifier in the HTS file.', null=True), - ), - migrations.AlterField( - model_name='htsfile', - name='uri', - field=models.URLField(help_text='A valid URI to the file', primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='metadata', - name='created', - field=models.DateTimeField(default=django.utils.timezone.now, help_text='Timestamp specifying when when this object was created.'), - ), - migrations.AlterField( - model_name='metadata', - name='created_by', - field=models.CharField(help_text='Name of the person who created the phenopacket.', max_length=200), - ), - migrations.AlterField( - model_name='metadata', - name='external_references', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A list of external (non-resource) references.', null=True, size=None), - ), - migrations.AlterField( - model_name='metadata', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='metadata', - name='resources', - field=models.ManyToManyField(help_text='A list of resources or ontologies referenced in the phenopacket', to='phenopackets.Resource'), - ), - migrations.AlterField( - model_name='metadata', - name='submitted_by', - field=models.CharField(blank=True, help_text='Name of the person who submitted the phenopacket.', max_length=200), - ), - migrations.AlterField( - model_name='metadata', - name='updates', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A list of updates to the phenopacket.', null=True, size=None), - ), - migrations.AlterField( - model_name='phenopacket', - name='biosamples', - field=models.ManyToManyField(blank=True, help_text='Samples (e.g. biopsies) taken from the individual, if any.', to='phenopackets.Biosample'), - ), - migrations.AlterField( - model_name='phenopacket', - name='diseases', - field=models.ManyToManyField(blank=True, help_text='A list of diseases diagnosed in the proband.', to='phenopackets.Disease'), - ), - migrations.AlterField( - model_name='phenopacket', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='phenopacket', - name='genes', - field=models.ManyToManyField(blank=True, help_text='Genes deemed to be relevant to the case; application-specific.', to='phenopackets.Gene'), - ), - migrations.AlterField( - model_name='phenopacket', - name='hts_files', - field=models.ManyToManyField(blank=True, help_text='A list of HTS files derived from the individual.', to='phenopackets.HtsFile'), - ), - migrations.AlterField( - model_name='phenopacket', - name='id', - field=models.CharField(help_text='Unique, arbitrary, researcher-specified identifier for the phenopacket.', max_length=200, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='phenopacket', - name='meta_data', - field=models.ForeignKey(help_text='A structured definition of the resources and ontologies used within a phenopacket.', on_delete=django.db.models.deletion.CASCADE, to='phenopackets.MetaData'), - ), - migrations.AlterField( - model_name='phenopacket', - name='subject', - field=models.ForeignKey(help_text='A subject of a phenopacket, representing either a human (typically) or another organism.', on_delete=django.db.models.deletion.CASCADE, related_name='phenopackets', to='patients.Individual'), - ), - migrations.AlterField( - model_name='phenopacket', - name='variants', - field=models.ManyToManyField(blank=True, help_text='A list of variants identified in the proband.', to='phenopackets.Variant'), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='description', - field=models.CharField(blank=True, help_text='Human-readable text describing the phenotypic feature; NOT for structured text.', max_length=200), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='evidence', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='One or more pieces of evidence that specify how the phenotype was determined.', null=True), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='modifier', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), blank=True, help_text='A list of ontology terms that provide more expressive / precise descriptions of a phenotypic feature, including e.g. positionality or external factors.', null=True, size=None), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='negated', - field=models.BooleanField(default=False, help_text='Whether the feature is present (false) or absent (true, feature is negated); default is false.'), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='onset', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the age at which the phenotypic feature was first noticed or diagnosed, e.g. HP:0003674', null=True), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='pftype', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term which describes the phenotype', verbose_name='type'), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='severity', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the severity of the condition', null=True), - ), - migrations.AlterField( - model_name='procedure', - name='body_site', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that is specified when it is not possible to represent the procedure with a single ontology class', null=True), - ), - migrations.AlterField( - model_name='procedure', - name='code', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term that represents a clinical procedure performed on a subject'), - ), - migrations.AlterField( - model_name='procedure', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='resource', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='resource', - name='iri_prefix', - field=models.URLField(help_text='The IRI prefix, when used with the namespace prefix and an object ID, should resolve the term or object from the resource in question.'), - ), - migrations.AlterField( - model_name='resource', - name='name', - field=models.CharField(help_text='The full name of the resource or ontology referred to by the id element.', max_length=200), - ), - migrations.AlterField( - model_name='resource', - name='namespace_prefix', - field=models.CharField(help_text='Prefix for objects from this resource. In the case of ontology resources, this should be the CURIE prefix.', max_length=200), - ), - migrations.AlterField( - model_name='resource', - name='url', - field=models.URLField(help_text='Resource URL. In the case of ontologies, this should be an OBO or OWL file. Other resources should link to the official or top-level url.'), - ), - migrations.AlterField( - model_name='variant', - name='allele', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text="The variant's corresponding allele"), - ), - migrations.AlterField( - model_name='variant', - name='extra_properties', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True), - ), - migrations.AlterField( - model_name='variant', - name='zygosity', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term taken from the Genotype Ontology (GENO) representing the zygosity of the variant', null=True), - ), - ] diff --git a/chord_metadata_service/phenopackets/migrations/0003_auto_20200121_1956.py b/chord_metadata_service/phenopackets/migrations/0003_auto_20200121_1956.py deleted file mode 100644 index e8263d6a2..000000000 --- a/chord_metadata_service/phenopackets/migrations/0003_auto_20200121_1956.py +++ /dev/null @@ -1,74 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-21 19:56 - -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('phenopackets', '0002_auto_20200121_1659'), - ] - - operations = [ - migrations.AlterField( - model_name='biosample', - name='histological_diagnosis', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing a refinement of the clinical diagnosis. Normal samples could be tagged with NCIT:C38757, representing a negative finding.', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='sampled_tissue', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term describing the tissue from which the specimen was collected. The use of UBERON is recommended.'), - ), - migrations.AlterField( - model_name='biosample', - name='taxonomy', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term specified when more than one organism may be studied. It is advised that codesfrom the NCBI Taxonomy resource are used, e.g. NCBITaxon:9606 for humans.', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='tumor_grade', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing the tumour grade. This should be a child term of NCIT:C28076 (Disease Grade Qualifier) or equivalent.', null=True), - ), - migrations.AlterField( - model_name='biosample', - name='tumor_progression', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing if the specimen is from a primary tumour, a metastasis, or a recurrence. There are multiple ways of representing this using ontology terms, and the terms chosen will have a specific meaning that is application specific..', null=True), - ), - migrations.AlterField( - model_name='disease', - name='term', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text="An ontology term that represents the disease. It's recommended that one of the OMIM, Orphanet, or MONDO ontologies is used for rare human diseases."), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='onset', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the age at which the phenotypic feature was first noticed or diagnosed, e.g. HP:0003674.', null=True), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='pftype', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term which describes the phenotype.', verbose_name='type'), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='severity', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the severity of the condition.', null=True), - ), - migrations.AlterField( - model_name='procedure', - name='body_site', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that is specified when it is not possible to represent the procedure with a single ontology class.', null=True), - ), - migrations.AlterField( - model_name='procedure', - name='code', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term that represents a clinical procedure performed on a subject.'), - ), - migrations.AlterField( - model_name='variant', - name='zygosity', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term taken from the Genotype Ontology (GENO) representing the zygosity of the variant.', null=True), - ), - ] diff --git a/chord_metadata_service/phenopackets/migrations/0004_auto_20200129_1537.py b/chord_metadata_service/phenopackets/migrations/0004_auto_20200129_1537.py deleted file mode 100644 index 11a02e191..000000000 --- a/chord_metadata_service/phenopackets/migrations/0004_auto_20200129_1537.py +++ /dev/null @@ -1,83 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-29 15:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('phenopackets', '0003_auto_20200121_1956'), - ] - - operations = [ - migrations.AlterField( - model_name='biosample', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='biosample', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='diagnosis', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='diagnosis', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='disease', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='disease', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='genomicinterpretation', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='genomicinterpretation', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='interpretation', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='interpretation', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='phenopacket', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='phenopacket', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='variant', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='variant', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/chord_metadata_service/phenopackets/migrations/0005_auto_20200428_1633.py b/chord_metadata_service/phenopackets/migrations/0005_auto_20200428_1633.py deleted file mode 100644 index e4cd3544c..000000000 --- a/chord_metadata_service/phenopackets/migrations/0005_auto_20200428_1633.py +++ /dev/null @@ -1,126 +0,0 @@ -# Generated by Django 2.2.12 on 2020-04-28 20:33 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('phenopackets', '0004_auto_20200129_1537'), - ] - - operations = [ - migrations.AlterField( - model_name='biosample', - name='diagnostic_markers', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), blank=True, help_text='A list of ontology terms representing clinically-relevant bio-markers.', null=True, size=None), - ), - migrations.AlterField( - model_name='biosample', - name='histological_diagnosis', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing a refinement of the clinical diagnosis. Normal samples could be tagged with NCIT:C38757, representing a negative finding.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='biosample', - name='individual_age_at_collection', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='individual_age_at_collection', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:age_or_age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age object describing the age of the individual at the time of collection of biospecimens or phenotypic observations.', 'properties': {'age': {'anyOf': [{'description': 'An ISO8601 string represent age.', 'type': 'string'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age range of a subject.', 'properties': {'end': {'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'type': 'object'}, 'start': {'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age schema', 'type': 'object'}]}}, 'title': 'Age schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='biosample', - name='sampled_tissue', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term describing the tissue from which the specimen was collected. The use of UBERON is recommended.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='biosample', - name='taxonomy', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term specified when more than one organism may be studied. It is advised that codesfrom the NCBI Taxonomy resource are used, e.g. NCBITaxon:9606 for humans.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='biosample', - name='tumor_grade', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing the tumour grade. This should be a child term of NCIT:C28076 (Disease Grade Qualifier) or equivalent.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='biosample', - name='tumor_progression', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term representing if the specimen is from a primary tumour, a metastasis, or a recurrence. There are multiple ways of representing this using ontology terms, and the terms chosen will have a specific meaning that is application specific..', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='disease', - name='disease_stage', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), blank=True, help_text='A list of terms representing the disease stage. Elements should be derived from child terms of NCIT:C28108 (Disease Stage Qualifier) or equivalent hierarchy from another ontology.', null=True, size=None), - ), - migrations.AlterField( - model_name='disease', - name='onset', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A representation of the age of onset of the disease', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:disease_onset_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema for the age of the onset of the disease.', 'properties': {'age': {'anyOf': [{'description': 'An ISO8601 string represent age.', 'type': 'string'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age range of a subject.', 'properties': {'end': {'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'type': 'object'}, 'start': {'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}]}}, 'title': 'Onset age', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='disease', - name='term', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text="An ontology term that represents the disease. It's recommended that one of the OMIM, Orphanet, or MONDO ontologies is used for rare human diseases.", validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='disease', - name='tnm_finding', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), blank=True, help_text='A list of terms representing the tumour TNM score. Elements should be derived from child terms of NCIT:C48232 (Cancer TNM Finding) or equivalent hierarchy from another ontology.', null=True, size=None), - ), - migrations.AlterField( - model_name='metadata', - name='external_references', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:external_reference_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'The schema encodes information about an external reference.', 'properties': {'description': {'description': 'An application specific description.', 'type': 'string'}, 'id': {'description': 'An application specific identifier.', 'type': 'string'}}, 'required': ['id'], 'title': 'External reference schema', 'type': 'object'})]), blank=True, help_text='A list of external (non-resource) references.', null=True, size=None), - ), - migrations.AlterField( - model_name='metadata', - name='updates', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:update_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'Schema to check incoming updates format', 'properties': {'comment': {'description': 'Comment about updates or reasons for an update.', 'type': 'string'}, 'timestamp': {'description': 'ISO8601 UTC timestamp at which this record was updated.', 'format': 'date-time', 'type': 'string'}, 'updated_by': {'description': 'Who updated the phenopacket', 'type': 'string'}}, 'required': ['timestamp', 'comment'], 'title': 'Updates schema', 'type': 'object'})]), blank=True, help_text='A list of updates to the phenopacket.', null=True, size=None), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='evidence', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='One or more pieces of evidence that specify how the phenotype was determined.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:evidence_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'The schema represents the evidence for an assertion such as an observation of a PhenotypicFeature.', 'properties': {'evidence_code': {'additionalProperties': False, 'description': 'An ontology class that represents the evidence type.', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'type': 'object'}, 'reference': {'additionalProperties': False, 'description': 'Representation of the source of the evidence.', 'properties': {'description': {'description': 'An application specific description.', 'type': 'string'}, 'id': {'description': 'An application specific identifier.', 'type': 'string'}}, 'required': ['id'], 'type': 'object'}}, 'required': ['evidence_code'], 'title': 'Evidence schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='modifier', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), blank=True, help_text='A list of ontology terms that provide more expressive / precise descriptions of a phenotypic feature, including e.g. positionality or external factors.', null=True, size=None), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='onset', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the age at which the phenotypic feature was first noticed or diagnosed, e.g. HP:0003674.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='pftype', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term which describes the phenotype.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})], verbose_name='type'), - ), - migrations.AlterField( - model_name='phenotypicfeature', - name='severity', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that describes the severity of the condition.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='procedure', - name='body_site', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term that is specified when it is not possible to represent the procedure with a single ontology class.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='procedure', - name='code', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='An ontology term that represents a clinical procedure performed on a subject.', validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='variant', - name='allele', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text="The variant's corresponding allele", validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:allele_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'dependencies': {'genome_assembly': ['chr', 'pos', 're', 'alt', 'info'], 'seq_id': ['position', 'deleted_sequence', 'inserted_sequence']}, 'description': 'Variant allele types', 'oneOf': [{'required': ['hgvs']}, {'required': ['genome_assembly']}, {'required': ['seq_id']}, {'required': ['iscn']}], 'properties': {'alt': {'type': 'string'}, 'chr': {'type': 'string'}, 'deleted_sequence': {'type': 'string'}, 'genome_assembly': {'type': 'string'}, 'hgvs': {'type': 'string'}, 'id': {'type': 'string'}, 'info': {'type': 'string'}, 'inserted_sequence': {'type': 'string'}, 'iscn': {'type': 'string'}, 'pos': {'type': 'integer'}, 'position': {'type': 'integer'}, 're': {'type': 'string'}, 'seq_id': {'type': 'string'}}, 'title': 'Allele schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='variant', - name='zygosity', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='An ontology term taken from the Genotype Ontology (GENO) representing the zygosity of the variant.', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'})]), - ), - ] diff --git a/chord_metadata_service/phenopackets/migrations/0006_auto_20200430_1444.py b/chord_metadata_service/phenopackets/migrations/0006_auto_20200430_1444.py deleted file mode 100644 index 6e32d8507..000000000 --- a/chord_metadata_service/phenopackets/migrations/0006_auto_20200430_1444.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.12 on 2020-04-30 14:44 - -import chord_metadata_service.restapi.validators -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('phenopackets', '0005_auto_20200428_1633'), - ] - - operations = [ - migrations.AlterField( - model_name='biosample', - name='individual_age_at_collection', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='individual_age_at_collection', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:age_or_age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'An age object describing the age of the individual at the time of collection of biospecimens or phenotypic observations.', 'oneOf': [{'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age range of a subject.', 'properties': {'end': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, 'start': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age range schema', 'type': 'object'}], 'title': 'Age schema', 'type': 'object'})]), - ), - migrations.AlterField( - model_name='disease', - name='onset', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='A representation of the age of onset of the disease', null=True, validators=[chord_metadata_service.restapi.validators.JsonSchemaValidator({'$id': 'chord_metadata_service:disease_onset_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'description': 'Schema for the age of the onset of the disease.', 'oneOf': [{'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, {'$id': 'chord_metadata_service:age_range_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age range of a subject.', 'properties': {'end': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}, 'start': {'$id': 'chord_metadata_service:age_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'An age of a subject.', 'properties': {'age': {'description': 'An ISO8601 string represent age.', 'type': 'string'}}, 'required': ['age'], 'title': 'Age schema', 'type': 'object'}}, 'required': ['start', 'end'], 'title': 'Age range schema', 'type': 'object'}, {'$id': 'chord_metadata_service:ontology_class_schema', '$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'description': 'todo', 'properties': {'id': {'description': 'CURIE style identifier.', 'type': 'string'}, 'label': {'description': 'Human-readable class name.', 'type': 'string'}}, 'required': ['id', 'label'], 'title': 'Ontology class schema', 'type': 'object'}], 'title': 'Onset age', 'type': 'object'})]), - ), - ] diff --git a/chord_metadata_service/phenopackets/models.py b/chord_metadata_service/phenopackets/models.py index d641fa1f8..02208b11c 100644 --- a/chord_metadata_service/phenopackets/models.py +++ b/chord_metadata_service/phenopackets/models.py @@ -3,15 +3,23 @@ from django.core.exceptions import ValidationError from django.contrib.postgres.fields import JSONField, ArrayField from chord_metadata_service.patients.models import Individual +from chord_metadata_service.resources.models import Resource from chord_metadata_service.restapi.description_utils import rec_help from chord_metadata_service.restapi.models import IndexableMixin -from chord_metadata_service.restapi.validators import JsonSchemaValidator -from chord_metadata_service.restapi.schemas import ( - UPDATE_SCHEMA, EXTERNAL_REFERENCE, EVIDENCE, ALLELE_SCHEMA, DISEASE_ONSET -) -import chord_metadata_service.phenopackets.descriptions as d +from chord_metadata_service.restapi.schema_utils import schema_list from chord_metadata_service.restapi.validators import ( - ontology_validator, ontology_list_validator, age_or_age_range_validator + JsonSchemaValidator, + age_or_age_range_validator, + ontology_validator, + ontology_list_validator +) +from . import descriptions as d +from .schemas import ( + ALLELE_SCHEMA, + PHENOPACKET_DISEASE_ONSET_SCHEMA, + PHENOPACKET_EVIDENCE_SCHEMA, + PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA, + PHENOPACKET_UPDATE_SCHEMA, ) @@ -22,29 +30,6 @@ ############################################################# -class Resource(models.Model): - """ - Class to represent a description of an external resource - used for referencing an object - - FHIR: CodeSystem - """ - - # resource_id e.g. "id": "uniprot" - id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.RESOURCE, "id")) - name = models.CharField(max_length=200, help_text=rec_help(d.RESOURCE, "name")) - namespace_prefix = models.CharField(max_length=200, help_text=rec_help(d.RESOURCE, "namespace_prefix")) - url = models.URLField(max_length=200, help_text=rec_help(d.RESOURCE, "url")) - version = models.CharField(max_length=200, help_text=rec_help(d.RESOURCE, "version")) - iri_prefix = models.URLField(max_length=200, help_text=rec_help(d.RESOURCE, "iri_prefix")) - extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.RESOURCE, "extra_properties")) - created = models.DateTimeField(auto_now=True) - updated = models.DateTimeField(auto_now_add=True) - - def __str__(self): - return str(self.id) - - class MetaData(models.Model): """ Class to store structured definitions of the resources @@ -57,15 +42,14 @@ class MetaData(models.Model): created_by = models.CharField(max_length=200, help_text=rec_help(d.META_DATA, "created_by")) submitted_by = models.CharField(max_length=200, blank=True, help_text=rec_help(d.META_DATA, "submitted_by")) resources = models.ManyToManyField(Resource, help_text=rec_help(d.META_DATA, "resources")) - updates = ArrayField( - JSONField(null=True, blank=True, - validators=[JsonSchemaValidator(schema=UPDATE_SCHEMA, format_checker=['date-time'])]), - blank=True, null=True, help_text=rec_help(d.META_DATA, "updates")) + updates = JSONField(blank=True, null=True, validators=[JsonSchemaValidator( + schema=schema_list(PHENOPACKET_UPDATE_SCHEMA), formats=['date-time'])], + help_text=rec_help(d.META_DATA, "updates")) phenopacket_schema_version = models.CharField(max_length=200, blank=True, help_text='Schema version of the current phenopacket.') - external_references = ArrayField( - JSONField(null=True, blank=True, validators=[JsonSchemaValidator(EXTERNAL_REFERENCE)]), - blank=True, null=True, help_text=rec_help(d.META_DATA, "external_references")) + external_references = JSONField(blank=True, null=True, validators=[JsonSchemaValidator( + schema=schema_list(PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA))], + help_text=rec_help(d.META_DATA, "external_references")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.META_DATA, "extra_properties")) updated = models.DateTimeField(auto_now_add=True) @@ -90,22 +74,20 @@ class PhenotypicFeature(models.Model, IndexableMixin): FHIR: Condition or Observation """ - description = models.CharField( - max_length=200, blank=True, help_text=rec_help(d.PHENOTYPIC_FEATURE, "description")) + description = models.CharField(max_length=200, blank=True, help_text=rec_help(d.PHENOTYPIC_FEATURE, "description")) pftype = JSONField(verbose_name='type', validators=[ontology_validator], help_text=rec_help(d.PHENOTYPIC_FEATURE, "type")) negated = models.BooleanField(default=False, help_text=rec_help(d.PHENOTYPIC_FEATURE, "negated")) severity = JSONField(blank=True, null=True, validators=[ontology_validator], help_text=rec_help(d.PHENOTYPIC_FEATURE, "severity")) - modifier = ArrayField( - JSONField(null=True, blank=True, validators=[ontology_validator]), blank=True, null=True, - help_text=rec_help(d.PHENOTYPIC_FEATURE, "modifier")) + modifier = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.PHENOTYPIC_FEATURE, "modifier")) onset = JSONField(blank=True, null=True, validators=[ontology_validator], help_text=rec_help(d.PHENOTYPIC_FEATURE, "onset")) # evidence can stay here because evidence is given for an observation of PF # JSON schema to check evidence_code is present # FHIR: Condition.evidence - evidence = JSONField(blank=True, null=True, validators=[JsonSchemaValidator(schema=EVIDENCE)], + evidence = JSONField(blank=True, null=True, validators=[JsonSchemaValidator(schema=PHENOPACKET_EVIDENCE_SCHEMA)], help_text=rec_help(d.PHENOTYPIC_FEATURE, "evidence")) biosample = models.ForeignKey( "Biosample", on_delete=models.SET_NULL, blank=True, null=True, related_name='phenotypic_features') @@ -152,7 +134,7 @@ class HtsFile(models.Model, IndexableMixin): ('CRAM', 'CRAM'), ('VCF', 'VCF'), ('BCF', 'BCF'), - ('GVCF', 'GVCF') + ('GVCF', 'GVCF'), ) uri = models.URLField(primary_key=True, max_length=200, help_text=rec_help(d.HTS_FILE, "uri")) description = models.CharField(max_length=200, blank=True, help_text=rec_help(d.HTS_FILE, "description")) @@ -184,9 +166,8 @@ class Gene(models.Model): # Gene id is unique id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.GENE, "id")) # CURIE style? Yes! - alternate_ids = ArrayField( - models.CharField(max_length=200, blank=True), blank=True, null=True, - help_text=rec_help(d.GENE, "alternate_ids")) + alternate_ids = ArrayField(models.CharField(max_length=200, blank=True), blank=True, default=list, + help_text=rec_help(d.GENE, "alternate_ids")) symbol = models.CharField(max_length=200, help_text=rec_help(d.GENE, "symbol")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.GENE, "extra_properties")) created = models.DateTimeField(auto_now=True) @@ -208,7 +189,7 @@ class Variant(models.Model): ('hgvsAllele', 'hgvsAllele'), ('vcfAllele', 'vcfAllele'), ('spdiAllele', 'spdiAllele'), - ('iscnAllele', 'iscnAllele') + ('iscnAllele', 'iscnAllele'), ) allele_type = models.CharField(max_length=200, choices=ALLELE, help_text="One of four allele types.") allele = JSONField(validators=[JsonSchemaValidator(schema=ALLELE_SCHEMA)], @@ -240,12 +221,12 @@ class Disease(models.Model, IndexableMixin): # "id": "HP:0003581", # "label": "Adult onset" # } - onset = JSONField(blank=True, null=True, validators=[JsonSchemaValidator(schema=DISEASE_ONSET)], + onset = JSONField(blank=True, null=True, validators=[JsonSchemaValidator(schema=PHENOPACKET_DISEASE_ONSET_SCHEMA)], help_text=rec_help(d.DISEASE, "onset")) - disease_stage = ArrayField(JSONField(null=True, blank=True, validators=[ontology_validator]), - blank=True, null=True, help_text=rec_help(d.DISEASE, "disease_stage")) - tnm_finding = ArrayField(JSONField(null=True, blank=True, validators=[ontology_validator]), - blank=True, null=True, help_text=rec_help(d.DISEASE, "tnm_finding")) + disease_stage = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.DISEASE, "disease_stage")) + tnm_finding = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.DISEASE, "tnm_finding")) extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.DISEASE, "extra_properties")) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) @@ -283,8 +264,8 @@ class Biosample(models.Model, IndexableMixin): help_text=rec_help(d.BIOSAMPLE, "tumor_progression")) tumor_grade = JSONField(blank=True, null=True, validators=[ontology_validator], help_text=rec_help(d.BIOSAMPLE, "tumor_grade")) - diagnostic_markers = ArrayField(JSONField(null=True, blank=True, validators=[ontology_validator]), - blank=True, null=True, help_text=rec_help(d.BIOSAMPLE, "diagnostic_markers")) + diagnostic_markers = JSONField(blank=True, null=True, validators=[ontology_list_validator], + help_text=rec_help(d.BIOSAMPLE, "diagnostic_markers")) # CHECK! if Procedure instance is deleted Biosample instance is deleted too procedure = models.ForeignKey(Procedure, on_delete=models.CASCADE, help_text=rec_help(d.BIOSAMPLE, "procedure")) hts_files = models.ManyToManyField( @@ -330,7 +311,7 @@ class Phenopacket(models.Model, IndexableMixin): hts_files = models.ManyToManyField(HtsFile, blank=True, help_text=rec_help(d.PHENOPACKET, "hts_files")) # TODO OneToOneField meta_data = models.ForeignKey(MetaData, on_delete=models.CASCADE, help_text=rec_help(d.PHENOPACKET, "meta_data")) - dataset = models.ForeignKey("chord.Dataset", on_delete=models.CASCADE, blank=True, null=True) + table = models.ForeignKey("chord.Table", on_delete=models.CASCADE, blank=True, null=True) # TODO: Help text extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.PHENOPACKET, "extra_properties")) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) diff --git a/chord_metadata_service/phenopackets/schemas.py b/chord_metadata_service/phenopackets/schemas.py index 46610d4fe..33817646b 100644 --- a/chord_metadata_service/phenopackets/schemas.py +++ b/chord_metadata_service/phenopackets/schemas.py @@ -1,14 +1,39 @@ # Individual schemas for validation of JSONField values -import chord_metadata_service.phenopackets.descriptions as descriptions -from chord_metadata_service.phenopackets.models import * -from chord_metadata_service.patients.descriptions import INDIVIDUAL -from chord_metadata_service.patients.models import Individual -from chord_metadata_service.restapi.description_utils import describe_schema, ONTOLOGY_CLASS - -ALLELE_SCHEMA = { +from chord_metadata_service.patients.schemas import INDIVIDUAL_SCHEMA +from chord_metadata_service.resources.schemas import RESOURCE_SCHEMA +from chord_metadata_service.restapi.description_utils import describe_schema +from chord_metadata_service.restapi.schemas import ( + AGE, + AGE_RANGE, + AGE_OR_AGE_RANGE, + EXTRA_PROPERTIES_SCHEMA, + ONTOLOGY_CLASS, +) + +from . import descriptions + + +__all__ = [ + "ALLELE_SCHEMA", + "PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA", + "PHENOPACKET_UPDATE_SCHEMA", + "PHENOPACKET_META_DATA_SCHEMA", + "PHENOPACKET_EVIDENCE_SCHEMA", + "PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA", + "PHENOPACKET_GENE_SCHEMA", + "PHENOPACKET_HTS_FILE_SCHEMA", + "PHENOPACKET_VARIANT_SCHEMA", + "PHENOPACKET_BIOSAMPLE_SCHEMA", + "PHENOPACKET_DISEASE_ONSET_SCHEMA", + "PHENOPACKET_DISEASE_SCHEMA", + "PHENOPACKET_SCHEMA", +] + + +ALLELE_SCHEMA = describe_schema({ "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "todo", + "$id": "chord_metadata_service:allele_schema", "title": "Allele schema", "description": "Variant allele types", "type": "object", @@ -20,7 +45,7 @@ "genome_assembly": {"type": "string"}, "chr": {"type": "string"}, "pos": {"type": "integer"}, - "re": {"type": "string"}, + "ref": {"type": "string"}, "alt": {"type": "string"}, "info": {"type": "string"}, @@ -31,6 +56,7 @@ "iscn": {"type": "string"} }, + "additionalProperties": False, "oneOf": [ {"required": ["hgvs"]}, {"required": ["genome_assembly"]}, @@ -38,280 +64,73 @@ {"required": ["iscn"]} ], "dependencies": { - "genome_assembly": ["chr", "pos", "re", "alt", "info"], + "genome_assembly": ["chr", "pos", "ref", "alt", "info"], "seq_id": ["position", "deleted_sequence", "inserted_sequence"] } -} - - -UPDATE_SCHEMA = describe_schema({ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "todo", - "title": "Updates schema", - "description": "Schema to check incoming updates format", - "type": "object", - "properties": { - "timestamp": { - "type": "string", - "format": "date-time", - }, - "updated_by": {"type": "string"}, - "comment": {"type": "string"} - }, - "required": ["timestamp", "comment"] -}, descriptions.UPDATE) - - -def _single_optional_eq_search(order, queryable: str = "all"): - return { - "operations": ["eq"], - "queryable": queryable, - "canNegate": True, - "required": False, - "type": "single", - "order": order - } - - -def _optional_str_search(order, queryable: str = "all"): - return { - "operations": ["eq", "co"], - "queryable": queryable, - "canNegate": True, - "required": False, - "order": order - } - - -def _single_optional_str_search(order, queryable: str = "all"): - return {**_optional_str_search(order, queryable), "type": "single"} - - -def _multiple_optional_str_search(order, queryable: str = "all"): - return {**_optional_str_search(order, queryable), "type": "multiple"} - - -def _tag_with_database_attrs(schema: dict, db_attrs: dict): - return { - **schema, - "search": { - **schema.get("search", {}), - "database": { - **schema.get("search", {}).get("database", {}), - **db_attrs - } - } - } - +}, descriptions.ALLELE) -PHENOPACKET_ONTOLOGY_SCHEMA = describe_schema({ - "type": "object", - "properties": { - "id": { - "type": "string", - "search": _multiple_optional_str_search(0) - }, - "label": { - "type": "string", - "search": _multiple_optional_str_search(1) - }, - }, - "required": ["id", "label"], - "search": { - "database": { - "type": "jsonb" # TODO: parameterize? - } - } -}, ONTOLOGY_CLASS) PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:external_reference_schema", + "title": "External reference schema", "type": "object", "properties": { "id": { "type": "string", - "search": _single_optional_str_search(0) }, "description": { "type": "string", - "search": _multiple_optional_str_search(1) # TODO: Searchable? may leak - } - }, - "required": ["id"], - "search": { - "database": { - "type": "jsonb" # TODO: parameterize? - } - } -}, descriptions.EXTERNAL_REFERENCE) - - -PHENOPACKET_INDIVIDUAL_SCHEMA = describe_schema({ - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique researcher-specified identifier for the individual.", - "search": { - **_single_optional_eq_search(0, queryable="internal"), - "database": { - "field": Individual._meta.pk.column - } - } - }, - "alternate_ids": { - "type": "array", - "items": { - "type": "string", - "search": _multiple_optional_str_search(0, queryable="internal") - }, - "description": "A list of alternative identifiers for the individual.", # TODO: More specific - "search": { - "database": { - "type": "array" - } - } - }, - "date_of_birth": { - # TODO: This is a special ISO format... need UI for this - # TODO: Internal? - "type": "string", - "search": _single_optional_eq_search(1, queryable="internal") - }, - # TODO: Age - "sex": { - "type": "string", - "enum": ["UNKNOWN_SEX", "FEMALE", "MALE", "OTHER_SEX"], - "description": "An individual's phenotypic sex.", - "search": _single_optional_eq_search(2) - }, - "karyotypic_sex": { - "type": "string", - "enum": [ - "UNKNOWN_KARYOTYPE", - "XX", - "XY", - "XO", - "XXY", - "XXX", - "XXYY", - "XXXY", - "XXXX", - "XYY", - "OTHER_KARYOTYPE" - ], - "description": "An individual's karyotypic sex.", - "search": _single_optional_eq_search(3) - }, - "taxonomy": PHENOPACKET_ONTOLOGY_SCHEMA, - }, - "search": { - "database": { - "relation": Individual._meta.db_table, - "primary_key": Individual._meta.pk.column, } }, "required": ["id"] -}, INDIVIDUAL) - -PHENOPACKET_RESOURCE_SCHEMA = describe_schema({ - "type": "object", # TODO - "properties": { - "id": { - "type": "string", - "search": _single_optional_str_search(0) - }, - "name": { - "type": "string", - "search": _multiple_optional_str_search(1) - }, - "namespace_prefix": { - "type": "string", - "search": _multiple_optional_str_search(2) - }, - "url": { - "type": "string", - "search": _multiple_optional_str_search(3) - }, - "version": { - "type": "string", - "search": _multiple_optional_str_search(4) - }, - "iri_prefix": { - "type": "string", - "search": _multiple_optional_str_search(5) - } - }, - "required": ["id", "name", "namespace_prefix", "url", "version", "iri_prefix"], - "search": { - "database": { - "relationship": { - "type": "MANY_TO_ONE", - "foreign_key": "resource_id" # TODO: No hard-code, from M2M - } - } - } -}, descriptions.RESOURCE) +}, descriptions.EXTERNAL_REFERENCE) PHENOPACKET_UPDATE_SCHEMA = describe_schema({ - "type": "object", # TODO + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:update_schema", + "title": "Updates schema", + "type": "object", "properties": { "timestamp": { "type": "string", + "format": "date-time" }, "updated_by": { "type": "string", - "search": _multiple_optional_str_search(0), }, "comment": { "type": "string", - "search": _multiple_optional_str_search(1) } }, + "additionalProperties": False, "required": ["timestamp", "comment"], - "search": { - "database": { - "type": "jsonb" - } - } }, descriptions.UPDATE) # noinspection PyProtectedMember PHENOPACKET_META_DATA_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { - "created": {"type": "string"}, + "created": { + "type": "string", + "format": "date-time" + }, "created_by": { "type": "string", - "search": _multiple_optional_str_search(0) }, "submitted_by": { "type": "string", - "search": _multiple_optional_str_search(1) }, "resources": { "type": "array", - "items": PHENOPACKET_RESOURCE_SCHEMA, - "search": { - "database": { - "relation": MetaData._meta.get_field("resources").remote_field.through._meta.db_table, - "relationship": { - "type": "ONE_TO_MANY", - "parent_foreign_key": "metadata_id", # TODO: No hard-code - "parent_primary_key": MetaData._meta.pk.column # TODO: Redundant? - } - } - } + "items": RESOURCE_SCHEMA, }, "updates": { "type": "array", "items": PHENOPACKET_UPDATE_SCHEMA, - "search": { - "database": { - "type": "array" - } - } }, "phenopacket_schema_version": { "type": "string", @@ -319,28 +138,22 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): "external_references": { "type": "array", "items": PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA - } + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA }, - "search": { - "database": { - "relation": MetaData._meta.db_table, - "primary_key": MetaData._meta.pk.column - } - } }, descriptions.META_DATA) PHENOPACKET_EVIDENCE_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:evidence_schema", + "title": "Evidence schema", "type": "object", "properties": { - "evidence_code": PHENOPACKET_ONTOLOGY_SCHEMA, + "evidence_code": ONTOLOGY_CLASS, "reference": PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA }, + "additionalProperties": False, "required": ["evidence_code"], - "search": { - "database": { - "type": "jsonb" - } - } }, descriptions.EVIDENCE) PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA = describe_schema({ @@ -348,39 +161,22 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): "properties": { "description": { "type": "string", - "search": _multiple_optional_str_search(0), # TODO: Searchable? may leak }, - "type": PHENOPACKET_ONTOLOGY_SCHEMA, + "type": ONTOLOGY_CLASS, "negated": { "type": "boolean", - "search": _single_optional_eq_search(1) }, - "severity": PHENOPACKET_ONTOLOGY_SCHEMA, + "severity": ONTOLOGY_CLASS, "modifier": { # TODO: Plural? "type": "array", - "items": PHENOPACKET_ONTOLOGY_SCHEMA + "items": ONTOLOGY_CLASS }, - "onset": PHENOPACKET_ONTOLOGY_SCHEMA, + "onset": ONTOLOGY_CLASS, "evidence": PHENOPACKET_EVIDENCE_SCHEMA, + "extra_properties": EXTRA_PROPERTIES_SCHEMA }, - "search": { - "database": { - "relation": PhenotypicFeature._meta.db_table, - "primary_key": PhenotypicFeature._meta.pk.column - } - } }, descriptions.PHENOTYPIC_FEATURE) -PHENOPACKET_AGE_SCHEMA = describe_schema({ - "type": "object", - "properties": { - "age": { - "type": "string", - } - }, - "required": ["age"] -}, descriptions.AGE_NESTED) - # TODO: search PHENOPACKET_GENE_SCHEMA = describe_schema({ @@ -388,26 +184,23 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): "properties": { "id": { "type": "string", - "search": _single_optional_str_search(0) }, "alternate_ids": { "type": "array", "items": { "type": "string", - "search": _single_optional_str_search(1) } }, "symbol": { "type": "string", - "search": _single_optional_str_search(2) - } + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA }, "required": ["id", "symbol"] }, descriptions.GENE) PHENOPACKET_HTS_FILE_SCHEMA = describe_schema({ - # TODO: Search? Probably not "type": "object", "properties": { "uri": { @@ -425,7 +218,8 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): }, "individual_to_sample_identifiers": { "type": "object" # TODO - } + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA } }, descriptions.HTS_FILE) @@ -435,7 +229,8 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): "type": "object", # TODO "properties": { "allele": ALLELE_SCHEMA, # TODO - "zygosity": PHENOPACKET_ONTOLOGY_SCHEMA + "zygosity": ONTOLOGY_CLASS, + "extra_properties": EXTRA_PROPERTIES_SCHEMA } }, descriptions.VARIANT) @@ -445,184 +240,106 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): "properties": { "id": { "type": "string", - "search": { - **_single_optional_eq_search(0, queryable="internal"), - "database": {"field": Biosample._meta.pk.column} - } }, "individual_id": { "type": "string", }, "description": { "type": "string", - "search": _multiple_optional_str_search(1), # TODO: Searchable? may leak }, - "sampled_tissue": PHENOPACKET_ONTOLOGY_SCHEMA, + "sampled_tissue": ONTOLOGY_CLASS, "phenotypic_features": { "type": "array", "items": PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA, - "search": { - "database": { - **PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA["search"]["database"], - "relationship": { - "type": "ONE_TO_MANY", - "parent_foreign_key": PhenotypicFeature._meta.get_field("biosample").column, - "parent_primary_key": Biosample._meta.pk.column # TODO: Redundant - } - } - } }, - "taxonomy": PHENOPACKET_ONTOLOGY_SCHEMA, - "individual_age_at_collection": { - "type": "object", - "oneOf": [ # TODO: Front end will need to deal with this - { - "properties": PHENOPACKET_AGE_SCHEMA["properties"], - "description": PHENOPACKET_AGE_SCHEMA["description"], - "required": ["age"], - "additionalProperties": False - }, - { - "properties": { - "start": PHENOPACKET_AGE_SCHEMA, - "end": PHENOPACKET_AGE_SCHEMA, - }, - "description": d.AGE_RANGE, # TODO: annotated - "required": ["start", "end"], - "additionalProperties": False - } - ] - }, - "histological_diagnosis": PHENOPACKET_ONTOLOGY_SCHEMA, - "tumor_progression": PHENOPACKET_ONTOLOGY_SCHEMA, - "tumor_grade": PHENOPACKET_ONTOLOGY_SCHEMA, # TODO: Is this a list? + "taxonomy": ONTOLOGY_CLASS, + "individual_age_at_collection": AGE_OR_AGE_RANGE, + "histological_diagnosis": ONTOLOGY_CLASS, + "tumor_progression": ONTOLOGY_CLASS, + "tumor_grade": ONTOLOGY_CLASS, # TODO: Is this a list? "diagnostic_markers": { "type": "array", - "items": PHENOPACKET_ONTOLOGY_SCHEMA, - "search": {"database": {"type": "array"}} + "items": ONTOLOGY_CLASS, }, "procedure": { "type": "object", "properties": { - "code": PHENOPACKET_ONTOLOGY_SCHEMA, - "body_site": PHENOPACKET_ONTOLOGY_SCHEMA + "code": ONTOLOGY_CLASS, + "body_site": ONTOLOGY_CLASS }, "required": ["code"], - "search": { - "database": { - "primary_key": Procedure._meta.pk.column, - "relation": Procedure._meta.db_table, - "relationship": { - "type": "MANY_TO_ONE", - "foreign_key": Biosample._meta.get_field("procedure").column - } - } - } }, "hts_files": { "type": "array", - "items": PHENOPACKET_HTS_FILE_SCHEMA # TODO + "items": PHENOPACKET_HTS_FILE_SCHEMA }, "variants": { "type": "array", - "items": PHENOPACKET_VARIANT_SCHEMA, # TODO: search? + "items": PHENOPACKET_VARIANT_SCHEMA }, "is_control_sample": { - "type": "boolean", # TODO: Boolean search - "search": _single_optional_eq_search(1), + "type": "boolean" }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA }, "required": ["id", "sampled_tissue", "procedure"], - "search": { - "database": { - "primary_key": Biosample._meta.pk.column, - "relation": Biosample._meta.db_table, - } - } }, descriptions.BIOSAMPLE) + +PHENOPACKET_DISEASE_ONSET_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:disease_onset_schema", + "title": "Onset age", + "description": "Schema for the age of the onset of the disease.", + "type": "object", + "anyOf": [ + AGE, + AGE_RANGE, + ONTOLOGY_CLASS + ] +} + PHENOPACKET_DISEASE_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:disease_schema", + "title": "Disease schema", "type": "object", "properties": { - "term": PHENOPACKET_ONTOLOGY_SCHEMA, - "onset": PHENOPACKET_AGE_SCHEMA, + "term": ONTOLOGY_CLASS, + "onset": PHENOPACKET_DISEASE_ONSET_SCHEMA, "disease_stage": { "type": "array", - "items": PHENOPACKET_ONTOLOGY_SCHEMA, - "search": {"database": {"type": "array"}} + "items": ONTOLOGY_CLASS, }, "tnm_finding": { "type": "array", - "items": PHENOPACKET_ONTOLOGY_SCHEMA, - "search": {"database": {"type": "array"}} + "items": ONTOLOGY_CLASS, }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA }, "required": ["term"], - "search": { - "database": { - "primary_key": Disease._meta.pk.column, - "relation": Disease._meta.db_table, - } - } }, descriptions.DISEASE) # Deduplicate with other phenopacket representations # noinspection PyProtectedMember PHENOPACKET_SCHEMA = describe_schema({ "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "TODO", - "title": "Dataset Table Schema", + "$id": "chord_metadata_service:phenopacket_schema", + "title": "Phenopacket schema", "description": "Schema for metadata service datasets", "type": "object", "properties": { "id": { "type": "string", - "search": {"database": {"field": Phenopacket._meta.pk.column}} }, - "subject": _tag_with_database_attrs(PHENOPACKET_INDIVIDUAL_SCHEMA, { - "relationship": { - "type": "MANY_TO_ONE", - "foreign_key": Phenopacket._meta.get_field("subject").column - } - }), + "subject": INDIVIDUAL_SCHEMA, "phenotypic_features": { "type": "array", - "items": PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA, - "search": { - "database": { - **PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA["search"]["database"], - "relationship": { - "type": "ONE_TO_MANY", - "parent_foreign_key": "phenopacket_id", # TODO: No hard-code - "parent_primary_key": Phenopacket._meta.pk.column # TODO: Redundant? - } - } - } + "items": PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA }, "biosamples": { "type": "array", - "items": { - **PHENOPACKET_BIOSAMPLE_SCHEMA, - "search": { - "database": { - **PHENOPACKET_BIOSAMPLE_SCHEMA["search"]["database"], - "relationship": { - "type": "MANY_TO_ONE", - "foreign_key": "biosample_id" # TODO: No hard-code, from M2M - } - } - } - }, - "search": { - "database": { - "relation": Phenopacket._meta.get_field("biosamples").remote_field.through._meta.db_table, - "relationship": { - "type": "ONE_TO_MANY", - "parent_foreign_key": "phenopacket_id", # TODO: No hard-code - "parent_primary_key": Phenopacket._meta.pk.column # TODO: Redundant? - } - } - } + "items": PHENOPACKET_BIOSAMPLE_SCHEMA }, "genes": { "type": "array", @@ -634,41 +351,14 @@ def _tag_with_database_attrs(schema: dict, db_attrs: dict): }, "diseases": { # TODO: Too sensitive for search? "type": "array", - "items": { - **PHENOPACKET_DISEASE_SCHEMA, - "search": { - **PHENOPACKET_DISEASE_SCHEMA["search"], - "database": { - **PHENOPACKET_DISEASE_SCHEMA["search"]["database"], - "relationship": { - "type": "MANY_TO_ONE", - "foreign_key": "disease_id" # TODO: No hard-code, from M2M - } - } - } - }, - "search": { - "database": { - "relation": Phenopacket._meta.get_field("diseases").remote_field.through._meta.db_table, - "relationship": { - "type": "ONE_TO_MANY", - "parent_foreign_key": "phenopacket_id", # TODO: No hard-code - "parent_primary_key": Phenopacket._meta.pk.column # TODO: Redundant? - } - } - } + "items": PHENOPACKET_DISEASE_SCHEMA, }, # TODO "hts_files": { "type": "array", "items": PHENOPACKET_HTS_FILE_SCHEMA # TODO }, - "meta_data": PHENOPACKET_META_DATA_SCHEMA + "meta_data": PHENOPACKET_META_DATA_SCHEMA, + "extra_properties": EXTRA_PROPERTIES_SCHEMA }, "required": ["id", "meta_data"], - "search": { - "database": { - "relation": Phenopacket._meta.db_table, - "primary_key": Phenopacket._meta.pk.column - } - } }, descriptions.PHENOPACKET) diff --git a/chord_metadata_service/phenopackets/search_schemas.py b/chord_metadata_service/phenopackets/search_schemas.py new file mode 100644 index 000000000..66f1f479a --- /dev/null +++ b/chord_metadata_service/phenopackets/search_schemas.py @@ -0,0 +1,409 @@ +from . import models, schemas +from chord_metadata_service.patients.schemas import INDIVIDUAL_SCHEMA +from chord_metadata_service.resources.search_schemas import RESOURCE_SEARCH_SCHEMA +from chord_metadata_service.restapi.schema_utils import ( + search_optional_eq, + search_optional_str, + tag_schema_with_search_properties, +) +from chord_metadata_service.restapi.search_schemas import ONTOLOGY_SEARCH_SCHEMA + + +__all__ = [ + "EXTERNAL_REFERENCE_SEARCH_SCHEMA", + "PHENOPACKET_SEARCH_SCHEMA", +] + + +# TODO: Rewrite and use +def _tag_with_database_attrs(schema: dict, db_attrs: dict): + return { + **schema, + "search": { + **schema.get("search", {}), + "database": { + **schema.get("search", {}).get("database", {}), + **db_attrs + } + } + } + + +EXTERNAL_REFERENCE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_EXTERNAL_REFERENCE_SCHEMA, { + "properties": { + "id": { + "search": search_optional_str(0) + }, + "description": { + "search": search_optional_str(1, multiple=True) # TODO: Searchable? may leak + } + }, + "search": { + "database": { + "type": "jsonb" # TODO: parameterize? + } + } +}) + +INDIVIDUAL_SEARCH_SCHEMA = tag_schema_with_search_properties(INDIVIDUAL_SCHEMA, { + "properties": { + "id": { + "search": { + **search_optional_eq(0, queryable="internal"), + "database": { + "field": models.Individual._meta.pk.column + } + } + }, + "alternate_ids": { + "items": { + "search": search_optional_str(0, queryable="internal", multiple=True) + }, + "search": { + "database": { + "type": "array" + } + } + }, + "date_of_birth": { + # TODO: Internal? + # TODO: Allow lt / gt + "search": search_optional_eq(1, queryable="internal") + }, + # TODO: Age + "sex": { + "search": search_optional_eq(2) + }, + "karyotypic_sex": { + "search": search_optional_eq(3) + }, + "taxonomy": ONTOLOGY_SEARCH_SCHEMA, + }, + "search": { + "database": { + "relation": models.Individual._meta.db_table, + "primary_key": models.Individual._meta.pk.column, + } + }, +}) + +UPDATE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_UPDATE_SCHEMA, { + "properties": { + # TODO: timestamp + "updated_by": { + "search": search_optional_str(0, multiple=True), + }, + "comment": { + "search": search_optional_str(1, multiple=True), + } + }, + "search": { + "database": { + "type": "jsonb" + } + } +}) + +# noinspection PyProtectedMember +META_DATA_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_META_DATA_SCHEMA, { + "properties": { + # TODO: created + "created_by": { + "search": search_optional_str(0, multiple=True), + }, + "submitted_by": { + "search": search_optional_str(1, multiple=True), + }, + "resources": { + "items": RESOURCE_SEARCH_SCHEMA, + "search": { + "database": { + "relation": models.MetaData._meta.get_field("resources").remote_field.through._meta.db_table, + "relationship": { + "type": "ONE_TO_MANY", + "parent_foreign_key": "metadata_id", # TODO: No hard-code + "parent_primary_key": models.MetaData._meta.pk.column # TODO: Redundant? + } + } + } + }, + "updates": { + "items": UPDATE_SEARCH_SCHEMA, + "search": { + "database": { + "type": "array" + } + } + }, + # TODO: phenopacket_schema_version + "external_references": { + "items": EXTERNAL_REFERENCE_SEARCH_SCHEMA + } + }, + "search": { + "database": { + "relation": models.MetaData._meta.db_table, + "primary_key": models.MetaData._meta.pk.column + } + } +}) + +EVIDENCE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_EVIDENCE_SCHEMA, { + "properties": { + "evidence_code": ONTOLOGY_SEARCH_SCHEMA, + "reference": EXTERNAL_REFERENCE_SEARCH_SCHEMA, + }, + "search": { + "database": { + "type": "jsonb" + } + } +}) + +PHENOTYPIC_FEATURE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_PHENOTYPIC_FEATURE_SCHEMA, { + "properties": { + "description": { + "search": search_optional_str(0, multiple=True), # TODO: Searchable? may leak + }, + "type": ONTOLOGY_SEARCH_SCHEMA, + "negated": { + "search": search_optional_eq(1), + }, + "severity": ONTOLOGY_SEARCH_SCHEMA, + "modifier": { # TODO: Plural? + "items": ONTOLOGY_SEARCH_SCHEMA + }, + "onset": ONTOLOGY_SEARCH_SCHEMA, + "evidence": EVIDENCE_SEARCH_SCHEMA, + }, + "search": { + "database": { + "relation": models.PhenotypicFeature._meta.db_table, + "primary_key": models.PhenotypicFeature._meta.pk.column + } + } +}) + +# TODO: Fix +GENE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_GENE_SCHEMA, { + "properties": { + "id": { + "search": search_optional_str(0), + }, + "alternate_ids": { + "items": { + "search": search_optional_str(1), + } + }, + "symbol": { + "search": search_optional_str(2), + } + }, +}) + +# TODO: Search? Probably not +HTS_FILE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_HTS_FILE_SCHEMA, {}) + +# TODO: search?? +VARIANT_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_VARIANT_SCHEMA, { + "properties": { + "allele": {"search": {}}, # TODO + "zygosity": ONTOLOGY_SEARCH_SCHEMA, + } +}) + +BIOSAMPLE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_BIOSAMPLE_SCHEMA, { + "properties": { + "id": { + "search": { + **search_optional_eq(0, queryable="internal"), + "database": {"field": models.Biosample._meta.pk.column} + } + }, + "individual_id": { # TODO: Does this work? + "search": search_optional_eq(1, queryable="internal"), + }, + "description": { + "search": search_optional_str(2, multiple=True), # TODO: Searchable? may leak + }, + "sampled_tissue": ONTOLOGY_SEARCH_SCHEMA, + "phenotypic_features": { + "items": PHENOTYPIC_FEATURE_SEARCH_SCHEMA, + "search": { + "database": { + **PHENOTYPIC_FEATURE_SEARCH_SCHEMA["search"]["database"], + "relationship": { + "type": "ONE_TO_MANY", + "parent_foreign_key": models.PhenotypicFeature._meta.get_field("biosample").column, + "parent_primary_key": models.Biosample._meta.pk.column # TODO: Redundant + } + } + } + }, + "taxonomy": ONTOLOGY_SEARCH_SCHEMA, + # TODO: Front end will need to deal with this: + # TODO: individual_age_at_collection + "histological_diagnosis": ONTOLOGY_SEARCH_SCHEMA, + "tumor_progression": ONTOLOGY_SEARCH_SCHEMA, + "tumor_grade": ONTOLOGY_SEARCH_SCHEMA, # TODO: Is this a list? + "diagnostic_markers": { + "items": ONTOLOGY_SEARCH_SCHEMA, + "search": {"database": {"type": "array"}} + }, + "procedure": { + "properties": { + "code": ONTOLOGY_SEARCH_SCHEMA, + "body_site": ONTOLOGY_SEARCH_SCHEMA + }, + "search": { + "database": { + "primary_key": models.Procedure._meta.pk.column, + "relation": models.Procedure._meta.db_table, + "relationship": { + "type": "MANY_TO_ONE", + "foreign_key": models.Biosample._meta.get_field("procedure").column + } + } + } + }, + "hts_files": { + "items": HTS_FILE_SEARCH_SCHEMA # TODO + }, + "variants": { + "items": VARIANT_SEARCH_SCHEMA, # TODO: search? + }, + "is_control_sample": { + "search": search_optional_eq(1), # TODO: Boolean search + }, + }, + "search": { + "database": { + "primary_key": models.Biosample._meta.pk.column, + "relation": models.Biosample._meta.db_table, + } + } +}) + +# TODO +DISEASE_ONSET_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_DISEASE_ONSET_SCHEMA, {}) + +DISEASE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_DISEASE_SCHEMA, { + "properties": { + "term": ONTOLOGY_SEARCH_SCHEMA, + "onset": DISEASE_ONSET_SEARCH_SCHEMA, + "disease_stage": { + "items": ONTOLOGY_SEARCH_SCHEMA, + "search": {"database": {"type": "array"}} + }, + "tnm_finding": { + "items": ONTOLOGY_SEARCH_SCHEMA, + "search": {"database": {"type": "array"}} + }, + }, + "search": { + "database": { + "primary_key": models.Disease._meta.pk.column, + "relation": models.Disease._meta.db_table, + } + } +}) + +# noinspection PyProtectedMember +PHENOPACKET_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.PHENOPACKET_SCHEMA, { + "properties": { + "id": { + "search": {"database": {"field": models.Phenopacket._meta.pk.column}} + }, + "subject": { + **INDIVIDUAL_SEARCH_SCHEMA, + "search": { + **INDIVIDUAL_SEARCH_SCHEMA["search"], + "database": { + **INDIVIDUAL_SEARCH_SCHEMA["search"]["database"], + "relationship": { + "type": "MANY_TO_ONE", + "foreign_key": models.Phenopacket._meta.get_field("subject").column + } + } + } + }, + "phenotypic_features": { + "items": PHENOTYPIC_FEATURE_SEARCH_SCHEMA, + "search": { + "database": { + **PHENOTYPIC_FEATURE_SEARCH_SCHEMA["search"]["database"], + "relationship": { + "type": "ONE_TO_MANY", + "parent_foreign_key": "phenopacket_id", # TODO: No hard-code + "parent_primary_key": models.Phenopacket._meta.pk.column # TODO: Redundant? + } + } + } + }, + "biosamples": { + "items": { + **BIOSAMPLE_SEARCH_SCHEMA, + "search": { + "database": { + **BIOSAMPLE_SEARCH_SCHEMA["search"]["database"], + "relationship": { + "type": "MANY_TO_ONE", + "foreign_key": "biosample_id" # TODO: No hard-code, from M2M + } + } + } + }, + "search": { + "database": { + "relation": models.Phenopacket._meta.get_field("biosamples").remote_field.through._meta.db_table, + "relationship": { + "type": "ONE_TO_MANY", + "parent_foreign_key": "phenopacket_id", # TODO: No hard-code + "parent_primary_key": models.Phenopacket._meta.pk.column # TODO: Redundant? + } + } + } + }, + "genes": { + "items": GENE_SEARCH_SCHEMA + }, + "variants": { + "items": VARIANT_SEARCH_SCHEMA + }, + "diseases": { # TODO: Too sensitive for search? + "items": { + **DISEASE_SEARCH_SCHEMA, + "search": { + **DISEASE_SEARCH_SCHEMA["search"], + "database": { + **DISEASE_SEARCH_SCHEMA["search"]["database"], + "relationship": { + "type": "MANY_TO_ONE", + "foreign_key": "disease_id" # TODO: No hard-code, from M2M + } + } + } + }, + "search": { + "database": { + "relation": models.Phenopacket._meta.get_field("diseases").remote_field.through._meta.db_table, + "relationship": { + "type": "ONE_TO_MANY", + "parent_foreign_key": "phenopacket_id", # TODO: No hard-code + "parent_primary_key": models.Phenopacket._meta.pk.column # TODO: Redundant? + } + } + } + }, # TODO + "hts_files": { + "items": HTS_FILE_SEARCH_SCHEMA # TODO + }, + "meta_data": META_DATA_SEARCH_SCHEMA + }, + "search": { + "database": { + "relation": models.Phenopacket._meta.db_table, + "primary_key": models.Phenopacket._meta.pk.column + } + } +}) diff --git a/chord_metadata_service/phenopackets/serializers.py b/chord_metadata_service/phenopackets/serializers.py index f25bc72d2..f2437556c 100644 --- a/chord_metadata_service/phenopackets/serializers.py +++ b/chord_metadata_service/phenopackets/serializers.py @@ -1,8 +1,39 @@ import re from rest_framework import serializers -from .models import * +from .models import ( + MetaData, + PhenotypicFeature, + Procedure, + HtsFile, + Gene, + Variant, + Disease, + Biosample, + Phenopacket, + GenomicInterpretation, + Diagnosis, + Interpretation, +) +from chord_metadata_service.resources.serializers import ResourceSerializer +from chord_metadata_service.restapi import fhir_utils from chord_metadata_service.restapi.serializers import GenericSerializer -from chord_metadata_service.restapi.fhir_utils import * + + +__all__ = [ + "MetaDataSerializer", + "PhenotypicFeatureSerializer", + "ProcedureSerializer", + "HtsFileSerializer", + "GeneSerializer", + "VariantSerializer", + "DiseaseSerializer", + "BiosampleSerializer", + "SimplePhenopacketSerializer", + "PhenopacketSerializer", + "GenomicInterpretationSerializer", + "DiagnosisSerializer", + "InterpretationSerializer", +] ############################################################# @@ -11,11 +42,6 @@ # # ############################################################# -class ResourceSerializer(GenericSerializer): - class Meta: - model = Resource - fields = '__all__' - class MetaDataSerializer(GenericSerializer): resources = ResourceSerializer(read_only=True, many=True) @@ -39,7 +65,7 @@ class Meta: exclude = ['pftype'] # meta info for converting to FHIR fhir_datatype_plural = 'observations' - class_converter = fhir_observation + class_converter = fhir_utils.fhir_observation class ProcedureSerializer(GenericSerializer): @@ -49,7 +75,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'specimen.collections' - class_converter = fhir_specimen_collection + class_converter = fhir_utils.fhir_specimen_collection def create(self, validated_data): if validated_data.get('body_site'): @@ -67,7 +93,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'document_references' - class_converter = fhir_document_reference + class_converter = fhir_utils.fhir_document_reference class GeneSerializer(GenericSerializer): @@ -80,7 +106,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'observations' - class_converter = fhir_obs_component_region_studied + class_converter = fhir_utils.fhir_obs_component_region_studied class VariantSerializer(GenericSerializer): @@ -90,7 +116,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'observations' - class_converter = fhir_obs_component_variant + class_converter = fhir_utils.fhir_obs_component_variant def to_representation(self, obj): """ Change 'allele_type' field name to allele type value. """ @@ -103,7 +129,7 @@ def to_internal_value(self, data): """ When writing back to db change field name back to 'allele'. """ if 'allele' not in data.keys(): - allele_type = data.get('allele_type') # e.g. spdiAllele + allele_type = data.get('allele_type') # e.g. spdiAllele # split by uppercase normilize = filter(None, re.split("([A-Z][^A-Z]*)", allele_type)) normilized_allele_type = '_'.join([i.lower() for i in normilize]) @@ -118,7 +144,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'conditions' - class_converter = fhir_condition + class_converter = fhir_utils.fhir_condition class BiosampleSerializer(GenericSerializer): @@ -131,7 +157,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'specimens' - class_converter = fhir_specimen + class_converter = fhir_utils.fhir_specimen def create(self, validated_data): procedure_data = validated_data.pop('procedure') @@ -163,7 +189,7 @@ class Meta: fields = '__all__' # meta info for converting to FHIR fhir_datatype_plural = 'compositions' - class_converter = fhir_composition + class_converter = fhir_utils.fhir_composition def to_representation(self, instance): """" diff --git a/chord_metadata_service/phenopackets/tests/constants.py b/chord_metadata_service/phenopackets/tests/constants.py index bd0810cd5..8cd50c815 100644 --- a/chord_metadata_service/phenopackets/tests/constants.py +++ b/chord_metadata_service/phenopackets/tests/constants.py @@ -31,7 +31,8 @@ "external_references": [ { "id": "PMID:30808312", - "description": "Bao M, et al. COL6A1 mutation leading to Bethlem myopathy with recurrent hematuria: a case report. BMC Neurol. 2019;19(1):32." + "description": "Bao M, et al. COL6A1 mutation leading to Bethlem myopathy with recurrent hematuria: a case " + "report. BMC Neurol. 2019;19(1):32." }, { "id": "PMID:3080844", @@ -175,32 +176,6 @@ } } -VALID_RESOURCE_1 = { - "id": "so", - "name": "Sequence types and features", - "url": "http://purl.obolibrary.org/obo/so.owl", - "version": "2015-11-24", - "namespace_prefix": "SO", - "iri_prefix": "http://purl.obolibrary.org/obo/SO_" -} - -VALID_RESOURCE_2 = { - "id": "hgnc", - "name": "HUGO Gene Nomenclature Committee", - "url": "https://www.genenames.org", - "version": "2019-08-08", - "namespace_prefix": "HGNC", - "iri_prefix": "https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/" -} - -DUPLICATE_RESOURCE_3 = { - "id": "hgnc", - "name": "HUGO Gene Nomenclature Committee", - "url": "https://www.genenames.org", - "version": "2019-08-08", - "namespace_prefix": "HGNC" -} - def valid_phenopacket(subject, meta_data): return dict( diff --git a/chord_metadata_service/phenopackets/tests/test_api.py b/chord_metadata_service/phenopackets/tests/test_api.py index cdd2e4451..5c7b06cac 100644 --- a/chord_metadata_service/phenopackets/tests/test_api.py +++ b/chord_metadata_service/phenopackets/tests/test_api.py @@ -1,7 +1,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from .constants import * -from ..serializers import * +from . import constants as c +from .. import models as m, serializers as s from chord_metadata_service.restapi.tests.utils import get_response @@ -9,9 +9,9 @@ class CreateBiosampleTest(APITestCase): """ Test module for creating an Biosample. """ def setUp(self): - self.individual = Individual.objects.create(**VALID_INDIVIDUAL_1) - self.procedure = VALID_PROCEDURE_1 - self.valid_payload = valid_biosample_1(self.individual.id, self.procedure) + self.individual = m.Individual.objects.create(**c.VALID_INDIVIDUAL_1) + self.procedure = c.VALID_PROCEDURE_1 + self.valid_payload = c.valid_biosample_1(self.individual.id, self.procedure) self.invalid_payload = { "id": "biosample:1", "individual": self.individual.id, @@ -50,8 +50,8 @@ def test_create_biosample(self): response = get_response('biosample-list', self.valid_payload) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Biosample.objects.count(), 1) - self.assertEqual(Biosample.objects.get().id, 'biosample_id:1') + self.assertEqual(m.Biosample.objects.count(), 1) + self.assertEqual(m.Biosample.objects.get().id, 'biosample_id:1') def test_create_invalid_biosample(self): """ POST a new biosample with invalid data. """ @@ -59,28 +59,28 @@ def test_create_invalid_biosample(self): invalid_response = get_response('biosample-list', self.invalid_payload) self.assertEqual( invalid_response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Biosample.objects.count(), 0) + self.assertEqual(m.Biosample.objects.count(), 0) def test_seriliazer_validate_invalid(self): - serializer = BiosampleSerializer(data=self.invalid_payload) + serializer = s.BiosampleSerializer(data=self.invalid_payload) self.assertEqual(serializer.is_valid(), False) def test_seriliazer_validate_valid(self): - serializer = BiosampleSerializer(data=self.valid_payload) + serializer = s.BiosampleSerializer(data=self.valid_payload) self.assertEqual(serializer.is_valid(), True) class CreatePhenotypicFeatureTest(APITestCase): def setUp(self): - valid_payload = valid_phenotypic_feature() - removed_pftype = valid_payload.pop('pftype', None) + valid_payload = c.valid_phenotypic_feature() + valid_payload.pop('pftype', None) valid_payload['type'] = { "id": "HP:0000520", "label": "Proptosis" } self.valid_phenotypic_feature = valid_payload - invalid_payload = invalid_phenotypic_feature() + invalid_payload = c.invalid_phenotypic_feature() invalid_payload['type'] = { "id": "HP:0000520", "label": "Proptosis" @@ -92,19 +92,19 @@ def test_create_phenotypic_feature(self): response = get_response('phenotypicfeature-list', self.valid_phenotypic_feature) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(PhenotypicFeature.objects.count(), 1) + self.assertEqual(m.PhenotypicFeature.objects.count(), 1) def test_modifier(self): - serializer = PhenotypicFeatureSerializer(data=self.invalid_phenotypic_feature) + serializer = s.PhenotypicFeatureSerializer(data=self.invalid_phenotypic_feature) self.assertEqual(serializer.is_valid(), False) class CreateProcedureTest(APITestCase): def setUp(self): - self.valid_procedure = VALID_PROCEDURE_1 - self.duplicate_procedure = VALID_PROCEDURE_1 - self.valid_procedure_duplicate_code = VALID_PROCEDURE_2 + self.valid_procedure = c.VALID_PROCEDURE_1 + self.duplicate_procedure = c.VALID_PROCEDURE_1 + self.valid_procedure_duplicate_code = c.VALID_PROCEDURE_2 def test_procedure(self): response = get_response('procedure-list', self.valid_procedure) @@ -115,138 +115,122 @@ def test_procedure(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response_duplicate.status_code, status.HTTP_201_CREATED) self.assertEqual(response_duplicate_code.status_code, status.HTTP_201_CREATED) - self.assertEqual(Procedure.objects.count(), 2) + self.assertEqual(m.Procedure.objects.count(), 2) class CreateHtsFileTest(APITestCase): def setUp(self): - self.hts_file = VALID_HTS_FILE + self.hts_file = c.VALID_HTS_FILE def test_hts_file(self): response = get_response('htsfile-list', self.hts_file) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(HtsFile.objects.count(), 1) + self.assertEqual(m.HtsFile.objects.count(), 1) class CreateGeneTest(APITestCase): def setUp(self): - self.gene = VALID_GENE_1 - self.duplicate_gene = DUPLICATE_GENE_2 - self.invalid_gene = INVALID_GENE_2 + self.gene = c.VALID_GENE_1 + self.duplicate_gene = c.DUPLICATE_GENE_2 + self.invalid_gene = c.INVALID_GENE_2 def test_gene(self): response = get_response('gene-list', self.gene) response_duplicate = get_response('htsfile-list', self.duplicate_gene) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response_duplicate.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Gene.objects.count(), 1) + self.assertEqual(m.Gene.objects.count(), 1) def test_alternate_ids(self): - serializer = GeneSerializer(data=self.invalid_gene) + serializer = s.GeneSerializer(data=self.invalid_gene) self.assertEqual(serializer.is_valid(), False) class CreateVariantTest(APITestCase): def setUp(self): - self.variant = VALID_VARIANT_1 - self.variant_2 = VALID_VARIANT_2 + self.variant = c.VALID_VARIANT_1 + self.variant_2 = c.VALID_VARIANT_2 def test_variant(self): response = get_response('variant-list', self.variant) - serializer = VariantSerializer(data=self.variant) + serializer = s.VariantSerializer(data=self.variant) self.assertEqual(serializer.is_valid(), True) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Variant.objects.count(), 1) + self.assertEqual(m.Variant.objects.count(), 1) def test_to_represenation(self): response = get_response('variant-list', self.variant_2) - serializer = VariantSerializer(data=self.variant) + serializer = s.VariantSerializer(data=self.variant) self.assertEqual(serializer.is_valid(), True) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Variant.objects.count(), 1) + self.assertEqual(m.Variant.objects.count(), 1) class CreateDiseaseTest(APITestCase): def setUp(self): - self.disease = VALID_DISEASE_1 - self.invalid_disease = INVALID_DISEASE_2 + self.disease = c.VALID_DISEASE_1 + self.invalid_disease = c.INVALID_DISEASE_2 def test_disease(self): response = get_response('disease-list', self.disease) - serializer = DiseaseSerializer(data=self.disease) + serializer = s.DiseaseSerializer(data=self.disease) self.assertEqual(serializer.is_valid(), True) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Disease.objects.count(), 1) + self.assertEqual(m.Disease.objects.count(), 1) def test_invalid_disease(self): - serializer = DiseaseSerializer(data=self.invalid_disease) + serializer = s.DiseaseSerializer(data=self.invalid_disease) self.assertEqual(serializer.is_valid(), False) - self.assertEqual(Disease.objects.count(), 0) - - -class CreateResourceTest(APITestCase): - - def setUp(self): - self.resource = VALID_RESOURCE_2 - self.duplicate_resource = DUPLICATE_RESOURCE_3 - - def test_resource(self): - response = get_response('resource-list', self.resource) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Resource.objects.count(), 1) - - def test_serializer(self): - serializer = ResourceSerializer(data=self.resource) - self.assertEqual(serializer.is_valid(), True) + self.assertEqual(m.Disease.objects.count(), 0) class CreateMetaDataTest(APITestCase): def setUp(self): - self.metadata = VALID_META_DATA_2 + self.metadata = c.VALID_META_DATA_2 def test_metadata(self): response = get_response('metadata-list', self.metadata) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(MetaData.objects.count(), 1) + self.assertEqual(m.MetaData.objects.count(), 1) def test_serializer(self): # is_valid() calls validation on serializer - serializer = MetaDataSerializer(data=self.metadata) + serializer = s.MetaDataSerializer(data=self.metadata) self.assertEqual(serializer.is_valid(), True) class CreatePhenopacketTest(APITestCase): def setUp(self): - individual = Individual.objects.create(**VALID_INDIVIDUAL_1) + individual = m.Individual.objects.create(**c.VALID_INDIVIDUAL_1) self.subject = individual.id - meta = MetaData.objects.create(**VALID_META_DATA_2) + meta = m.MetaData.objects.create(**c.VALID_META_DATA_2) self.metadata = meta.id - self.phenopacket = valid_phenopacket( + self.phenopacket = c.valid_phenopacket( subject=self.subject, meta_data=self.metadata) def test_phenopacket(self): response = get_response('phenopacket-list', self.phenopacket) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Phenopacket.objects.count(), 1) + self.assertEqual(m.Phenopacket.objects.count(), 1) def test_serializer(self): - serializer = PhenopacketSerializer(data=self.phenopacket) + serializer = s.PhenopacketSerializer(data=self.phenopacket) self.assertEqual(serializer.is_valid(), True) class CreateGenomicInterpretationTest(APITestCase): def setUp(self): - self.gene = Gene.objects.create(**VALID_GENE_1).id - self.variant = Variant.objects.create(**VALID_VARIANT_1).id - self.genomic_interpretation = valid_genomic_interpretation( + self.gene = m.Gene.objects.create(**c.VALID_GENE_1).id + self.variant = m.Variant.objects.create(**c.VALID_VARIANT_1).id + self.genomic_interpretation = c.valid_genomic_interpretation( gene=self.gene, variant=self.variant ) @@ -255,40 +239,40 @@ def test_genomic_interpretation(self): response = get_response('genomicinterpretation-list', self.genomic_interpretation) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(GenomicInterpretation.objects.count(), 1) + self.assertEqual(m.GenomicInterpretation.objects.count(), 1) def test_serializer(self): - serializer = GenomicInterpretationSerializer(data=self.genomic_interpretation) + serializer = s.GenomicInterpretationSerializer(data=self.genomic_interpretation) self.assertEqual(serializer.is_valid(), True) class CreateDiagnosisTest(APITestCase): def setUp(self): - self.disease = Disease.objects.create(**VALID_DISEASE_1).id - self.diagnosis = valid_diagnosis(self.disease) + self.disease = m.Disease.objects.create(**c.VALID_DISEASE_1).id + self.diagnosis = c.valid_diagnosis(self.disease) def test_diagnosis(self): response = get_response('diagnosis-list', self.diagnosis) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - serializer = DiagnosisSerializer(data=self.diagnosis) + serializer = s.DiagnosisSerializer(data=self.diagnosis) self.assertEqual(serializer.is_valid(), True) class CreateInterpretationTest(APITestCase): def setUp(self): - self.individual = Individual.objects.create(**VALID_INDIVIDUAL_1) - self.metadata = MetaData.objects.create(**VALID_META_DATA_2) - self.phenopacket = Phenopacket.objects.create(**valid_phenopacket( + self.individual = m.Individual.objects.create(**c.VALID_INDIVIDUAL_1) + self.metadata = m.MetaData.objects.create(**c.VALID_META_DATA_2) + self.phenopacket = m.Phenopacket.objects.create(**c.valid_phenopacket( subject=self.individual, meta_data=self.metadata) ).id - self.metadata_interpretation = MetaData.objects.create(**VALID_META_DATA_2).id - self.disease = Disease.objects.create(**VALID_DISEASE_1) - self.diagnosis = Diagnosis.objects.create(**valid_diagnosis(self.disease)).id - self.interpretation = valid_interpretation( + self.metadata_interpretation = m.MetaData.objects.create(**c.VALID_META_DATA_2).id + self.disease = m.Disease.objects.create(**c.VALID_DISEASE_1) + self.diagnosis = m.Diagnosis.objects.create(**c.valid_diagnosis(self.disease)).id + self.interpretation = c.valid_interpretation( phenopacket=self.phenopacket, meta_data=self.metadata_interpretation ) diff --git a/chord_metadata_service/phenopackets/tests/test_models.py b/chord_metadata_service/phenopackets/tests/test_models.py index 4032c5d2f..4faddd917 100644 --- a/chord_metadata_service/phenopackets/tests/test_models.py +++ b/chord_metadata_service/phenopackets/tests/test_models.py @@ -1,22 +1,23 @@ -from django.test import TestCase -from ..models import * -from chord_metadata_service.patients.models import Individual -from django.db.utils import IntegrityError from django.core.exceptions import ValidationError -from .constants import * +from django.db.utils import IntegrityError +from django.test import TestCase + +from chord_metadata_service.resources.tests.constants import VALID_RESOURCE_1, VALID_RESOURCE_2 +from . import constants as c +from .. import models as m class BiosampleTest(TestCase): """ Test module for Biosample model """ def setUp(self): - self.individual = Individual.objects.create(**VALID_INDIVIDUAL_1) - self.procedure = Procedure.objects.create(**VALID_PROCEDURE_1) - self.biosample_1 = Biosample.objects.create(**valid_biosample_1(self.individual, self.procedure)) - self.biosample_2 = Biosample.objects.create(**valid_biosample_2(None, self.procedure)) - self.meta_data = MetaData.objects.create(**VALID_META_DATA_1) + self.individual = m.Individual.objects.create(**c.VALID_INDIVIDUAL_1) + self.procedure = m.Procedure.objects.create(**c.VALID_PROCEDURE_1) + self.biosample_1 = m.Biosample.objects.create(**c.valid_biosample_1(self.individual, self.procedure)) + self.biosample_2 = m.Biosample.objects.create(**c.valid_biosample_2(None, self.procedure)) + self.meta_data = m.MetaData.objects.create(**c.VALID_META_DATA_1) - self.phenopacket = Phenopacket.objects.create( + self.phenopacket = m.Phenopacket.objects.create( id="phenopacket_id:1", subject=self.individual, meta_data=self.meta_data, @@ -24,7 +25,7 @@ def setUp(self): self.phenopacket.biosamples.set([self.biosample_1, self.biosample_2]) def test_biosample(self): - biosample_one = Biosample.objects.get( + biosample_one = m.Biosample.objects.get( tumor_progression__label='Primary Malignant Neoplasm', sampled_tissue__label__icontains='urinary bladder' ) @@ -43,33 +44,33 @@ class PhenotypicFeatureTest(TestCase): """ Test module for PhenotypicFeature model. """ def setUp(self): - self.individual_1 = Individual.objects.create(**VALID_INDIVIDUAL_1) - self.individual_2 = Individual.objects.create(**VALID_INDIVIDUAL_2) - self.procedure = Procedure.objects.create(**VALID_PROCEDURE_1) - self.biosample_1 = Biosample.objects.create(**valid_biosample_1( + self.individual_1 = m.Individual.objects.create(**c.VALID_INDIVIDUAL_1) + self.individual_2 = m.Individual.objects.create(**c.VALID_INDIVIDUAL_2) + self.procedure = m.Procedure.objects.create(**c.VALID_PROCEDURE_1) + self.biosample_1 = m.Biosample.objects.create(**c.valid_biosample_1( self.individual_1, self.procedure) ) - self.biosample_2 = Biosample.objects.create(**valid_biosample_2( + self.biosample_2 = m.Biosample.objects.create(**c.valid_biosample_2( self.individual_2, self.procedure) ) - self.meta_data = MetaData.objects.create(**VALID_META_DATA_1) - self.phenopacket = Phenopacket.objects.create( + self.meta_data = m.MetaData.objects.create(**c.VALID_META_DATA_1) + self.phenopacket = m.Phenopacket.objects.create( id='phenopacket_id:1', subject=self.individual_2, meta_data=self.meta_data, ) - self.phenotypic_feature_1 = PhenotypicFeature.objects.create( - **valid_phenotypic_feature(biosample=self.biosample_1)) - self.phenotypic_feature_2 = PhenotypicFeature.objects.create( - **valid_phenotypic_feature(biosample=self.biosample_2, phenopacket=self.phenopacket)) + self.phenotypic_feature_1 = m.PhenotypicFeature.objects.create( + **c.valid_phenotypic_feature(biosample=self.biosample_1)) + self.phenotypic_feature_2 = m.PhenotypicFeature.objects.create( + **c.valid_phenotypic_feature(biosample=self.biosample_2, phenopacket=self.phenopacket)) def test_phenotypic_feature(self): - phenotypic_feature_query = PhenotypicFeature.objects.filter( + phenotypic_feature_query = m.PhenotypicFeature.objects.filter( severity__label='Mild', pftype__label='Proptosis' ) - phenotypic_feature_2 = PhenotypicFeature.objects.filter(phenopacket__id='phenopacket_id:1') - self.assertEqual(PhenotypicFeature.objects.count(), 2) + phenotypic_feature_2 = m.PhenotypicFeature.objects.filter(phenopacket__id='phenopacket_id:1') + self.assertEqual(m.PhenotypicFeature.objects.count(), 2) self.assertEqual(phenotypic_feature_query.count(), 2) self.assertEqual(phenotypic_feature_2.count(), 1) @@ -79,24 +80,24 @@ def test_phenotypic_feature_str(self): class ProcedureTest(TestCase): def setUp(self): - self.procedure_1 = Procedure.objects.create(**VALID_PROCEDURE_1) - self.procedure_2 = Procedure.objects.create(**VALID_PROCEDURE_2) + self.procedure_1 = m.Procedure.objects.create(**c.VALID_PROCEDURE_1) + self.procedure_2 = m.Procedure.objects.create(**c.VALID_PROCEDURE_2) def test_procedure(self): - procedure_query_1 = Procedure.objects.filter( + procedure_query_1 = m.Procedure.objects.filter( body_site__label__icontains='arm' ) - procedure_query_2 = Procedure.objects.filter(code__id='NCIT:C28743') + procedure_query_2 = m.Procedure.objects.filter(code__id='NCIT:C28743') self.assertEqual(procedure_query_1.count(), 2) self.assertEqual(procedure_query_2.count(), 2) class HtsFileTest(TestCase): def setUp(self): - self.hts_file = HtsFile.objects.create(**VALID_HTS_FILE) + self.hts_file = m.HtsFile.objects.create(**c.VALID_HTS_FILE) def test_hts_file(self): - hts_file = HtsFile.objects.get(genome_assembly='GRCh38') + hts_file = m.HtsFile.objects.get(genome_assembly='GRCh38') self.assertEqual(hts_file.uri, 'https://data.example/genomes/germline_wgs.vcf.gz') def test_hts_file_str(self): @@ -105,13 +106,13 @@ def test_hts_file_str(self): class GeneTest(TestCase): def setUp(self): - self.gene_1 = Gene.objects.create(**VALID_GENE_1) + self.gene_1 = m.Gene.objects.create(**c.VALID_GENE_1) def test_gene(self): - gene_1 = Gene.objects.get(id='HGNC:347') + gene_1 = m.Gene.objects.get(id='HGNC:347') self.assertEqual(gene_1.symbol, 'ETF1') with self.assertRaises(IntegrityError): - Gene.objects.create(**DUPLICATE_GENE_2) + m.Gene.objects.create(**c.DUPLICATE_GENE_2) def test_gene_str(self): self.assertEqual(str(self.gene_1), "HGNC:347") @@ -119,10 +120,10 @@ def test_gene_str(self): class VariantTest(TestCase): def setUp(self): - self.variant = Variant.objects.create(**VALID_VARIANT_1) + self.variant = m.Variant.objects.create(**c.VALID_VARIANT_1) def test_variant(self): - variant_query = Variant.objects.filter(zygosity__id='NCBITaxon:9606') + variant_query = m.Variant.objects.filter(zygosity__id='NCBITaxon:9606') self.assertEqual(variant_query.count(), 1) def test_variant_str(self): @@ -131,10 +132,10 @@ def test_variant_str(self): class DiseaseTest(TestCase): def setUp(self): - self.disease_1 = Disease.objects.create(**VALID_DISEASE_1) + self.disease_1 = m.Disease.objects.create(**c.VALID_DISEASE_1) def test_disease(self): - disease_query = Disease.objects.filter(term__id='OMIM:164400') + disease_query = m.Disease.objects.filter(term__id='OMIM:164400') self.assertEqual(disease_query.count(), 1) def test_disease_str(self): @@ -143,21 +144,21 @@ def test_disease_str(self): class GenomicInterpretationTest(TestCase): def setUp(self): - self.gene = Gene.objects.create(**VALID_GENE_1) - self.variant = Variant.objects.create(**VALID_VARIANT_1) - self.genomic_interpretation = GenomicInterpretation.objects.create( - **valid_genomic_interpretation(self.gene, self.variant) + self.gene = m.Gene.objects.create(**c.VALID_GENE_1) + self.variant = m.Variant.objects.create(**c.VALID_VARIANT_1) + self.genomic_interpretation = m.GenomicInterpretation.objects.create( + **c.valid_genomic_interpretation(self.gene, self.variant) ) def test_genomic_interpretation(self): - genomic_interpretation_query = GenomicInterpretation.objects.filter( + genomic_interpretation_query = m.GenomicInterpretation.objects.filter( gene='HGNC:347') self.assertEqual(genomic_interpretation_query.count(), 1) - self.assertEqual(GenomicInterpretation.objects.count(), 1) + self.assertEqual(m.GenomicInterpretation.objects.count(), 1) def test_validation_gene_or_variant(self): with self.assertRaises(ValidationError): - GenomicInterpretation.objects.create(**valid_genomic_interpretation()).clean() + m.GenomicInterpretation.objects.create(**c.valid_genomic_interpretation()).clean() def test_genomic_interpretation_str(self): self.assertEqual(str(self.genomic_interpretation), str(self.genomic_interpretation.id)) @@ -165,17 +166,17 @@ def test_genomic_interpretation_str(self): class DiagnosisTest(TestCase): def setUp(self): - self.disease = Disease.objects.create(**VALID_DISEASE_1) + self.disease = m.Disease.objects.create(**c.VALID_DISEASE_1) - self.gene = Gene.objects.create(**VALID_GENE_1) - self.variant = Variant.objects.create(**VALID_VARIANT_1) - self.genomic_interpretation_1 = GenomicInterpretation.objects.create( - **valid_genomic_interpretation(self.gene, self.variant) + self.gene = m.Gene.objects.create(**c.VALID_GENE_1) + self.variant = m.Variant.objects.create(**c.VALID_VARIANT_1) + self.genomic_interpretation_1 = m.GenomicInterpretation.objects.create( + **c.valid_genomic_interpretation(self.gene, self.variant) ) - self.genomic_interpretation_2 = GenomicInterpretation.objects.create( - **valid_genomic_interpretation(self.gene) + self.genomic_interpretation_2 = m.GenomicInterpretation.objects.create( + **c.valid_genomic_interpretation(self.gene) ) - self.diagnosis = Diagnosis.objects.create(**valid_diagnosis( + self.diagnosis = m.Diagnosis.objects.create(**c.valid_diagnosis( self.disease)) self.diagnosis.genomic_interpretations.set([ self.genomic_interpretation_1, @@ -183,7 +184,7 @@ def setUp(self): ]) def test_diagnosis(self): - diagnosis = Diagnosis.objects.filter(disease__term__id='OMIM:164400') + diagnosis = m.Diagnosis.objects.filter(disease__term__id='OMIM:164400') self.assertEqual(diagnosis.count(), 1) def test_diagnosis_str(self): @@ -192,26 +193,26 @@ def test_diagnosis_str(self): class InterpretationTest(TestCase): def setUp(self): - self.disease = Disease.objects.create(**VALID_DISEASE_1) - self.diagnosis = Diagnosis.objects.create(**valid_diagnosis( + self.disease = m.Disease.objects.create(**c.VALID_DISEASE_1) + self.diagnosis = m.Diagnosis.objects.create(**c.valid_diagnosis( self.disease)) - self.meta_data_phenopacket = MetaData.objects.create(**VALID_META_DATA_1) - self.meta_data_interpretation = MetaData.objects.create(**VALID_META_DATA_2) + self.meta_data_phenopacket = m.MetaData.objects.create(**c.VALID_META_DATA_1) + self.meta_data_interpretation = m.MetaData.objects.create(**c.VALID_META_DATA_2) - self.individual = Individual.objects.create(**VALID_INDIVIDUAL_1) - self.phenopacket = Phenopacket.objects.create( + self.individual = m.Individual.objects.create(**c.VALID_INDIVIDUAL_1) + self.phenopacket = m.Phenopacket.objects.create( id="phenopacket_id:1", subject=self.individual, meta_data=self.meta_data_phenopacket, ) - self.interpretation = Interpretation.objects.create(**valid_interpretation( + self.interpretation = m.Interpretation.objects.create(**c.valid_interpretation( phenopacket=self.phenopacket, meta_data=self.meta_data_interpretation )) self.interpretation.diagnosis.set([self.diagnosis]) def test_interpretation(self): - interpretation_query = Interpretation.objects.filter( + interpretation_query = m.Interpretation.objects.filter( resolution_status='IN_PROGRESS' ) self.assertEqual(interpretation_query.count(), 1) @@ -220,30 +221,15 @@ def test_interpretation_str(self): self.assertEqual(str(self.interpretation), str(self.interpretation.id)) -class ResourceTest(TestCase): - def setUp(self): - self.resource_1 = Resource.objects.create(**VALID_RESOURCE_1) - self.resource_2 = Resource.objects.create(**VALID_RESOURCE_2) - - def test_resource(self): - self.assertEqual(Resource.objects.count(), 2) - with self.assertRaises(IntegrityError): - Resource.objects.create(**DUPLICATE_RESOURCE_3) - - def test_resource_str(self): - self.assertEqual(str(self.resource_1), "so") - self.assertEqual(str(self.resource_2), "hgnc") - - class MetaDataTest(TestCase): def setUp(self): - self.resource_1 = Resource.objects.create(**VALID_RESOURCE_1) - self.resource_2 = Resource.objects.create(**VALID_RESOURCE_2) - self.metadata = MetaData.objects.create(**VALID_META_DATA_2) + self.resource_1 = m.Resource.objects.create(**VALID_RESOURCE_1) + self.resource_2 = m.Resource.objects.create(**VALID_RESOURCE_2) + self.metadata = m.MetaData.objects.create(**c.VALID_META_DATA_2) self.metadata.resources.set([self.resource_1, self.resource_2]) def test_metadata(self): - metadata = MetaData.objects.get(created_by__icontains='ksenia') + metadata = m.MetaData.objects.get(created_by__icontains='ksenia') self.assertEqual(metadata.submitted_by, 'Ksenia Zaytseva') self.assertEqual(metadata.resources.count(), 2) diff --git a/chord_metadata_service/resources/__init__.py b/chord_metadata_service/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/chord_metadata_service/resources/admin.py b/chord_metadata_service/resources/admin.py new file mode 100644 index 000000000..a646858bd --- /dev/null +++ b/chord_metadata_service/resources/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Resource + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + pass diff --git a/chord_metadata_service/resources/api_views.py b/chord_metadata_service/resources/api_views.py new file mode 100644 index 000000000..4c80377bf --- /dev/null +++ b/chord_metadata_service/resources/api_views.py @@ -0,0 +1,23 @@ +from rest_framework import viewsets +from rest_framework.settings import api_settings + +from chord_metadata_service.restapi.api_renderers import PhenopacketsRenderer +from chord_metadata_service.restapi.pagination import LargeResultsSetPagination + +from .models import Resource +from .serializers import ResourceSerializer + + +class ResourceViewSet(viewsets.ModelViewSet): + """ + get: + Return a list of all existing resources + + post: + Create a new resource + + """ + queryset = Resource.objects.all().order_by("id") + serializer_class = ResourceSerializer + renderer_classes = (*api_settings.DEFAULT_RENDERER_CLASSES, PhenopacketsRenderer) + pagination_class = LargeResultsSetPagination diff --git a/chord_metadata_service/resources/apps.py b/chord_metadata_service/resources/apps.py new file mode 100644 index 000000000..2fbdc6314 --- /dev/null +++ b/chord_metadata_service/resources/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ResourcesConfig(AppConfig): + name = 'chord_metadata_service.resources' diff --git a/chord_metadata_service/resources/descriptions.py b/chord_metadata_service/resources/descriptions.py new file mode 100644 index 000000000..0814b73de --- /dev/null +++ b/chord_metadata_service/resources/descriptions.py @@ -0,0 +1,66 @@ +# Portions of this text copyright (c) 2019-2020 the Canadian Centre for Computational Genomics; licensed under the +# GNU Lesser General Public License version 3. + +# Portions of this text (c) 2019 Julius OB Jacobsen, Peter N Robinson, Christopher J Mungall; taken from the +# Phenopackets documentation: https://phenopackets-schema.readthedocs.io +# Licensed under the BSD 3-Clause License: +# BSD 3-Clause License +# +# Portions Copyright (c) 2018, PhenoPackets +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from chord_metadata_service.restapi.description_utils import EXTRA_PROPERTIES + + +__all__ = ["RESOURCE"] + + +RESOURCE = { + "description": "A description of an external resource used for referencing an object.", + "properties": { + "id": { + "description": "Unique researcher-specified identifier for the resource.", + "help": "For OBO ontologies, the value of this string MUST always be the official OBO ID, which is always " + "equivalent to the ID prefix in lower case. For other resources use the prefix in " + "identifiers.org." + }, + "name": { + "description": "Human-readable name for the resource.", + "help": "The full name of the resource or ontology referred to by the id element." + }, + "namespace_prefix": "Prefix for objects from this resource. In the case of ontology resources, this should be " + "the CURIE prefix.", + "url": "Resource URL. In the case of ontologies, this should be an OBO or OWL file. Other resources should " + "link to the official or top-level url.", + "version": "The version of the resource or ontology used to make the annotation.", + "iri_prefix": "The IRI prefix, when used with the namespace prefix and an object ID, should resolve the term " + "or object from the resource in question.", + **EXTRA_PROPERTIES + } +} diff --git a/chord_metadata_service/resources/migrations/0001_v1_0_0.py b/chord_metadata_service/resources/migrations/0001_v1_0_0.py new file mode 100644 index 000000000..e661f6a19 --- /dev/null +++ b/chord_metadata_service/resources/migrations/0001_v1_0_0.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.13 on 2020-07-06 14:55 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.CharField(help_text='For OBO ontologies, the value of this string MUST always be the official OBO ID, which is always equivalent to the ID prefix in lower case. For other resources use the prefix in identifiers.org.', max_length=200, primary_key=True, serialize=False)), + ('name', models.CharField(help_text='The full name of the resource or ontology referred to by the id element.', max_length=200)), + ('namespace_prefix', models.CharField(help_text='Prefix for objects from this resource. In the case of ontology resources, this should be the CURIE prefix.', max_length=200)), + ('url', models.URLField(help_text='Resource URL. In the case of ontologies, this should be an OBO or OWL file. Other resources should link to the official or top-level url.')), + ('version', models.CharField(help_text='The version of the resource or ontology used to make the annotation.', max_length=200)), + ('iri_prefix', models.URLField(help_text='The IRI prefix, when used with the namespace prefix and an object ID, should resolve the term or object from the resource in question.')), + ('extra_properties', django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Extra properties that are not supported by current schema.', null=True)), + ('created', models.DateTimeField(auto_now=True)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'unique_together': {('namespace_prefix', 'version')}, + }, + ), + ] diff --git a/chord_metadata_service/resources/migrations/__init__.py b/chord_metadata_service/resources/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/chord_metadata_service/resources/models.py b/chord_metadata_service/resources/models.py new file mode 100644 index 000000000..ef9b64a0d --- /dev/null +++ b/chord_metadata_service/resources/models.py @@ -0,0 +1,49 @@ +from django.contrib.postgres.fields import JSONField +from django.core.exceptions import ValidationError +from django.db import models + +from chord_metadata_service.restapi.description_utils import rec_help + +from . import descriptions as d + + +class Resource(models.Model): + """ + Class to represent a description of an external resource + used for referencing an object + + FHIR: CodeSystem + """ + + class Meta: + unique_together = (("namespace_prefix", "version"),) + + # resource_id e.g. "id": "uniprot:2019_07" + id = models.CharField(primary_key=True, max_length=200, help_text=rec_help(d.RESOURCE, "id")) + name = models.CharField(max_length=200, help_text=rec_help(d.RESOURCE, "name")) + namespace_prefix = models.CharField(max_length=200, help_text=rec_help(d.RESOURCE, "namespace_prefix")) + url = models.URLField(max_length=200, help_text=rec_help(d.RESOURCE, "url")) + version = models.CharField(max_length=200, help_text=rec_help(d.RESOURCE, "version")) + iri_prefix = models.URLField(max_length=200, help_text=rec_help(d.RESOURCE, "iri_prefix")) + extra_properties = JSONField(blank=True, null=True, help_text=rec_help(d.RESOURCE, "extra_properties")) + + created = models.DateTimeField(auto_now=True) + updated = models.DateTimeField(auto_now_add=True) + + def clean(self): + # For phenopackets compliance, we need to have a string identifier. Django does not allow compound keys, we + # ideally want to identify resources by the pair (namespace_prefix, version). In this case, we hack this by + # enforcing that id == (namespace_prefix, version). In the case of an unspecified version, enforce + # id == namespace_prefix. + if (self.version and self.id != f"{self.namespace_prefix}:{self.version}") or \ + (not self.version and self.id != self.namespace_prefix): + raise ValidationError({ + "id": [ValidationError("Resource ID must match the format 'namespace_prefix:version'")], + }) + + def save(self, *args, **kwargs): + self.clean() + return super().save(*args, **kwargs) + + def __str__(self): + return str(self.id) diff --git a/chord_metadata_service/resources/schemas.py b/chord_metadata_service/resources/schemas.py new file mode 100644 index 000000000..bb12d2134 --- /dev/null +++ b/chord_metadata_service/resources/schemas.py @@ -0,0 +1,35 @@ +from chord_metadata_service.restapi.description_utils import describe_schema +from chord_metadata_service.restapi.schemas import EXTRA_PROPERTIES_SCHEMA + +from . import descriptions + + +__all__ = ["RESOURCE_SCHEMA"] + + +RESOURCE_SCHEMA = describe_schema({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", # TODO + "properties": { + "id": { + "type": "string", + }, + "name": { + "type": "string", + }, + "namespace_prefix": { + "type": "string", + }, + "url": { + "type": "string", + }, + "version": { + "type": "string", + }, + "iri_prefix": { + "type": "string", + }, + "extra_properties": EXTRA_PROPERTIES_SCHEMA + }, + "required": ["id", "name", "namespace_prefix", "url", "version", "iri_prefix"], +}, descriptions.RESOURCE) diff --git a/chord_metadata_service/resources/search_schemas.py b/chord_metadata_service/resources/search_schemas.py new file mode 100644 index 000000000..2dd69099a --- /dev/null +++ b/chord_metadata_service/resources/search_schemas.py @@ -0,0 +1,41 @@ +from chord_metadata_service.restapi.schema_utils import ( + search_optional_str, + tag_schema_with_search_properties, +) + +from . import schemas + + +__all__ = ["RESOURCE_SEARCH_SCHEMA"] + + +RESOURCE_SEARCH_SCHEMA = tag_schema_with_search_properties(schemas.RESOURCE_SCHEMA, { + "properties": { + "id": { + "search": search_optional_str(0) + }, + "name": { + "search": search_optional_str(1, multiple=True) + }, + "namespace_prefix": { + "search": search_optional_str(2, multiple=True) + }, + "url": { + "search": search_optional_str(3, multiple=True) + }, + "version": { + "search": search_optional_str(4, multiple=True) + }, + "iri_prefix": { + "search": search_optional_str(5, multiple=True) + } + }, + "search": { + "database": { + "relationship": { + "type": "MANY_TO_ONE", # TODO: Only in some cases - phenopacket + "foreign_key": "resource_id" # TODO: No hard-code, from M2M + } + } + } +}) diff --git a/chord_metadata_service/resources/serializers.py b/chord_metadata_service/resources/serializers.py new file mode 100644 index 000000000..d761c379f --- /dev/null +++ b/chord_metadata_service/resources/serializers.py @@ -0,0 +1,12 @@ +from chord_metadata_service.restapi.serializers import GenericSerializer + +from .models import Resource + + +__all__ = ["ResourceSerializer"] + + +class ResourceSerializer(GenericSerializer): + class Meta: + model = Resource + fields = '__all__' diff --git a/chord_metadata_service/resources/tests/__init__.py b/chord_metadata_service/resources/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/chord_metadata_service/resources/tests/constants.py b/chord_metadata_service/resources/tests/constants.py new file mode 100644 index 000000000..0fb7f2dba --- /dev/null +++ b/chord_metadata_service/resources/tests/constants.py @@ -0,0 +1,25 @@ +VALID_RESOURCE_1 = { + "id": "SO:2015-11-24", + "name": "Sequence types and features", + "url": "http://purl.obolibrary.org/obo/so.owl", + "version": "2015-11-24", + "namespace_prefix": "SO", + "iri_prefix": "http://purl.obolibrary.org/obo/SO_" +} + +VALID_RESOURCE_2 = { + "id": "HGNC:2019-08-08", + "name": "HUGO Gene Nomenclature Committee", + "url": "https://www.genenames.org", + "version": "2019-08-08", + "namespace_prefix": "HGNC", + "iri_prefix": "https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/" +} + +DUPLICATE_RESOURCE_3 = { + "id": "HGNC:2019-08-08", + "name": "HUGO Gene Nomenclature Committee", + "url": "https://www.genenames.org", + "version": "2019-08-08", + "namespace_prefix": "HGNC" +} diff --git a/chord_metadata_service/resources/tests/test_api.py b/chord_metadata_service/resources/tests/test_api.py new file mode 100644 index 000000000..b6ca1c679 --- /dev/null +++ b/chord_metadata_service/resources/tests/test_api.py @@ -0,0 +1,23 @@ +from rest_framework import status +from rest_framework.test import APITestCase +from chord_metadata_service.restapi.tests.utils import get_response + +from ..models import Resource +from ..serializers import ResourceSerializer +from .constants import VALID_RESOURCE_2, DUPLICATE_RESOURCE_3 + + +class CreateResourceTest(APITestCase): + + def setUp(self): + self.resource = VALID_RESOURCE_2 + self.duplicate_resource = DUPLICATE_RESOURCE_3 + + def test_resource(self): + response = get_response('resource-list', self.resource) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Resource.objects.count(), 1) + + def test_serializer(self): + serializer = ResourceSerializer(data=self.resource) + self.assertEqual(serializer.is_valid(), True) diff --git a/chord_metadata_service/resources/tests/test_models.py b/chord_metadata_service/resources/tests/test_models.py new file mode 100644 index 000000000..b64ada380 --- /dev/null +++ b/chord_metadata_service/resources/tests/test_models.py @@ -0,0 +1,36 @@ +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from ..models import Resource +from .constants import VALID_RESOURCE_1, VALID_RESOURCE_2, DUPLICATE_RESOURCE_3 + + +class ResourceTest(TestCase): + """ + Test class for Resource model. + """ + + def setUp(self): + self.resource_1 = Resource.objects.create(**VALID_RESOURCE_1) + self.resource_2 = Resource.objects.create(**VALID_RESOURCE_2) + + def test_resource(self): + self.assertEqual(Resource.objects.count(), 2) + with self.assertRaises(IntegrityError): + Resource.objects.create(**DUPLICATE_RESOURCE_3) + + def test_resource_str(self): + self.assertEqual(str(self.resource_1), f"{self.resource_1.namespace_prefix}:{self.resource_1.version}") + self.assertEqual(str(self.resource_2), f"{self.resource_2.namespace_prefix}:{self.resource_2.version}") + + def test_invalid_id(self): + with self.assertRaises(ValidationError): + Resource.objects.create( + id="SO", + name="Sequence types and features", + namespace_prefix="SO", + url="https://raw.githubusercontent.com/The-Sequence-Ontology/SO-Ontologies/v3.1/so.owl", + version="3.1", + iri_prefix="http://purl.obolibrary.org/obo/SO_", + ) diff --git a/chord_metadata_service/resources/utils.py b/chord_metadata_service/resources/utils.py new file mode 100644 index 000000000..648a41cb2 --- /dev/null +++ b/chord_metadata_service/resources/utils.py @@ -0,0 +1,7 @@ +__all__ = ["make_resource_id"] + + +def make_resource_id(namespace_prefix: str, version: str = "") -> str: + namespace_prefix = namespace_prefix.strip() + version = version.strip() + return namespace_prefix if not version else f"{namespace_prefix}:{version}" diff --git a/chord_metadata_service/restapi/api_renderers.py b/chord_metadata_service/restapi/api_renderers.py index 8d0fa9aa2..97a7efe97 100644 --- a/chord_metadata_service/restapi/api_renderers.py +++ b/chord_metadata_service/restapi/api_renderers.py @@ -1,12 +1,13 @@ -from rest_framework.renderers import JSONRenderer +import json from djangorestframework_camel_case.render import CamelCaseJSONRenderer -from .jsonld_utils import dataset_to_jsonld from rdflib import Graph -import json from rdflib.plugin import register, Serializer +from rest_framework.renderers import JSONRenderer +from uuid import UUID + +from .jsonld_utils import dataset_to_jsonld register('json-ld', Serializer, 'rdflib_jsonld.serializer', 'JsonLDSerializer') -from uuid import UUID class UUIDEncoder(json.JSONEncoder): @@ -31,11 +32,7 @@ def render(self, data, media_type=None, renderer_context=None): 'class_converter', 'objects' ) if 'results' in data: - final_data = {} - final_data[fhir_datatype_plural] = [] - for item in data.get('results'): - item_data = class_converter(item) - final_data[fhir_datatype_plural].append(item_data) + final_data = {fhir_datatype_plural: [class_converter(item) for item in data['results']]} else: final_data = class_converter(data) return super(FHIRRenderer, self).render(final_data, media_type, renderer_context) @@ -55,11 +52,7 @@ class JSONLDDatasetRenderer(PhenopacketsRenderer): def render(self, data, media_type=None, renderer_context=None): if 'results' in data: - json_obj = {} - json_obj['results'] = [] - for item in data['results']: - dataset_jsonld = dataset_to_jsonld(item) - json_obj['results'].append(dataset_jsonld) + json_obj = {'results': [dataset_to_jsonld(item) for item in data['results']]} else: json_obj = dataset_to_jsonld(data) diff --git a/chord_metadata_service/restapi/apps.py b/chord_metadata_service/restapi/apps.py index a0a4de169..6ab8d482d 100644 --- a/chord_metadata_service/restapi/apps.py +++ b/chord_metadata_service/restapi/apps.py @@ -2,4 +2,4 @@ class RestapiConfig(AppConfig): - name = 'restapi' + name = 'chord_metadata_service.restapi' diff --git a/chord_metadata_service/restapi/descriptions.py b/chord_metadata_service/restapi/descriptions.py new file mode 100644 index 000000000..c2fd08c15 --- /dev/null +++ b/chord_metadata_service/restapi/descriptions.py @@ -0,0 +1,20 @@ +AGE = { + "description": "An ISO8601 duration string (e.g. P40Y10M05D for 40 years, 10 months, 5 days) representing an age " + "of a subject.", + "help": "Age of a subject." +} + +AGE_RANGE = { + "description": "Age range of a subject (e.g. when a subject's age falls into a bin.)", + "properties": { + "start": "An ISO8601 duration string representing the start of the age range bin.", + "end": "An ISO8601 duration string representing the end of the age range bin." + } +} + +AGE_NESTED = { + "description": AGE["description"], + "properties": { + "age": AGE + } +} diff --git a/chord_metadata_service/restapi/fhir_ingest.py b/chord_metadata_service/restapi/fhir_ingest.py new file mode 100644 index 000000000..f19a7c155 --- /dev/null +++ b/chord_metadata_service/restapi/fhir_ingest.py @@ -0,0 +1,148 @@ +import uuid +import jsonschema +import logging + +from django.core.exceptions import ValidationError +from typing import Dict + +from .schemas import FHIR_BUNDLE_SCHEMA +from .fhir_utils import ( + patient_to_individual, + observation_to_phenotypic_feature, + condition_to_disease, + specimen_to_biosample +) +from chord_metadata_service.chord.models import Table +from chord_metadata_service.patients.models import Individual +from chord_metadata_service.phenopackets.models import ( + Biosample, + Disease, + MetaData, + Phenopacket, + PhenotypicFeature, + Procedure, +) + + +logger = logging.getLogger("fhir_ingest") +logger.setLevel(logging.INFO) + + +def _parse_reference(ref): + """ FHIR test data has reference object in a format "ResourceType/uuid" """ + return ref.split('/')[-1] + + +def check_schema(schema, obj, additional_info=None): + """ Validates schema and catches errors. """ + try: + jsonschema.validate(obj, schema) + except jsonschema.exceptions.ValidationError: + v = jsonschema.Draft7Validator(schema) + errors = [e for e in v.iter_errors(obj)] + error_messages = [ + f"{i} validation error {'.'.join(str(v) for v in error.path)}: {error.message}" + for i, error in enumerate(errors, 1) + ] + raise ValidationError(f"{additional_info + ' ' if additional_info else None}errors: {error_messages}") + + +def ingest_patients(patients_data, table_id, created_by): + """ Takes FHIR Bundle containing Patient resources. """ + # check if Patients data follows FHIR Bundle schema + check_schema(FHIR_BUNDLE_SCHEMA, patients_data, 'patients data') + + phenopacket_ids = {} + for item in patients_data["entry"]: + individual_data = patient_to_individual(item["resource"]) + individual, _ = Individual.objects.get_or_create(**individual_data) + # create metadata for Phenopacket + meta_data_obj, _ = MetaData.objects.get_or_create( + created_by=created_by, + phenopacket_schema_version="1.0.0-RC3", + external_references=[] + ) + # create new phenopacket for each individual + phenopacket_ids[individual.id] = str(uuid.uuid4()) + phenopacket = Phenopacket.objects.create( + id=phenopacket_ids[individual.id], + subject=individual, + meta_data=meta_data_obj, + table=Table.objects.get(ownership_record_id=table_id) + ) + logger.info(f'Phenopacket {phenopacket.id} created') + + return phenopacket_ids + + +def ingest_observations(phenopacket_ids: Dict[str, str], observations_data): + """ Takes FHIR Bundle containing Observation resources. """ + # check if Observations data follows FHIR Bundle schema + check_schema(FHIR_BUNDLE_SCHEMA, observations_data, 'observations data') + + for item in observations_data["entry"]: + phenotypic_feature_data = observation_to_phenotypic_feature(item["resource"]) + + # Observation must have a subject + try: + item["resource"]["subject"] + except KeyError: + raise KeyError(f"Observation {item['resource']['id']} doesn't have a subject.") + + subject = _parse_reference(item["resource"]["subject"]["reference"]) + phenotypic_feature, _ = PhenotypicFeature.objects.get_or_create( + phenopacket=Phenopacket.objects.get(id=phenopacket_ids[subject]), + **phenotypic_feature_data + ) + + logger.info(f'PhenotypicFeature {phenotypic_feature.id} created') + + +def ingest_conditions(phenopacket_ids: Dict[str, str], conditions_data): + """ Takes FHIR Bundle containing Condition resources. """ + # check if Conditions data follows FHIR Bundle schema + check_schema(FHIR_BUNDLE_SCHEMA, conditions_data, 'conditions data') + + for item in conditions_data["entry"]: + disease_data = condition_to_disease(item["resource"]) + disease = Disease.objects.create(**disease_data) + + # Condition must have a subject + try: + item["resource"]["subject"] + except KeyError: + raise KeyError(f"Condition {item['resource']['id']} doesn't have a subject.") + + subject = _parse_reference(item["resource"]["subject"]["reference"]) + + phenopacket = Phenopacket.objects.get(id=phenopacket_ids[subject]) + phenopacket.diseases.add(disease) + + logger.info(f'Disease {disease.id} created') + + +def ingest_specimens(phenopacket_ids: Dict[str, str], specimens_data): + """ Takes FHIR Bundle containing Specimen resources. """ + # check if Specimens data follows FHIR Bundle schema + check_schema(FHIR_BUNDLE_SCHEMA, specimens_data, 'specimens data') + + for item in specimens_data["entry"]: + biosample_data = specimen_to_biosample(item["resource"]) + procedure, _ = Procedure.objects.get_or_create(**biosample_data["procedure"]) + + # Specimen must have a subject + if not biosample_data.get("individual"): + raise KeyError(f"Specimen {item['resource']['id']} doesn't have a subject.") + + individual_id = _parse_reference(biosample_data["individual"]) + biosample, _ = Biosample.objects.get_or_create( + id=biosample_data["id"], + procedure=procedure, + individual=Individual.objects.get(id=individual_id), + sampled_tissue=biosample_data["sampled_tissue"] + ) + + phenopacket = Phenopacket.objects.get(id=phenopacket_ids[individual_id]) + phenopacket.biosamples.add(biosample) + + logger.info(f'Biosample {biosample.id} created') diff --git a/chord_metadata_service/restapi/fhir_utils.py b/chord_metadata_service/restapi/fhir_utils.py index 2894dd888..9498c597c 100644 --- a/chord_metadata_service/restapi/fhir_utils.py +++ b/chord_metadata_service/restapi/fhir_utils.py @@ -1,15 +1,29 @@ from datetime import datetime +from fhirclient.models import ( + observation as obs, + patient as p, + extension, + age, + coding as c, + codeableconcept, + specimen as s, + identifier as fhir_indentifier, + annotation as a, + range as range_, + quantity, + fhirreference, + documentreference, + attachment, + fhirdate, + condition as cond, + composition as comp, +) + from chord_metadata_service.restapi.semantic_mappings.phenopackets_on_fhir_mapping import PHENOPACKETS_ON_FHIR_MAPPING from chord_metadata_service.restapi.semantic_mappings.hl7_genomics_mapping import HL7_GENOMICS_MAPPING -from fhirclient.models import (observation as obs, patient as p, extension, age, coding as c, - codeableconcept, specimen as s, identifier as fhir_indentifier, - annotation as a, range, quantity, fhirreference, - documentreference, attachment, fhirdate, condition as cond, - composition as comp - ) -##################### Generic FHIR conversion functions ##################### +# ===================== Generic FHIR conversion functions ===================== def fhir_coding_util(obj): @@ -20,7 +34,7 @@ def fhir_coding_util(obj): coding.code = obj['id'] if 'system' in obj.keys(): coding.system = obj['system'] - return coding + return coding def fhir_codeable_concept(obj): @@ -57,7 +71,7 @@ def fhir_age(obj, mapping, field): age_extension.url = mapping if "start" in obj[field]: # Is an age range - age_extension.valueRange = range.Range() + age_extension.valueRange = range_.Range() age_extension.valueRange.low = quantity.Quantity() age_extension.valueRange.low.unit = obj[field]['start']['age'] age_extension.valueRange.high = quantity.Quantity() @@ -81,7 +95,7 @@ def check_disease_onset(disease): return False -##################### Class-based FHIR conversion functions ##################### +# ============== Phenopackets to FHIR class conversion functions ============== def fhir_patient(obj): @@ -141,7 +155,7 @@ def fhir_observation(obj): if 'description' in obj.keys(): observation.note = [] annotation = a.Annotation() - annotation.text = obj.get('description', None) + annotation.text = obj.get('description') observation.note.append(annotation) observation.code = fhir_codeable_concept(obj['type']) # required by FHIR specs but omitted by phenopackets, for now set for unknown @@ -158,8 +172,8 @@ def fhir_observation(obj): concept_extensions = codeable_concepts_fields( ['severity', 'modifier', 'onset'], 'phenotypic_feature', obj ) - for c in concept_extensions: - observation.extension.append(c) + for ce in concept_extensions: + observation.extension.append(ce) if 'evidence' in obj.keys(): evidence = extension.Extension() evidence.url = PHENOPACKETS_ON_FHIR_MAPPING['phenotypic_feature']['evidence']['url'] @@ -283,10 +297,10 @@ def fhir_obs_component_region_studied(obj): component = obs.ObservationComponent() component.code = fhir_codeable_concept(HL7_GENOMICS_MAPPING['gene']['gene_studied_code']) component.valueCodeableConcept = fhir_codeable_concept({ - "id": obj['id'], - "label": obj['symbol'], - "system": HL7_GENOMICS_MAPPING['gene']['gene_studied_value']['system'] - }) + "id": obj['id'], + "label": obj['symbol'], + "system": HL7_GENOMICS_MAPPING['gene']['gene_studied_value']['system'] + }) return component.as_json() @@ -384,3 +398,117 @@ def fhir_composition(obj): composition.section.append(section_content) return composition.as_json() + + +# ============== FHIR to Phenopackets class conversion functions ============== +# There is no guide to map FHIR to Phenopackets + +# SNOMED term to use as placeholder when collection method is not present in Specimen +procedure_not_assigned = { + "code": { + "id": "SNOMED:42630001", + "label": "Procedure code not assigned", + } +} + + +def patient_to_individual(obj): + """ FHIR Patient to Individual. """ + + patient = p.Patient(obj) + individual = { + "id": patient.id + } + if patient.identifier: + individual["alternate_ids"] = [alternate_id.value for alternate_id in patient.identifier] + gender_to_sex = { + "male": "MALE", + "female": "FEMALE", + "other": "OTHER_SEX", + "unknown": "UNKNOWN_SEX" + } + if patient.gender: + individual["sex"] = gender_to_sex[patient.gender] + if patient.birthDate: + individual["date_of_birth"] = patient.birthDate.isostring + if patient.active: + individual["active"] = patient.active + if patient.deceasedBoolean: + individual["deceased"] = patient.deceasedBoolean + individual["extra_properties"] = patient.as_json() + return individual + + +def observation_to_phenotypic_feature(obj): + """ FHIR Observation to Phenopackets PhenotypicFeature. """ + + observation = obs.Observation(obj) + codeable_concept = observation.code # CodeableConcept + phenotypic_feature = { + # id is an integer AutoField, store legacy id in description + # TODO change + "description": observation.id, + "pftype": { + "id": f"{codeable_concept.coding[0].system}:{codeable_concept.coding[0].code}", + "label": codeable_concept.coding[0].display + # TODO collect system info in metadata + } + } + if observation.specimen: # FK to Biosample + phenotypic_feature["biosample"] = observation.specimen.reference + phenotypic_feature["extra_properties"] = observation.as_json() + return phenotypic_feature + + +def condition_to_disease(obj): + """ FHIR Condition to Phenopackets Disease. """ + + condition = cond.Condition(obj) + codeable_concept = condition.code # CodeableConcept + disease = { + "term": { + # id is an integer AutoField, legacy id can be a string + # "id": condition.id, + "id": f"{codeable_concept.coding[0].system}:{codeable_concept.coding[0].code}", + "label": codeable_concept.coding[0].display + # TODO collect system info in metadata + }, + "extra_properties": condition.as_json() + } + # condition.stage.type is only in FHIR 4.0.0 version + return disease + + +def specimen_to_biosample(obj): + """ FHIR Specimen to Phenopackets Biosample. """ + + specimen = s.Specimen(obj) + biosample = { + "id": specimen.id + } + if specimen.subject: + biosample["individual"] = specimen.subject.reference + if specimen.type: + codeable_concept = specimen.type # CodeableConcept + biosample["sampled_tissue"] = { + "id": f"{codeable_concept.coding[0].system}:{codeable_concept.coding[0].code}", + "label": codeable_concept.coding[0].display + # TODO collect system info in metadata + } + if specimen.collection: + method_codeable_concept = specimen.collection.method + bodysite_codeable_concept = specimen.collection.bodySite + biosample["procedure"] = { + "code": { + "id": f"{method_codeable_concept.coding[0].system}:{method_codeable_concept.coding[0].code}", + "label": method_codeable_concept.coding[0].display + }, + "body_site": { + "id": f"{bodysite_codeable_concept.coding[0].system}:{bodysite_codeable_concept.coding[0].code}", + "label": bodysite_codeable_concept.coding[0].display + } + } + else: + biosample["procedure"] = procedure_not_assigned + biosample["extra_properties"] = specimen.as_json() + return biosample diff --git a/chord_metadata_service/restapi/jsonld_utils.py b/chord_metadata_service/restapi/jsonld_utils.py index bedddc7f5..3527d3643 100644 --- a/chord_metadata_service/restapi/jsonld_utils.py +++ b/chord_metadata_service/restapi/jsonld_utils.py @@ -1,9 +1,9 @@ -from .semantic_mappings.context import * +from .semantic_mappings.context import CONTEXT_TYPES, CONTEXT # utils to convert dataset json to json-ld -def obj_to_jsonld(obj, mapping) -> dict: +def obj_to_jsonld(obj: dict, mapping: str) -> dict: obj['@type'] = CONTEXT_TYPES[mapping]['type'] return obj @@ -34,38 +34,35 @@ def extra_properties_to_jsonld(extra_properties) -> list: return extra_properties +def _obj_identifiers_to_jsonld(obj): + if "identifier" in obj: + obj_to_jsonld(obj['identifier'], 'identifier') + if "alternate_identifiers" in obj: + for alt_id in obj["alternate_identifiers"]: + obj_to_jsonld(alt_id, "alternate_identifiers") + if "related_identifiers" in obj: + for rel_id in obj["related_identifiers"]: + obj_to_jsonld(rel_id, "related_identifiers") + + def spatial_coverage_to_jsonld(spatial_coverage) -> list: for sc in spatial_coverage: obj_to_jsonld(sc, 'spatial_coverage') - if 'identifier' in sc.keys(): - obj_to_jsonld(sc['identifier'], 'identifier') - if 'alternate_identifiers' in sc.keys(): - for alt_id in sc['alternate_identifiers']: - obj_to_jsonld(alt_id, 'alternate_identifiers') - if 'related_identifiers' in sc.keys(): - for rel_id in sc['related_identifiers']: - obj_to_jsonld(rel_id, 'related_identifiers') + _obj_identifiers_to_jsonld(sc) return spatial_coverage def distributions_to_jsonld(distributions) -> list: for distribution in distributions: obj_to_jsonld(distribution, 'distributions') - if 'identifier' in distribution.keys(): - obj_to_jsonld(distribution['identifier'], 'identifier') - if 'alternate_identifiers' in distribution.keys(): - for alt_id in distribution['alternate_identifiers']: - obj_to_jsonld(alt_id, 'alternate_identifiers') - if 'related_identifiers' in distribution.keys(): - for rel_id in distribution['related_identifiers']: - obj_to_jsonld(rel_id, 'related_identifiers') - if 'stored_in' in distribution.keys(): + _obj_identifiers_to_jsonld(distribution) + if 'stored_in' in distribution: obj_to_jsonld(distribution['stored_in'], 'stored_in') if 'dates' in distribution.keys(): dates_to_jsonld(distribution['dates']) - if 'licenses' in distribution.keys(): - for license in distribution['liceses']: - obj_to_jsonld(license, 'licenses') + if 'licenses' in distribution: + for license_ in distribution['liceses']: + obj_to_jsonld(license_, 'licenses') # access return distributions @@ -91,8 +88,8 @@ def dataset_to_jsonld(dataset): if 'information' in t.keys(): obj_to_jsonld(t['information'], 'annotation') if 'licenses' in dataset.keys(): - for license in dataset['licenses']: - obj_to_jsonld(license, 'licenses') + for license_ in dataset['licenses']: + obj_to_jsonld(license_, 'licenses') if 'extra_properties' in dataset.keys(): extra_properties_to_jsonld(dataset['extra_properties']) if 'alternate_identifiers' in dataset.keys(): diff --git a/chord_metadata_service/restapi/schema_utils.py b/chord_metadata_service/restapi/schema_utils.py new file mode 100644 index 000000000..edc9e1337 --- /dev/null +++ b/chord_metadata_service/restapi/schema_utils.py @@ -0,0 +1,92 @@ +from typing import List, Optional + +__all__ = [ + "search_optional_eq", + "search_optional_str", + "tag_schema_with_search_properties", + "customize_schema", + "schema_list", +] + + +def _searchable_field(operations: List[str], order: int, queryable: str = "all", multiple: bool = False): + return { + "operations": operations, + "queryable": queryable, + "canNegate": True, + "required": False, + "order": order, + "type": "multiple" if multiple else "single" + } + + +def search_optional_eq(order: int, queryable: str = "all"): + return _searchable_field(["eq"], order, queryable, multiple=False) + + +def search_optional_str(order: int, queryable: str = "all", multiple: bool = False): + return _searchable_field(["eq", "co"], order, queryable, multiple) + + +def tag_schema_with_search_properties(schema, search_descriptions: Optional[dict]): + if not isinstance(schema, dict) or not search_descriptions: + return schema + + if "type" not in schema: + # TODO: handle oneOf, allOf, etc. + return schema + + schema_with_search = { + **schema, + **({"search": search_descriptions["search"]} if "search" in search_descriptions else {}), + } + + if schema["type"] == "object": + return { + **schema_with_search, + **({ + "properties": { + p: tag_schema_with_search_properties(s, search_descriptions["properties"].get(p)) + for p, s in schema["properties"].items() + } + } if "properties" in schema and "properties" in search_descriptions else {}) + } + + if schema["type"] == "array": + return { + **schema_with_search, + **({"items": tag_schema_with_search_properties(schema["items"], search_descriptions["items"])} + if "items" in schema and "items" in search_descriptions else {}) + } + + return schema_with_search + + +def customize_schema(first_typeof: dict, second_typeof: dict, first_property: str, second_property: str, + schema_id: str = None, title: str = None, description: str = None, + additional_properties: bool = False, required=None) -> dict: + return { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": schema_id, + "title": title, + "description": description, + "type": "object", + "properties": { + first_property: first_typeof, + second_property: second_typeof + }, + "required": required or [], + "additionalProperties": additional_properties + } + + +def schema_list(schema): + """ Schema to validate JSON array values. """ + + return { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chord_metadata_service:schema_list", + "title": "Schema list", + "type": "array", + "items": schema + } diff --git a/chord_metadata_service/restapi/schemas.py b/chord_metadata_service/restapi/schemas.py index 96935c9a9..ec6f9b2c2 100644 --- a/chord_metadata_service/restapi/schemas.py +++ b/chord_metadata_service/restapi/schemas.py @@ -1,144 +1,51 @@ +from . import descriptions +from .description_utils import describe_schema, EXTRA_PROPERTIES, ONTOLOGY_CLASS as ONTOLOGY_CLASS_DESC + # Individual schemas for validation of JSONField values -################################ Phenopackets based schemas ################################ +__all__ = [ + "ONTOLOGY_CLASS", + "ONTOLOGY_CLASS_LIST", + "KEY_VALUE_OBJECT", + "AGE_STRING", + "AGE", + "AGE_RANGE", + "AGE_OR_AGE_RANGE", + "EXTRA_PROPERTIES_SCHEMA", + "FHIR_BUNDLE_SCHEMA", +] -ALLELE_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:allele_schema", - "title": "Allele schema", - "description": "Variant allele types", - "type": "object", - "properties": { - "id": {"type": "string"}, - - "hgvs": {"type": "string"}, - "genome_assembly": {"type": "string"}, - "chr": {"type": "string"}, - "pos": {"type": "integer"}, - "re": {"type": "string"}, - "alt": {"type": "string"}, - "info": {"type": "string"}, +# ======================== Phenopackets based schemas ========================= - "seq_id": {"type": "string"}, - "position": {"type": "integer"}, - "deleted_sequence": {"type": "string"}, - "inserted_sequence": {"type": "string"}, - "iscn": {"type": "string"} - }, - "additionalProperties": False, - "oneOf": [ - { - "required": ["hgvs"] - }, - { - "required": ["genome_assembly"] - }, - { - "required": ["seq_id"] - }, - { - "required": ["iscn"] - } - - ], - "dependencies": { - "genome_assembly": ["chr", "pos", "re", "alt", "info"], - "seq_id": ["position", "deleted_sequence", "inserted_sequence"] - } -} - -UPDATE_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:update_schema", - "title": "Updates schema", - "description": "Schema to check incoming updates format", - "type": "object", - "properties": { - "timestamp": {"type": "string", "format": "date-time", - "description": "ISO8601 UTC timestamp at which this record was updated."}, - "updated_by": {"type": "string", "description": "Who updated the phenopacket"}, - "comment": {"type": "string", "description": "Comment about updates or reasons for an update."} - }, - "additionalProperties": False, - "required": ["timestamp", "comment"] -} - -ONTOLOGY_CLASS = { +ONTOLOGY_CLASS = describe_schema({ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "chord_metadata_service:ontology_class_schema", "title": "Ontology class schema", - "description": "todo", "type": "object", "properties": { - "id": {"type": "string", "description": "CURIE style identifier."}, - "label": {"type": "string", "description": "Human-readable class name."} + "id": {"type": "string"}, + "label": {"type": "string"} }, "additionalProperties": False, "required": ["id", "label"] -} +}, ONTOLOGY_CLASS_DESC) ONTOLOGY_CLASS_LIST = { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "ONTOLOGY_CLASS_LIST", + "$id": "chord_metadata_service:ontology_class_list_schema", "title": "Ontology class list", "description": "Ontology class list", "type": "array", "items": ONTOLOGY_CLASS, } -EXTERNAL_REFERENCE = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:external_reference_schema", - "title": "External reference schema", - "description": "The schema encodes information about an external reference.", - "type": "object", - "properties": { - "id": {"type": "string", "description": "An application specific identifier."}, - "description": {"type": "string", "description": "An application specific description."} - }, - "additionalProperties": False, - "required": ["id"] -} - -EVIDENCE = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:evidence_schema", - "title": "Evidence schema", - "description": "The schema represents the evidence for an assertion such as an observation of a PhenotypicFeature.", - "type": "object", - "properties": { - "evidence_code": { - "type": "object", - "description": "An ontology class that represents the evidence type.", - "properties": { - "id": {"type": "string", "description": "CURIE style identifier."}, - "label": {"type": "string", "description": "Human-readable class name."} - }, - "additionalProperties": False, - "required": ["id", "label"] - }, - "reference": { - "type": "object", - "description": "Representation of the source of the evidence.", - "properties": { - "id": {"type": "string", "description": "An application specific identifier."}, - "description": {"type": "string", "description": "An application specific description."} - }, - "additionalProperties": False, - "required": ["id"] - } - }, - "additionalProperties": False, - "required": ["evidence_code"] -} - KEY_VALUE_OBJECT = { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "KEY_VALUE_OBJECT", + "$id": "chord_metadata_service:key_value_object_schema", "title": "Key-value object", "description": "The schema represents a key-value object.", "type": "object", @@ -148,35 +55,39 @@ "additionalProperties": False } +EXTRA_PROPERTIES_SCHEMA = describe_schema({ + "type": "object" +}, EXTRA_PROPERTIES) -AGE_STRING = {"type": "string", "description": "An ISO8601 string represent age."} -AGE = { +AGE_STRING = describe_schema({"type": "string"}, descriptions.AGE) + +AGE = describe_schema({ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "chord_metadata_service:age_schema", "title": "Age schema", - "description": "An age of a subject.", "type": "object", "properties": { "age": AGE_STRING }, "additionalProperties": False, "required": ["age"] -} +}, descriptions.AGE_NESTED) + -AGE_RANGE = { +AGE_RANGE = describe_schema({ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "chord_metadata_service:age_range_schema", "title": "Age range schema", - "description": "An age range of a subject.", "type": "object", "properties": { "start": AGE, - "end": AGE + "end": AGE, }, "additionalProperties": False, "required": ["start", "end"] -} +}, descriptions.AGE_RANGE) + AGE_OR_AGE_RANGE = { "$schema": "http://json-schema.org/draft-07/schema#", @@ -204,170 +115,34 @@ ] } -################################## mCode/FHIR based schemas ################################## -### FHIR datatypes +# ============================ FHIR INGEST SCHEMAS ============================ +# The schema used to validate FHIR data for ingestion + -# FHIR Quantity https://www.hl7.org/fhir/datatypes.html#Quantity -QUANTITY = { +FHIR_BUNDLE_SCHEMA = { + "$id": "chord_metadata_service_fhir_bundle_schema", "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:quantity_schema", - "title": "Quantity schema", - "description": "Schema for the datatype Quantity.", + "description": "FHIR Bundle schema", "type": "object", "properties": { - "value": { - "type": "number" - }, - "comparator": { - "enum": ["<", ">", "<=", ">=", "="] - }, - "unit": { - "type": "string" - }, - "system": { + "resourceType": { "type": "string", - "format": "uri" + "const": "Bundle", + "description": "Collection of resources." }, - "code": { - "type": "string" - } - }, - "additionalProperties": False -} - - -# FHIR CodeableConcept https://www.hl7.org/fhir/datatypes.html#CodeableConcept -CODEABLE_CONCEPT = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:codeable_concept_schema", - "title": "Codeable Concept schema", - "description": "Schema for the datatype Concept.", - "type": "object", - "properties": { - "coding": { + "entry": { "type": "array", "items": { "type": "object", "properties": { - "system": {"type": "string", "format": "uri"}, - "version": {"type": "string"}, - "code": {"type": "string"}, - "display": {"type": "string"}, - "user_selected": {"type": "boolean"} - } + "resource": {"type": "object"} + }, + "additionalProperties": True, + "required": ["resource"] } - }, - "text": { - "type": "string" } }, - "additionalProperties": False + "additionalProperties": True, + "required": ["resourceType", "entry"] } - - -# FHIR Period https://www.hl7.org/fhir/datatypes.html#Period -PERIOD = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:period_schema", - "title": "Period", - "description": "Period schema.", - "type": "object", - "properties": { - "start": { - "type": "string", - "format": "date-time" - }, - "end": { - "type": "string", - "format": "date-time" - } - }, - "additionalProperties": False -} - - -# FHIR Ratio https://www.hl7.org/fhir/datatypes.html#Ratio -RATIO = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:ratio", - "title": "Ratio", - "description": "Ratio schema.", - "type": "object", - "properties": { - "numerator": QUANTITY, - "denominator": QUANTITY - }, - "additionalProperties": False -} - - -### FHIR based mCode elements - -TIME_OR_PERIOD = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "chord_metadata_service:time_or_period", - "title": "Time of Period", - "description": "Time of Period schema.", - "type": "object", - "properties": { - "value": { - "anyOf": [ - {"type": "string", "format": "date-time"}, - PERIOD - ] - } - }, - "additionalProperties": False -} - - -def customize_schema(first_typeof: dict, second_typeof: dict, first_property: str, second_property: str, - id: str=None, title: str=None, description: str=None, additionalProperties=False, - required=None) -> dict: - if required is None: - required = [] - return { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": id, - "title": title, - "description": description, - "type": "object", - "properties": { - first_property: first_typeof, - second_property: second_typeof - }, - "required": required, - "additionalProperties": additionalProperties - } - - -COMORBID_CONDITION = customize_schema(first_typeof=ONTOLOGY_CLASS, second_typeof=ONTOLOGY_CLASS, - first_property="clinical_status", second_property="code", - id="chord_metadata_service:comorbid_condition_schema", - title="Comorbid Condition schema", - description="Comorbid condition schema.") - -#TODO this is definitely should be changed, fhir datatypes are too complex use Ontology_ class -COMPLEX_ONTOLOGY = customize_schema(first_typeof=ONTOLOGY_CLASS, second_typeof=ONTOLOGY_CLASS, - first_property="data_value", second_property="staging_system", - id="chord_metadata_service:complex_ontology_schema", title="Complex ontology", - description="Complex object to combine data value and staging system.", - required=["data_value"]) - -#TODO this is definitely should be changed, fhir datatypes are too complex use Ontology_ class -TUMOR_MARKER_TEST = customize_schema(first_typeof=ONTOLOGY_CLASS, - second_typeof={ - "anyOf": [ - ONTOLOGY_CLASS, - QUANTITY, - RATIO - ] - }, - first_property="code", second_property="data_value", - id="chord_metadata_service:tumor_marker_test", - title="Tumor marker test", - description="Tumor marker test schema.", - required=["code"] - ) - diff --git a/chord_metadata_service/restapi/search_schemas.py b/chord_metadata_service/restapi/search_schemas.py new file mode 100644 index 000000000..c7ec5b8e1 --- /dev/null +++ b/chord_metadata_service/restapi/search_schemas.py @@ -0,0 +1,20 @@ +from .schemas import ONTOLOGY_CLASS +from .schema_utils import search_optional_str, tag_schema_with_search_properties + +__all__ = ["ONTOLOGY_SEARCH_SCHEMA"] + +ONTOLOGY_SEARCH_SCHEMA = tag_schema_with_search_properties(ONTOLOGY_CLASS, { + "properties": { + "id": { + "search": search_optional_str(0, multiple=True) + }, + "label": { + "search": search_optional_str(1, multiple=True) + } + }, + "search": { + "database": { + "type": "jsonb" # TODO: parameterize? + } + } +}) diff --git a/chord_metadata_service/restapi/semantic_mappings/hl7_genomics_mapping.py b/chord_metadata_service/restapi/semantic_mappings/hl7_genomics_mapping.py index 984796de3..754a26278 100644 --- a/chord_metadata_service/restapi/semantic_mappings/hl7_genomics_mapping.py +++ b/chord_metadata_service/restapi/semantic_mappings/hl7_genomics_mapping.py @@ -21,4 +21,4 @@ "label": "Structural variant [Length]" } } -} \ No newline at end of file +} diff --git a/chord_metadata_service/restapi/semantic_mappings/phenopackets_on_fhir_mapping.py b/chord_metadata_service/restapi/semantic_mappings/phenopackets_on_fhir_mapping.py index dde8fc5dc..975e4e342 100644 --- a/chord_metadata_service/restapi/semantic_mappings/phenopackets_on_fhir_mapping.py +++ b/chord_metadata_service/restapi/semantic_mappings/phenopackets_on_fhir_mapping.py @@ -67,8 +67,10 @@ "biosample": { "title": "Biosample", "url": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/Biosample", - "individual_age_at_collection": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-individual-age-at-collection", - "histological_diagnosis": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-histological-diagnosis", + "individual_age_at_collection": + "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-individual-age-at-collection", + "histological_diagnosis": + "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-histological-diagnosis", "tumor_progression": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-tumor-progression", "tumor_grade": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-tumor-grade", "diagnostic_markers": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/biosample-diagnostic-markers", @@ -104,4 +106,4 @@ "onset": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/disease-onset", "disease_stage": "http://ga4gh.org/fhir/phenopackets/StructureDefinition/disease-tumor-stage", } -} \ No newline at end of file +} diff --git a/chord_metadata_service/restapi/serializers.py b/chord_metadata_service/restapi/serializers.py index 1d866be87..65aca4611 100644 --- a/chord_metadata_service/restapi/serializers.py +++ b/chord_metadata_service/restapi/serializers.py @@ -1,5 +1,5 @@ -from rest_framework import serializers from collections import OrderedDict +from rest_framework import serializers from typing import Tuple diff --git a/chord_metadata_service/restapi/tests/constants.py b/chord_metadata_service/restapi/tests/constants.py new file mode 100644 index 000000000..ac3651f97 --- /dev/null +++ b/chord_metadata_service/restapi/tests/constants.py @@ -0,0 +1,31 @@ +INVALID_FHIR_BUNDLE_1 = { + "resourceType": "NotBundle", + "entry": [ + { + "test": "required resource is not present" + } + ] +} + +INVALID_SUBJECT_NOT_PRESENT = { + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "id": "1c8d2ee3-2a7e-47f9-be16-abe4e9fa306b", + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "code": "718-7", + "display": "Hemoglobin [Mass/volume] in Blood", + "system": "http://loinc.org" + } + ], + "text": "Hemoglobin [Mass/volume] in Blood" + } + } + } + ] +} diff --git a/chord_metadata_service/phenopackets/tests/test_descriptions.py b/chord_metadata_service/restapi/tests/test_descriptions.py similarity index 60% rename from chord_metadata_service/phenopackets/tests/test_descriptions.py rename to chord_metadata_service/restapi/tests/test_descriptions.py index dfbcb7d11..a0a05d3a9 100644 --- a/chord_metadata_service/phenopackets/tests/test_descriptions.py +++ b/chord_metadata_service/restapi/tests/test_descriptions.py @@ -1,5 +1,5 @@ -from chord_metadata_service.restapi.description_utils import * from django.test import TestCase +from .. import description_utils as du TEST_SCHEMA_1 = {"type": "string"} @@ -16,14 +16,14 @@ class TestDescriptions(TestCase): def test_descriptions(self): - assert isinstance(describe_schema(None, "Test"), dict) - self.assertDictEqual(describe_schema(TEST_SCHEMA_1, None), TEST_SCHEMA_1) + assert isinstance(du.describe_schema(None, "Test"), dict) + self.assertDictEqual(du.describe_schema(TEST_SCHEMA_1, None), TEST_SCHEMA_1) - d = describe_schema(TEST_SCHEMA_2, TEST_HELP_2) + d = du.describe_schema(TEST_SCHEMA_2, TEST_HELP_2) assert d["description"] == d["help"] assert d["help"] == "1" assert d["items"]["description"] == d["items"]["help"] assert d["items"]["description"] == "2" def test_help_get(self): - assert rec_help(TEST_HELP_2, "[item]") == "2" + assert du.rec_help(TEST_HELP_2, "[item]") == "2" diff --git a/chord_metadata_service/restapi/tests/test_fhir.py b/chord_metadata_service/restapi/tests/test_fhir.py index 3a9dc3561..56b6cc6ec 100644 --- a/chord_metadata_service/restapi/tests/test_fhir.py +++ b/chord_metadata_service/restapi/tests/test_fhir.py @@ -1,4 +1,15 @@ +from rest_framework import status from rest_framework.test import APITestCase + +from chord_metadata_service.patients.models import Individual +from chord_metadata_service.patients.tests.constants import VALID_INDIVIDUAL, VALID_INDIVIDUAL_2 +from chord_metadata_service.phenopackets.models import ( + MetaData, + Procedure, + Biosample, + Phenopacket, + PhenotypicFeature, +) from chord_metadata_service.phenopackets.tests.constants import ( VALID_INDIVIDUAL_1, VALID_META_DATA_2, @@ -11,14 +22,12 @@ valid_biosample_2, valid_phenotypic_feature, ) -from chord_metadata_service.patients.tests.constants import VALID_INDIVIDUAL, VALID_INDIVIDUAL_2 from chord_metadata_service.restapi.tests.utils import get_response -from chord_metadata_service.phenopackets.serializers import * -from rest_framework import status # Tests for FHIR conversion functions + class FHIRPhenopacketTest(APITestCase): def setUp(self): @@ -129,7 +138,7 @@ def setUp(self): self.valid_procedure = VALID_PROCEDURE_1 def test_get_fhir(self): - response = get_response('procedure-list', self.valid_procedure) + get_response('procedure-list', self.valid_procedure) get_resp = self.client.get('/api/procedures?format=fhir') self.assertEqual(get_resp.status_code, status.HTTP_200_OK) get_resp_obj = get_resp.json() @@ -190,7 +199,7 @@ def setUp(self): self.gene = VALID_GENE_1 def test_get_fhir(self): - response = get_response('gene-list', self.gene) + get_response('gene-list', self.gene) get_resp = self.client.get('/api/genes?format=fhir') self.assertEqual(get_resp.status_code, status.HTTP_200_OK) get_resp_obj = get_resp.json() @@ -210,7 +219,7 @@ def setUp(self): self.variant = VALID_VARIANT_1 def test_get_fhir(self): - response = get_response('variant-list', self.variant) + get_response('variant-list', self.variant) get_resp = self.client.get('/api/variants?format=fhir') self.assertEqual(get_resp.status_code, status.HTTP_200_OK) get_resp_obj = get_resp.json() @@ -229,7 +238,7 @@ def setUp(self): self.disease = VALID_DISEASE_1 def test_get_fhir(self): - response = get_response('disease-list', self.disease) + get_response('disease-list', self.disease) get_resp = self.client.get('/api/diseases?format=fhir') self.assertEqual(get_resp.status_code, status.HTTP_200_OK) get_resp_obj = get_resp.json() diff --git a/chord_metadata_service/restapi/tests/test_fhir_ingest.py b/chord_metadata_service/restapi/tests/test_fhir_ingest.py new file mode 100644 index 000000000..8c14b7156 --- /dev/null +++ b/chord_metadata_service/restapi/tests/test_fhir_ingest.py @@ -0,0 +1,38 @@ +import uuid + +from django.core.exceptions import ValidationError +from rest_framework.test import APITestCase + +from chord_metadata_service.chord.data_types import DATA_TYPE_PHENOPACKET +from chord_metadata_service.chord.models import Project, Dataset, TableOwnership, Table +from chord_metadata_service.chord.tests.constants import VALID_DATA_USE_1 +from chord_metadata_service.restapi.fhir_ingest import ingest_patients, ingest_observations +from .constants import INVALID_FHIR_BUNDLE_1, INVALID_SUBJECT_NOT_PRESENT + + +class TestFhirIngest(APITestCase): + + def setUp(self) -> None: + p = Project.objects.create(title="Project 1", description="Test") + self.d = Dataset.objects.create(title="Dataset 1", description="Test dataset", data_use=VALID_DATA_USE_1, + project=p) + to = TableOwnership.objects.create(table_id=uuid.uuid4(), service_id=uuid.uuid4(), service_artifact="metadata", + dataset=self.d) + self.t = Table.objects.create(ownership_record=to, name="Table 1", data_type=DATA_TYPE_PHENOPACKET) + + def test_fhir_bundle_schema(self): + + with self.assertRaises(ValidationError): + try: + ingest_patients(INVALID_FHIR_BUNDLE_1, self.t, "Test") + except ValidationError as e: + self.assertIn("resourceType", e.message) + raise e + + def test_required_subject(self): + + with self.assertRaises(KeyError): + try: + ingest_observations({}, INVALID_SUBJECT_NOT_PRESENT) + except KeyError as e: + raise e diff --git a/chord_metadata_service/restapi/tests/test_jsonld.py b/chord_metadata_service/restapi/tests/test_jsonld.py index c5fa01608..cc124b670 100644 --- a/chord_metadata_service/restapi/tests/test_jsonld.py +++ b/chord_metadata_service/restapi/tests/test_jsonld.py @@ -1,8 +1,11 @@ from rest_framework.test import APITestCase from django.test import override_settings from rest_framework import status -from chord_metadata_service.chord.tests.constants import (VALID_PROJECT_1, - VALID_DATS_CREATORS, dats_dataset) +from chord_metadata_service.chord.tests.constants import ( + VALID_PROJECT_1, + VALID_DATS_CREATORS, + dats_dataset, +) from chord_metadata_service.restapi.tests.utils import get_response @@ -13,8 +16,7 @@ def setUp(self) -> None: self.project = project.json() self.creators = VALID_DATS_CREATORS self.dataset = dats_dataset(self.project['identifier'], self.creators) - resp = get_response('dataset-list', self.dataset) - + get_response('dataset-list', self.dataset) def test_jsonld(self): get_resp = self.client.get('/api/datasets?format=json-ld') diff --git a/chord_metadata_service/restapi/tests/utils.py b/chord_metadata_service/restapi/tests/utils.py index 73f4c50fb..b2d1dcdaa 100644 --- a/chord_metadata_service/restapi/tests/utils.py +++ b/chord_metadata_service/restapi/tests/utils.py @@ -2,6 +2,7 @@ from django.urls import reverse from rest_framework.test import APIClient + # Helper functions for tests def get_response(viewname, obj): diff --git a/chord_metadata_service/restapi/urls.py b/chord_metadata_service/restapi/urls.py index 327d0ba2d..20e8aaf61 100644 --- a/chord_metadata_service/restapi/urls.py +++ b/chord_metadata_service/restapi/urls.py @@ -1,17 +1,25 @@ from django.urls import path, include from rest_framework import routers + from chord_metadata_service.chord import api_views as chord_views from chord_metadata_service.experiments import api_views as experiment_views +from chord_metadata_service.mcode import api_views as mcode_views from chord_metadata_service.patients import api_views as individual_views from chord_metadata_service.phenopackets import api_views as phenopacket_views -from chord_metadata_service.mcode import api_views as mcode_views +from chord_metadata_service.resources import api_views as resources_views -# from .settings import DEBUG +__all__ = ["router", "urlpatterns"] router = routers.DefaultRouter(trailing_slash=False) +# CHORD app urls +router.register(r'projects', chord_views.ProjectViewSet) +router.register(r'datasets', chord_views.DatasetViewSet) +router.register(r'table_ownership', chord_views.TableOwnershipViewSet) +router.register(r'tables', chord_views.TableViewSet) + # Experiments app urls router.register(r'experiments', experiment_views.ExperimentViewSet) @@ -25,20 +33,17 @@ router.register(r'genes', phenopacket_views.GeneViewSet) router.register(r'variants', phenopacket_views.VariantViewSet) router.register(r'diseases', phenopacket_views.DiseaseViewSet) -router.register(r'resources', phenopacket_views.ResourceViewSet) router.register(r'metadata', phenopacket_views.MetaDataViewSet) router.register(r'biosamples', phenopacket_views.BiosampleViewSet) router.register(r'phenopackets', phenopacket_views.PhenopacketViewSet) router.register(r'genomicinterpretations', phenopacket_views.GenomicInterpretationViewSet) router.register(r'diagnoses', phenopacket_views.DiagnosisViewSet) router.register(r'interpretations', phenopacket_views.InterpretationViewSet) -router.register(r'projects', chord_views.ProjectViewSet) -router.register(r'datasets', chord_views.DatasetViewSet) -router.register(r'table_ownership', chord_views.TableOwnershipViewSet) # mCode app urls -router.register(r'geneticvariantstested', mcode_views.GeneticVariantTestedViewSet) -router.register(r'geneticvariantsfound', mcode_views.GeneticVariantFoundViewSet) +router.register(r'geneticspecimens', mcode_views.GeneticSpecimenViewSet) +router.register(r'cancergeneticvariants', mcode_views.CancerGeneticVariantViewSet) +router.register(r'genomicregionsstudied', mcode_views.GenomicRegionStudiedViewSet) router.register(r'genomicsreports', mcode_views.GenomicsReportViewSet) router.register(r'labsvital', mcode_views.LabsVitalViewSet) router.register(r'cancerconditions', mcode_views.CancerConditionViewSet) @@ -47,6 +52,16 @@ router.register(r'medicationstatements', mcode_views.MedicationStatementViewSet) router.register(r'mcodepackets', mcode_views.MCodePacketViewSet) +# Resources app urls +router.register(r'resources', resources_views.ResourceViewSet) + urlpatterns = [ path('', include(router.urls)), + # apps schemas + path('chord_phenopacket_schema', phenopacket_views.get_chord_phenopacket_schema, + name="chord-phenopacket-schema"), + path('experiment_schema', experiment_views.get_experiment_schema, + name="experiment-schema"), + path('mcode_schema', mcode_views.get_mcode_schema, + name="mcode-schema"), ] diff --git a/chord_metadata_service/restapi/validators.py b/chord_metadata_service/restapi/validators.py index 78e7478b1..20a017160 100644 --- a/chord_metadata_service/restapi/validators.py +++ b/chord_metadata_service/restapi/validators.py @@ -1,18 +1,20 @@ from rest_framework import serializers from jsonschema import Draft7Validator, FormatChecker from chord_metadata_service.restapi.schemas import ( - ONTOLOGY_CLASS, QUANTITY, COMPLEX_ONTOLOGY, TIME_OR_PERIOD, TUMOR_MARKER_TEST, - ONTOLOGY_CLASS_LIST, KEY_VALUE_OBJECT, AGE_OR_AGE_RANGE, COMORBID_CONDITION + AGE_OR_AGE_RANGE, + ONTOLOGY_CLASS, + ONTOLOGY_CLASS_LIST, + KEY_VALUE_OBJECT, ) -class JsonSchemaValidator(object): +class JsonSchemaValidator: """ Custom class based validator to validate against Json schema for JSONField """ - def __init__(self, schema, format_checker=None): + def __init__(self, schema, formats=None): self.schema = schema - self.format_checker = format_checker - self.validator = Draft7Validator(self.schema, format_checker=FormatChecker(formats=self.format_checker)) + self.formats = formats + self.validator = Draft7Validator(self.schema, format_checker=FormatChecker(formats=self.formats)) def __call__(self, value): if not self.validator.is_valid(value): @@ -26,16 +28,11 @@ def deconstruct(self): return ( 'chord_metadata_service.restapi.validators.JsonSchemaValidator', [self.schema], - {} + {"formats": self.formats} ) +age_or_age_range_validator = JsonSchemaValidator(AGE_OR_AGE_RANGE) ontology_validator = JsonSchemaValidator(ONTOLOGY_CLASS) ontology_list_validator = JsonSchemaValidator(ONTOLOGY_CLASS_LIST) key_value_validator = JsonSchemaValidator(KEY_VALUE_OBJECT) -age_or_age_range_validator = JsonSchemaValidator(AGE_OR_AGE_RANGE) -quantity_validator = JsonSchemaValidator(schema=QUANTITY, format_checker=['uri']) -tumor_marker_test_validator = JsonSchemaValidator(schema=TUMOR_MARKER_TEST) -complex_ontology_validator = JsonSchemaValidator(schema=COMPLEX_ONTOLOGY, format_checker=['uri']) -time_or_period_validator = JsonSchemaValidator(schema=TIME_OR_PERIOD, format_checker=['date-time']) -comorbid_condition_validator = JsonSchemaValidator(COMORBID_CONDITION) \ No newline at end of file diff --git a/docs/_static/simple_metadata_service_model_v1.0.png b/docs/_static/simple_metadata_service_model_v1.0.png new file mode 100644 index 000000000..13c656531 Binary files /dev/null and b/docs/_static/simple_metadata_service_model_v1.0.png differ diff --git a/docs/modules/introduction.rst b/docs/modules/introduction.rst index 68b18250c..60cf91dc1 100644 --- a/docs/modules/introduction.rst +++ b/docs/modules/introduction.rst @@ -2,18 +2,19 @@ Introduction ============ Metadata service is a service to store phenotypic and clinical metadata about the patient and/or biosample. -Data model is partly based on `GA4GH Phenopackets schema `_. +The data model is partly based on `GA4GH Phenopackets schema `_ and +extended to support oncology-related metadata and experiments metadata. The simplified data model of the service is below. -.. image:: ../_static/simple_metadata_service_model_v0.3.0.png +.. image:: ../_static/simple_metadata_service_model_v1.0.png Technical implementation ------------------------ The service is implemented in Python and Django and uses PostgreSQL database to store the data. -Besides PostgreSQL the data can be indexed and queried in Elasticsearch. +Besides PostgreSQL, the data can be indexed and queried in Elasticsearch. Architecture @@ -22,15 +23,27 @@ Architecture The Metadata Service contains several services that share one API. Services depend on each other and are separated based on their scope. -**1. Patients service** handles anonymized individual’s data (e.g. individual id, sex, age or date of birth) +**1. Patients service** handles anonymized individual’s data (e.g. individual id, sex, age, or date of birth). -- Data model: aggregated profile from GA4GH Phenopackets Individual and FHIR Patient. It contains all fields of Phenopacket Individual and additional fields from FHIR Patient. +- Data model: aggregated profile from GA4GH Phenopackets Individual, FHIR Patient, and mCODE Patient. It contains all fields of Phenopacket Individual and additional fields from FHIR and mCODE Patient. -**2. Phenopackets service** handles phenotypic and clinical data +**2. Phenopackets service** handles phenotypic and clinical data. - Data model: GA4GH Phenopackets schema. Currently contains only two out of four Phenopackets top elements - Phenopacket and Interpretation. -**3. CHORD service** handles granular metadata about dataset (e.g. description, where the dataset is located, who are the creators of the dataset, licenses applied to the dataset, +**3. mCode service** handles patient's oncology-related data. + +- Data model: mCODE data elements. mCODE data elements grouped in a mCodepacket (like Phenopacket) containing patient's cancer-related descriptions including genomics data, medication statements, and cancer-related procedures. + +**4. Experiments service** handles experiment related data. + +- Data model: derived from IHEC metadata `Experiment specification `_. + +**5. Resources service** handles metadata about ontologies used for data annotation. + +- Data model: derived from the Phenopackets schema Resource profile. + +**6. CHORD service** handles granular metadata about dataset (e.g. description, where the dataset is located, who are the creators of the dataset, licenses applied to the dataset, authorization policy, terms of use). The dataset in the current implementation is one or more phenopackets related to each other through their provenance. @@ -40,7 +53,7 @@ The dataset in the current implementation is one or more phenopackets related to - GA4GH DUO is used to capture the terms of use applied to a dataset. -**4. Restapi service** handles all generic functionality shared among other services (e.g. renderers, common serializers, schemas, validators) +**7. Restapi service** handles all generic functionality shared among other services (e.g. renderers, common serializers, schemas, validators) Metadata standards @@ -48,12 +61,15 @@ Metadata standards `Phenopackets schema `_ is used for phenotypic description of patient and/or biosample. +`mCODE data elements `_ are used for oncology-related description of patient. + `DATS standard `_ is used for dataset description. `DUO ontology `_ is used for describing terms of use for a dataset. `Phenopackets on FHIR Implementation Guide `_ is used to map Phenopackets elements to `FHIR `_ resources. +`IHEC Metadata Experiment `_ is used for describing an experiment. REST API highlights ------------------- @@ -68,7 +84,7 @@ REST API highlights - Other available renderers: - - Currently the following classes can be retrieved in FHIR format by appending :code:`?format=fhir`: Phenopacket, Individual, Biosample, PhenotypicFeature, HtsFile, Gene, Variant, Disease, Procedure. + - Currently, the following classes can be retrieved in FHIR format by appending :code:`?format=fhir`: Phenopacket, Individual, Biosample, PhenotypicFeature, HtsFile, Gene, Variant, Disease, Procedure. - JSON-LD context to schema.org provided for the Dataset class in order to allow for a Google dataset search for Open Access Data: append :code:`?format=json-ld` when querying dataset endpoint. @@ -76,9 +92,14 @@ REST API highlights **Data ingest** -Currently only the data that follow Phenopackets schema can be ingested. +Ingest workflows are implemented for different types of data within the service. Ingest endpoint is :code:`/private/ingest`. -Example of POST request body: + +**1. Phenopackets data ingest** + +The data must follow Phenopackets schema in order to be ingested. + +Example of Phenopackets POST request body: .. code-block:: @@ -111,7 +132,53 @@ Example of POST request body: } } +**2. mCode data ingest** + +mCODE data elements are based on FHIR datatypes. +Only mCode related profiles will be ingested. +It's expected that the data is compliant with FHIR Release 4 and provided in FHIR Bundles. + +Example of mCode FHIR data POST request body: +.. code-block:: + + { + "table_id":"table_unique_id", + "workflow_id":"mcode_fhir_json", + "workflow_params":{ + "mcode_fhir_json.json_document":"/path/to/data.json" + }, + "workflow_outputs":{ + "json_document":"/path/to/data.json" + } + } + + +**3. FHIR data ingest** + +At the moment there is no implementation guide from FHIR to Phenopackets. +FHIR data will only be ingested partially where it's possible to establish mapping between FHIR resource and Phenopackets element. +The ingestion works for the following FHIR resources: Patient, Observation, Condition, Specimen. +It's expected that the data is compliant with FHIR Release 4 and provided in FHIR Bundles. + +.. code-block:: + + { + "table_id": "table_unique_id", + "workflow_id": "fhir_json", + "workflow_params": { + "fhir_json.patients": "/path/to/patients.json", + "fhir_json.observations": "/path/to/observations.json", + "fhir_json.conditions": "/path/to/conditions.json", + "fhir_json.specimens": "/path/to/specimens.json" + }, + "workflow_outputs": { + "patients": "/path/to/patients.json", + "observations": "/path/to/observations.json", + "conditions": "/path/to/conditions.json", + "specimens": "/path/to/specimens.json" + } + } Elasticsearch index (optional) diff --git a/docs/modules/models.rst b/docs/modules/models.rst index 3a06bc47b..2171141b4 100644 --- a/docs/modules/models.rst +++ b/docs/modules/models.rst @@ -13,6 +13,24 @@ Patients service .. automodule:: chord_metadata_service.patients.models :members: +Mcode service +------------------- + +.. automodule:: chord_metadata_service.mcode.models + :members: + +Experiments service +------------------- + +.. automodule:: chord_metadata_service.experiments.models + :members: + +Resources service +------------------- + +.. automodule:: chord_metadata_service.resources.models + :members: + CHORD service ------------------- diff --git a/docs/modules/views.rst b/docs/modules/views.rst index 6c564ddf7..74de9630d 100644 --- a/docs/modules/views.rst +++ b/docs/modules/views.rst @@ -13,6 +13,24 @@ Patients service .. automodule:: chord_metadata_service.patients.api_views :members: +Mcode service +------------------- + +.. automodule:: chord_metadata_service.mcode.api_views + :members: + +Experiments service +------------------- + +.. automodule:: chord_metadata_service.experiments.api_views + :members: + +Resources service +------------------- + +.. automodule:: chord_metadata_service.resources.api_views + :members: + CHORD service ------------------- diff --git a/examples/conditions.json b/examples/conditions.json new file mode 100644 index 000000000..420c76323 --- /dev/null +++ b/examples/conditions.json @@ -0,0 +1,73 @@ +{ + "resourceType": "Bundle", + "entry": [ + { + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Condition/bab430ff-5b09-4e4a-8871-6e4fcb84fa17", + "resource": { + "assertedDate": "2009-04-05T11:12:53-04:00", + "clinicalStatus": "active", + "code": { + "coding": [ + { + "code": "38341003", + "display": "Hypertension", + "system": "http://snomed.info/sct" + } + ], + "text": "Hypertension" + }, + "context": { + "reference": "Encounter/630a642d-0402-454c-9426-c399cf9b2aab" + }, + "id": "bab430ff-5b09-4e4a-8871-6e4fcb84fa17", + "meta": { + "lastUpdated": "2019-04-09T12:25:36.486194+00:00", + "versionId": "MTU1NDgxMjczNjQ4NjE5NDAwMA" + }, + "onsetDateTime": "2009-04-05T11:12:53-04:00", + "resourceType": "Condition", + "subject": { + "reference": "Patient/6f7acde5-db81-4361-82cf-886893a3280c" + }, + "verificationStatus": "confirmed" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Condition/0e2a23c7-90ef-4e0a-b9da-c7869104f8f6", + "resource": { + "assertedDate": "1961-06-27T21:21:32-04:00", + "clinicalStatus": "active", + "code": { + "coding": [ + { + "code": "38341003", + "display": "Hypertension", + "system": "http://snomed.info/sct" + } + ], + "text": "Hypertension" + }, + "context": { + "reference": "Encounter/bdba928a-6a8a-4998-bdb9-4190029cfd56" + }, + "id": "0e2a23c7-90ef-4e0a-b9da-c7869104f8f6", + "meta": { + "lastUpdated": "2019-04-09T12:25:35.399801+00:00", + "versionId": "MTU1NDgxMjczNTM5OTgwMTAwMA" + }, + "onsetDateTime": "1961-06-27T21:21:32-04:00", + "resourceType": "Condition", + "subject": { + "reference": "Patient/728b270d-d7de-4143-82fe-d3ccd92cebe4" + }, + "verificationStatus": "confirmed" + }, + "search": { + "mode": "match" + } + } + ] +} \ No newline at end of file diff --git a/examples/mcode_example.json b/examples/mcode_example.json index aa59b5e6c..c717fd2d7 100644 --- a/examples/mcode_example.json +++ b/examples/mcode_example.json @@ -1,154 +1,533 @@ { - "id": "mcodepacket:01", - "subject": { - "id": "ind:HG00096", - "taxonomy": { - "id": "NCBITaxon:9606", - "label": "Homo sapiens" - }, - "date_of_birth": "1923-12-07", - "sex": "MALE", - "karyotypic_sex": "XY", - "created": "2020-03-25T18:10:14.017215Z", - "updated": "2020-03-25T18:10:14.017215Z" - }, - "genomics_report": { - "id": "genomics_resport:01", - "test_name": { - "id": "GTR000511179.13", - "label": "Clinical Exome" - }, - "performing_organization_name": "Test organization", - "specimen_type": { - "id": "BDY", - "label": "Whole body" - }, - "genetic_variant_tested": [ - { - "id": "variant:03", - "method": { - "id": "FISH", - "label": "Fluorescent in situ hybridization (FISH)" - }, - "variant_tested_identifier": { - "id": "t12", - "label": "test" - }, - "variant_tested_description": "test" - } - ], - "genetic_variant_found": [ - { - "id": "variant_found:01", - "method": { - "id": "t12", - "label": "test" - } - } - ] - }, - "cancer_condition": { - "id": "cancer_condition:01", - "tnm_staging": [ - { - "id": "tnmstaging:01", - "tnm_type": "clinical", - "stage_group": { - "data_value": { - "id": "test", - "label": "t12" - } - }, - "primary_tumor_category": { - "data_value": { - "id": "test", - "label": "t12" - } - }, - "regional_nodes_category": { - "data_value": { - "id": "test", - "label": "t12" - } - }, - "distant_metastases_category": { - "data_value": { - "id": "test", - "label": "t12" - } - }, - "cancer_condition": "cancer_condition:01" - } - ], - "condition_type": "primary", - "body_location_code": [ + "id":"8670db4d-77ad-4bee-b38c-599453510c6d", + "subject":{ + "id":"6fcf56ed-8ad8-4395-a966-9ebee3822656" + }, + "cancer_condition":{ + "id":"5f2395f3-3271-441d-841a-af276c238eb0", + "clinical_status":{ + "id":"http://terminology.hl7.org/CodeSystem/condition-clinical:active", + "label":"active" + }, + "verification_status":{ + "id":"http://terminology.hl7.org/CodeSystem/condition-ver-status:confirmed", + "label":"confirmed" + }, + "code":{ + "id":"http://snomed.info/sct:254837009", + "label":"Malignant neoplasm of breast (disorder)" + }, + "date_of_diagnosis":"2019-03-02T22:02:57-05:00", + "condition_type":"primary", + "tnm_staging":[ + { + "id":"63a13799-00cb-4c7c-afa9-d997df3dbcf8", + "cancer_condition":"5f2395f3-3271-441d-841a-af276c238eb0", + "stage_group":{ + "data_value":{ + "id":"http://snomed.info/sct:261614003", + "label":"Stage 2A (qualifier value)" + } + }, + "tnm_type":"clinical", + "primary_tumor_category":{ + "data_value":{ + "id":"http://snomed.info/sct:67673008", + "label":"T2 category (finding)" + } + }, + "regional_nodes_category":{ + "data_value":{ + "id":"http://snomed.info/sct:62455006", + "label":"N0 category (finding)" + } + }, + "distant_metastases_category":{ + "data_value":{ + "id":"http://snomed.info/sct:62455006", + "label":"N0 category (finding)" + } + } + }, + { + "id":"b19e1d7c-3f01-4933-b0f1-36f8ca2d5bbc", + "cancer_condition":"5f2395f3-3271-441d-841a-af276c238eb0", + "stage_group":{ + "data_value":{ + "id":"http://snomed.info/sct:258219007", + "label":"Stage 2 (qualifier value)" + } + }, + "tnm_type":"clinical", + "primary_tumor_category":{ + "data_value":{ + "id":"http://snomed.info/sct:67673008", + "label":"T2 category (finding)" + } + }, + "regional_nodes_category":{ + "data_value":{ + "id":"http://snomed.info/sct:62455006", + "label":"N0 category (finding)" + } + }, + "distant_metastases_category":{ + "data_value":{ + "id":"http://snomed.info/sct:62455006", + "label":"N0 category (finding)" + } + } + } + ] + }, + "cancer_disease_status":{ + "id":"http://snomed.info/sct:268910001", + "label":"Patient's condition improved" + }, + "medication_statement": [ { - "id": "442083009", - "label": "Anatomical or acquired body structure (body structure)" + "id":"fd5416ee-78ad-41ea-b847-dcc3e975dec9", + "medication_code":{ + "id":"http://www.nlm.nih.gov/research/umls/rxnorm:198240", + "label":"tamoxifen citrate 10 MG Oral Tablet" } - ], - "clinical_status": { - "id": "active", - "label": "Active" - }, - "condition_code": { - "id": "404087009", - "label": "Carcinosarcoma of skin (disorder)" - }, - "date_of_diagnosis": "2018-11-13T20:20:39Z", - "histology_morphology_behavior": { - "id": "372147008", - "label": "Kaposi's sarcoma - category (morphologic abnormality)" - } - }, - "medication_statement": { - "id": "medication_statement:02", - "medication_code": { - "id": "92052", - "label": "Verapamil Oral Tablet [Calan]" - }, - "termination_reason": [ - { - "id": "182992009", - "label": "Treatment completed" - }, - { - "id": "1821212992009", - "label": "Treatment completed" + } + ], + "tumor_marker":[ + { + "id":"9f785640-bdd2-4afe-93e2-3959b8994567", + "tumor_marker_code":{ + "id":"http://loinc.org:48676-1", + "label":"HER2 [Interpretation] in Tissue" + }, + "tumor_marker_data_value":{ + "id":"http://snomed.info/sct:260385009", + "label":"Negative (qualifier value)" + }, + "individual":"6fcf56ed-8ad8-4395-a966-9ebee3822656" + }, + { + "id":"deeb6699-867b-4260-9d52-18b302bc42da", + "tumor_marker_code":{ + "id":"http://loinc.org:48676-1", + "label":"HER2 [Interpretation] in Tissue" + }, + "tumor_marker_data_value":{ + "id":"http://snomed.info/sct:260385009", + "label":"Negative (qualifier value)" + }, + "individual":"6fcf56ed-8ad8-4395-a966-9ebee3822656" + }, + { + "id":"61f262ea-d3e9-4657-99c3-420d4b0f5280", + "tumor_marker_code":{ + "id":"http://loinc.org:16112-5", + "label":"Estrogen receptor [Interpretation] in Tissue" + }, + "tumor_marker_data_value":{ + "id":"http://snomed.info/sct:10828004", + "label":"Positive (qualifier value)" + }, + "individual":"6fcf56ed-8ad8-4395-a966-9ebee3822656" + }, + { + "id":"6fa53768-cd9e-4633-b9e9-e6a3b264f145", + "tumor_marker_code":{ + "id":"http://loinc.org:16113-3", + "label":"Progesterone receptor [Interpretation] in Tissue" + }, + "tumor_marker_data_value":{ + "id":"http://snomed.info/sct:260385009", + "label":"Negative (qualifier value)" + }, + "individual":"6fcf56ed-8ad8-4395-a966-9ebee3822656" } - ], - "treatment_intent": { - "id": "373808002", - "label": "Curative - procedure intent" - }, - "start_date": "2018-11-13T20:20:39Z", - "end_date": "2019-04-13T20:20:39Z", - "date_time": "2019-04-13T20:20:39Z" - }, - "cancer_related_procedures": [ - { - "id": "cancer_related_procedure:03", - "procedure_type": "radiation", - "code": { - "id": "33356009", - "label": "Betatron teleradiotherapy (procedure)" - }, - "occurence_time_or_period": { - "value": { - "end": "2019-04-13T20:20:39+00:00", - "start": "2018-11-13T20:20:39+00:00" - } - }, - "target_body_site": [ - { - "id": "74805009", - "label": "Mammary gland sinus" - } - ], - "treatment_intent": { - "id": "373808002", - "label": "Curative - procedure intent" + ], + "cancer_related_procedures":[ + { + "id":"910dba95-2870-4e83-9f0f-b16da9c39297", + "code":{ + "id":"http://snomed.info/sct:69031006", + "label":"Excision of breast tissue (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"surgical" + }, + { + "id":"2676fcea-a788-4dbd-97b2-57e30df8cc0f", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"9b3fef9e-8860-4721-8f74-609fd173c10a", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"2d5367df-a102-4b18-b254-62fbdb1586ae", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"a7609255-0482-4b36-ad90-c821e863404d", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"83fdc3c6-7be8-422a-aa12-6252bcc54739", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"51f0d170-cfbf-4ffa-8d05-3f4565cca150", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"df19b727-fff0-4d5d-82bd-b9ddd7337dba", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"5ccc627a-aa39-418b-842c-23fc200d7510", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"93e6ed86-474b-4033-aae1-8547a0a63d23", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"5d4ade2d-53ad-489a-8336-a53c8693672c", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"03c7d064-63bc-46ef-adf3-fe64c5981d4e", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"d722684b-f0d9-4931-b3de-7f3638a6551e", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"168785f3-e7db-41c8-bb1d-a1aaef0e53e4", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"5f7736ab-86b8-43d0-88cf-243260d1cc01", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"b40ed30c-3ad7-468a-846a-1ab8ce682b64", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"1c645d66-1bb0-4e89-9322-34aefb59f0b8", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"a8889bc8-1523-4e79-aa4d-6026eeaa02bd", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"37cd7a3f-d4e2-48b1-be07-7126491e3aef", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"29e8e15f-9835-43fa-b76d-33d58a4b239c", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"3ebbde3e-6a23-4c13-9c65-ef7b899c1473", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"22265f09-ad9a-4c17-a450-27a0652ebba6", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"ff832c55-e38a-4b07-b40a-53d1767a0f0b", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"db38f007-6676-4449-a885-727bc358894a", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"74dd61a3-cd90-4f5b-a6f7-9ac485e7d43e", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"f1044644-8494-49e2-b6f8-2414467fe551", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"156be3f2-f228-448d-b54f-59fc671e05e2", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"424f3afb-3884-4a7d-af02-afdca441a644", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"46c576a4-196d-47e2-ad25-3d4333c8d1ea", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"11995568-c20e-45e4-9a03-64e6b17cacd7", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"12de0ba3-ccff-483d-b493-178b3d6f12ee", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"36cf5f93-1454-496c-b4d0-6a2166b188be", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"6d86fdb3-ef23-46b6-b478-552da5e12504", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"bc483d87-9735-4d70-ae6f-3433202943c1", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" + }, + { + "id":"833b7ae7-3111-4f0b-bc84-f57bad2cb6c0", + "code":{ + "id":"http://snomed.info/sct:448385000", + "label":"Megavoltage radiation therapy using photons (procedure)" + }, + "reason_reference":[ + "5f2395f3-3271-441d-841a-af276c238eb0" + ], + "procedure_type":"radiation" } - } - ] -} + ] +} \ No newline at end of file diff --git a/examples/observations.json b/examples/observations.json new file mode 100644 index 000000000..45da2ea84 --- /dev/null +++ b/examples/observations.json @@ -0,0 +1,155 @@ +{ + "entry": [ + { + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Observation/97684895-3a6e-4d46-9edd-31984bc7c3a6", + "resource": { + "category": [ + { + "coding": [ + { + "code": "laboratory", + "display": "laboratory", + "system": "http://hl7.org/fhir/observation-category" + } + ] + } + ], + "code": { + "coding": [ + { + "code": "718-7", + "display": "Hemoglobin [Mass/volume] in Blood", + "system": "http://loinc.org" + } + ], + "text": "Hemoglobin [Mass/volume] in Blood" + }, + "context": { + "reference": "Encounter/2a0e0f6c-493f-4c5b-bf89-5f98aee24f21" + }, + "effectiveDateTime": "2014-03-02T10:12:53-05:00", + "id": "97684895-3a6e-4d46-9edd-31984bc7c3a6", + "issued": "2014-03-02T10:12:53.714-05:00", + "meta": { + "lastUpdated": "2019-04-09T12:25:36.531998+00:00", + "versionId": "MTU1NDgxMjczNjUzMTk5ODAwMA" + }, + "resourceType": "Observation", + "status": "final", + "subject": { + "reference": "Patient/6f7acde5-db81-4361-82cf-886893a3280c" + }, + "valueQuantity": { + "code": "g/dL", + "system": "http://unitsofmeasure.org", + "unit": "g/dL", + "value": 17.14301557752162 + } + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Observation/1c8d2ee3-2a7e-47f9-be16-abe4e9fa306b", + "resource": { + "category": [ + { + "coding": [ + { + "code": "laboratory", + "display": "laboratory", + "system": "http://hl7.org/fhir/observation-category" + } + ] + } + ], + "code": { + "coding": [ + { + "code": "785-6", + "display": "MCH [Entitic mass] by Automated count", + "system": "http://loinc.org" + } + ], + "text": "MCH [Entitic mass] by Automated count" + }, + "context": { + "reference": "Encounter/2a0e0f6c-493f-4c5b-bf89-5f98aee24f21" + }, + "effectiveDateTime": "2014-03-02T10:12:53-05:00", + "id": "1c8d2ee3-2a7e-47f9-be16-abe4e9fa306b", + "issued": "2014-03-02T10:12:53.714-05:00", + "meta": { + "lastUpdated": "2019-04-09T12:25:36.531997+00:00", + "versionId": "MTU1NDgxMjczNjUzMTk5NzAwMA" + }, + "resourceType": "Observation", + "status": "final", + "subject": { + "reference": "Patient/6f7acde5-db81-4361-82cf-886893a3280c" + }, + "valueQuantity": { + "code": "pg", + "system": "http://unitsofmeasure.org", + "unit": "pg", + "value": 27.035236044041877 + } + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Observation/b0598af4-8ffe-43ba-84e5-b7fb49d3dcd7", + "resource": { + "category": [ + { + "coding": [ + { + "code": "laboratory", + "display": "laboratory", + "system": "http://hl7.org/fhir/observation-category" + } + ] + } + ], + "code": { + "coding": [ + { + "code": "777-3", + "display": "Platelets [#/volume] in Blood by Automated count", + "system": "http://loinc.org" + } + ], + "text": "Platelets [#/volume] in Blood by Automated count" + }, + "context": { + "reference": "Encounter/2a0e0f6c-493f-4c5b-bf89-5f98aee24f21" + }, + "effectiveDateTime": "2014-03-02T10:12:53-05:00", + "id": "b0598af4-8ffe-43ba-84e5-b7fb49d3dcd7", + "issued": "2014-03-02T10:12:53.714-05:00", + "meta": { + "lastUpdated": "2019-04-09T12:25:36.530961+00:00", + "versionId": "MTU1NDgxMjczNjUzMDk2MTAwMA" + }, + "resourceType": "Observation", + "status": "final", + "subject": { + "reference": "Patient/728b270d-d7de-4143-82fe-d3ccd92cebe4" + }, + "valueQuantity": { + "code": "10*3/uL", + "system": "http://unitsofmeasure.org", + "unit": "10*3/uL", + "value": 306.49607523265786 + } + }, + "search": { + "mode": "match" + } + } + ], + "resourceType": "Bundle" +} \ No newline at end of file diff --git a/examples/patients.json b/examples/patients.json new file mode 100644 index 000000000..f3c471693 --- /dev/null +++ b/examples/patients.json @@ -0,0 +1,431 @@ +{ + "resourceType": "Bundle", + "total": 154259, + "type": "searchset", + "entry": [ + { + "resource": { + "meta": { + "lastUpdated": "2019-04-09T12:25:36.451316+00:00", + "versionId": "MTU1NDgxMjczNjQ1MTMxNjAwMA" + }, + "identifier": [ + { + "system": "https://github.com/synthetichealth/synthea", + "value": "a238ebf2-392b-44be-9a17-da07a15220e2" + }, + { + "system": "http://hospital.smarthealthit.org", + "value": "a238ebf2-392b-44be-9a17-da07a15220e2", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0203", + "code": "MR", + "display": "Medical Record Number" + } + ], + "text": "Medical Record Number" + } + }, + { + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "999-99-7515", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/identifier-type", + "code": "SB", + "display": "Social Security Number" + } + ], + "text": "Social Security Number" + } + }, + { + "system": "urn:oid:2.16.840.1.113883.4.3.25", + "value": "S99942098", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0203", + "code": "DL", + "display": "Driver's License" + } + ], + "text": "Driver's License" + } + }, + { + "system": "http://standardhealthrecord.org/fhir/StructureDefinition/passportNumber", + "value": "X19416767X", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0203", + "code": "PPN", + "display": "Passport Number" + } + ], + "text": "Passport Number" + } + } + ], + "name": [ + { + "given": [ + "Gregg522" + ], + "prefix": [ + "Mr." + ], + "family": "Hettinger594", + "use": "official" + } + ], + "telecom": [ + { + "system": "phone", + "value": "555-282-3544", + "use": "home" + } + ], + "extension": [ + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2106-3", + "display": "White" + } + }, + { + "valueString": "White", + "url": "text" + } + ] + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2186-5", + "display": "Not Hispanic or Latino" + } + }, + { + "valueString": "Not Hispanic or Latino", + "url": "text" + } + ] + }, + { + "valueString": "Krysta658 Terry864", + "url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName" + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", + "valueCode": "M" + }, + { + "valueAddress": { + "state": "Estremadura", + "country": "PT", + "city": "Lisbon" + }, + "url": "http://hl7.org/fhir/StructureDefinition/birthPlace" + }, + { + "url": "http://synthetichealth.github.io/synthea/disability-adjusted-life-years", + "valueDecimal": 0 + }, + { + "url": "http://synthetichealth.github.io/synthea/quality-adjusted-life-years", + "valueDecimal": 27 + } + ], + "address": [ + { + "state": "Massachusetts", + "postalCode": "02330", + "city": "Carver", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/geolocation", + "extension": [ + { + "url": "latitude", + "valueDecimal": 41.875179 + }, + { + "url": "longitude", + "valueDecimal": -70.74671500000002 + } + ] + } + ], + "line": [ + "1087 Halvorson Light" + ], + "country": "US" + } + ], + "resourceType": "Patient", + "communication": [ + { + "language": { + "coding": [ + { + "system": "urn:ietf:bcp:47", + "code": "pt", + "display": "Portuguese" + } + ], + "text": "Portuguese" + } + } + ], + "text": { + "status": "generated", + "div": "
Generated by Synthea.Version identifier: v2.2.0-56-g113d8a2d\n . Person seed: 8417064283020065324 Population seed: 5
" + }, + "gender": "male", + "multipleBirthBoolean": false, + "birthDate": "1991-02-10", + "maritalStatus": { + "coding": [ + { + "system": "http://hl7.org/fhir/v3/MaritalStatus", + "code": "M", + "display": "M" + } + ], + "text": "M" + }, + "id": "6f7acde5-db81-4361-82cf-886893a3280c" + }, + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Patient/6f7acde5-db81-4361-82cf-886893a3280c", + "search": { + "mode": "match" + } + }, + { + "resource": { + "meta": { + "lastUpdated": "2019-04-09T12:25:35.392600+00:00", + "versionId": "MTU1NDgxMjczNTM5MjYwMDAwMA" + }, + "identifier": [ + { + "system": "https://github.com/synthetichealth/synthea", + "value": "b02886ea-3925-41d4-b606-377ae05eea78" + }, + { + "system": "http://hospital.smarthealthit.org", + "value": "b02886ea-3925-41d4-b606-377ae05eea78", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0203", + "code": "MR", + "display": "Medical Record Number" + } + ], + "text": "Medical Record Number" + } + }, + { + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "999-14-7088", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/identifier-type", + "code": "SB", + "display": "Social Security Number" + } + ], + "text": "Social Security Number" + } + }, + { + "system": "urn:oid:2.16.840.1.113883.4.3.25", + "value": "S99933145", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0203", + "code": "DL", + "display": "Driver's License" + } + ], + "text": "Driver's License" + } + }, + { + "system": "http://standardhealthrecord.org/fhir/StructureDefinition/passportNumber", + "value": "X51147095X", + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0203", + "code": "PPN", + "display": "Passport Number" + } + ], + "text": "Passport Number" + } + } + ], + "name": [ + { + "given": [ + "Jonathan639" + ], + "prefix": [ + "Mr." + ], + "family": "Prosacco716", + "use": "official" + } + ], + "telecom": [ + { + "system": "phone", + "value": "555-719-3748", + "use": "home" + } + ], + "extension": [ + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2106-3", + "display": "White" + } + }, + { + "valueString": "White", + "url": "text" + } + ] + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2186-5", + "display": "Not Hispanic or Latino" + } + }, + { + "valueString": "Not Hispanic or Latino", + "url": "text" + } + ] + }, + { + "valueString": "Corinna386 Kulas532", + "url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName" + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", + "valueCode": "M" + }, + { + "valueAddress": { + "state": "Massachusetts", + "country": "US", + "city": "Sandwich" + }, + "url": "http://hl7.org/fhir/StructureDefinition/birthPlace" + }, + { + "url": "http://synthetichealth.github.io/synthea/disability-adjusted-life-years", + "valueDecimal": 2.1052411837922023 + }, + { + "url": "http://synthetichealth.github.io/synthea/quality-adjusted-life-years", + "valueDecimal": 72.8947588162078 + } + ], + "address": [ + { + "state": "Massachusetts", + "postalCode": "02052", + "city": "Medfield", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/geolocation", + "extension": [ + { + "url": "latitude", + "valueDecimal": 42.187011 + }, + { + "url": "longitude", + "valueDecimal": -71.30040799999998 + } + ] + } + ], + "line": [ + "1038 Ratke Throughway Apt 10" + ], + "country": "US" + } + ], + "gender": "unknown", + "resourceType": "Patient", + "communication": [ + { + "language": { + "coding": [ + { + "system": "urn:ietf:bcp:47", + "code": "en-US", + "display": "English" + } + ], + "text": "English" + } + } + ], + "text": { + "status": "generated", + "div": "
Generated by Synthea.Version identifier: v2.2.0-56-g113d8a2d\n . Person seed: -5708762760784740351 Population seed: 5
" + }, + "multipleBirthBoolean": false, + "birthDate": "1943-06-08", + "maritalStatus": { + "coding": [ + { + "system": "http://hl7.org/fhir/v3/MaritalStatus", + "code": "M", + "display": "M" + } + ], + "text": "M" + }, + "id": "728b270d-d7de-4143-82fe-d3ccd92cebe4" + }, + "fullUrl": "https://syntheticmass.mitre.org/v1/fhir/Patient/728b270d-d7de-4143-82fe-d3ccd92cebe4", + "search": { + "mode": "match" + } + } + ] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 626a3cc4f..e70c8b59c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,50 +1,60 @@ alabaster==0.7.12 +appdirs==1.4.4 attrs==19.3.0 Babel==2.8.0 -certifi==2020.4.5.1 +bento-lib==0.11.0 +certifi==2020.6.20 chardet==3.0.4 -chord-lib==0.8.0 -codecov==2.0.22 +codecov==2.1.7 colorama==0.4.3 coreapi==2.3.3 coreschema==0.0.4 -coverage==5.1 -Django==2.2.12 -django-filter==2.2.0 +coverage==5.2 +distlib==0.3.1 +Django==2.2.14 +django-filter==2.3.0 django-nose==1.4.6 django-rest-swagger==2.2.0 djangorestframework==3.11.0 -djangorestframework-camel-case==1.1.2 +djangorestframework-camel-case==1.2.0 docutils==0.16 -elasticsearch==7.6.0 +elasticsearch==7.8.0 +entrypoints==0.3 fhirclient==3.2.0 -idna==2.9 +filelock==3.0.12 +flake8==3.8.3 +idna==2.10 imagesize==1.2.0 -importlib-metadata==1.6.0 +importlib-metadata==1.7.0 isodate==0.6.0 itypes==1.2.0 Jinja2==2.11.2 jsonschema==3.2.0 -Markdown==3.2.1 +Markdown==3.2.2 MarkupSafe==1.1.1 -more-itertools==8.2.0 +mccabe==0.6.1 +more-itertools==8.4.0 nose==1.3.7 openapi-codec==1.3.2 -packaging==20.3 +packaging==20.4 +pluggy==0.13.1 psycopg2-binary==2.8.5 +py==1.9.0 +pycodestyle==2.6.0 +pyflakes==2.2.0 Pygments==2.6.1 pyparsing==2.4.7 pyrsistent==0.16.0 python-dateutil==2.8.1 pytz==2020.1 PyYAML==5.3.1 -rdflib==4.2.2 -rdflib-jsonld==0.4.0 -redis==3.4.1 -requests==2.23.0 +rdflib==5.0.0 +rdflib-jsonld==0.5.0 +redis==3.5.3 +requests==2.24.0 rfc3987==1.3.8 simplejson==3.17.0 -six==1.14.0 +six==1.15.0 snowballstemmer==2.0.0 Sphinx==2.4.4 sphinx-rtd-theme==0.4.3 @@ -56,8 +66,11 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 sqlparse==0.3.1 strict-rfc3339==0.7 +toml==0.10.1 +tox==3.16.1 uritemplate==3.0.1 urllib3==1.25.9 +virtualenv==20.0.25 Werkzeug==1.0.1 wincertstore==0.2 zipp==3.1.0 diff --git a/setup.py b/setup.py index cc4dc0cb8..aa619a050 100644 --- a/setup.py +++ b/setup.py @@ -16,14 +16,14 @@ python_requires=">=3.6", install_requires=[ - "chord_lib[django]==0.8.0", - "Django>=2.2,<3.0", - "django-filter>=2.2,<3.0", + "bento_lib[django]==0.11.0", + "Django>=2.2.14,<3.0", + "django-filter>=2.3,<3.0", "django-nose>=1.4,<2.0", "djangorestframework>=3.11,<3.12", - "djangorestframework-camel-case>=1.1,<2.0", + "djangorestframework-camel-case>=1.2.0,<2.0", "django-rest-swagger==2.2.0", - "elasticsearch==7.1.0", + "elasticsearch==7.8.0", "fhirclient>=3.2,<4.0", "jsonschema>=3.2,<4.0", "psycopg2-binary>=2.8,<3.0", @@ -32,7 +32,7 @@ "strict-rfc3339==0.7", "rdflib==4.2.2", "rdflib-jsonld==0.4.0", - "requests>=2.23,<3.0", + "requests>=2.24.0,<3.0", "rfc3987==1.3.8", "uritemplate>=3.0,<4.0", ], diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..0cee70a79 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[flake8] +max-line-length = 120 +exclude = .git, .tox, __pycache__, migrations + +[testenv] +passenv = + CHORD_* + POSTGRES_* +skip_install = true +commands = + pip install -r requirements.txt + coverage run ./manage.py test + flake8 ./chord_metadata_service