diff --git a/matchmaker/models.py b/matchmaker/models.py index 36c676069f..3797c8a242 100644 --- a/matchmaker/models.py +++ b/matchmaker/models.py @@ -8,7 +8,10 @@ class MatchmakerSubmission(ModelWithGUID): - SEX_LOOKUP = {Individual.SEX_MALE: 'MALE', Individual.SEX_FEMALE: 'FEMALE'} + SEX_LOOKUP = { + **{sex: 'MALE' for sex in Individual.MALE_SEXES}, + **{sex: 'FEMALE' for sex in Individual.FEMALE_SEXES}, + } individual = models.OneToOneField(Individual, on_delete=models.PROTECT) diff --git a/seqr/fixtures/1kg_project.json b/seqr/fixtures/1kg_project.json index d0c79afaed..3b737b363b 100644 --- a/seqr/fixtures/1kg_project.json +++ b/seqr/fixtures/1kg_project.json @@ -444,7 +444,7 @@ "individual_id": "NA19675_1", "mother_id": 3, "father_id": 2, - "sex": "M", + "sex": "XXY", "affected": "A", "display_name": "", "notes": "", @@ -536,7 +536,7 @@ "individual_id": "HG00731", "mother_id": 6, "father_id": 5, - "sex": "F", + "sex": "X0", "affected": "A", "proband_relationship": "S", "display_name": "HG00731_a", diff --git a/seqr/migrations/0076_alter_individual_sex.py b/seqr/migrations/0076_alter_individual_sex.py new file mode 100644 index 0000000000..93f5967eac --- /dev/null +++ b/seqr/migrations/0076_alter_individual_sex.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-10-10 20:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('seqr', '0075_alter_individual_primary_biosample'), + ] + + operations = [ + migrations.AlterField( + model_name='individual', + name='sex', + field=models.CharField(choices=[('M', 'Male'), ('F', 'Female'), ('U', 'Unknown'), ('XXY', 'XXY'), ('XYY', 'XYY'), ('XXX', 'XXX'), ('X0', 'X0')], default='U', max_length=3), + ), + ] diff --git a/seqr/models.py b/seqr/models.py index e571e184fa..267ec0d247 100644 --- a/seqr/models.py +++ b/seqr/models.py @@ -447,10 +447,16 @@ class Individual(ModelWithGUID): SEX_MALE = 'M' SEX_FEMALE = 'F' SEX_UNKNOWN = 'U' + FEMALE_ANEUPLOIDIES = ['XXX', 'X0'] + MALE_ANEUPLOIDIES = ['XXY', 'XYY'] + FEMALE_SEXES = [SEX_FEMALE] + FEMALE_ANEUPLOIDIES + MALE_SEXES = [SEX_MALE] + MALE_ANEUPLOIDIES SEX_CHOICES = ( (SEX_MALE, 'Male'), ('F', 'Female'), ('U', 'Unknown'), + *[(sex, sex) for sex in MALE_ANEUPLOIDIES], + *[(sex, sex) for sex in FEMALE_ANEUPLOIDIES], ) AFFECTED_STATUS_AFFECTED = 'A' @@ -603,7 +609,7 @@ class Individual(ModelWithGUID): mother = models.ForeignKey('seqr.Individual', null=True, blank=True, on_delete=models.SET_NULL, related_name='maternal_children') father = models.ForeignKey('seqr.Individual', null=True, blank=True, on_delete=models.SET_NULL, related_name='paternal_children') - sex = models.CharField(max_length=1, choices=SEX_CHOICES, default='U') + sex = models.CharField(max_length=3, choices=SEX_CHOICES, default='U') affected = models.CharField(max_length=1, choices=AFFECTED_STATUS_CHOICES, default=AFFECTED_STATUS_UNKNOWN) # TODO once sample and individual ids are fully decoupled no reason to maintain this field diff --git a/seqr/utils/search/hail_search_utils.py b/seqr/utils/search/hail_search_utils.py index 28efcc401e..5433dc4650 100644 --- a/seqr/utils/search/hail_search_utils.py +++ b/seqr/utils/search/hail_search_utils.py @@ -141,7 +141,7 @@ def _get_sample_data(samples, inheritance_filter=None, inheritance_mode=None, ** affected=F('individual__affected'), ) if inheritance_mode == X_LINKED_RECESSIVE: - sample_values['is_male'] = Case(When(individual__sex=Individual.SEX_MALE, then=True), default=False) + sample_values['is_male'] = Case(When(individual__sex__in=Individual.MALE_SEXES, then=True), default=False) sample_data = samples.order_by('guid').values('individual__individual_id', 'dataset_type', 'sample_type', **sample_values) custom_affected = (inheritance_filter or {}).pop('affected', None) diff --git a/seqr/views/apis/anvil_workspace_api_tests.py b/seqr/views/apis/anvil_workspace_api_tests.py index 36490f516a..f5db4e7859 100644 --- a/seqr/views/apis/anvil_workspace_api_tests.py +++ b/seqr/views/apis/anvil_workspace_api_tests.py @@ -730,7 +730,7 @@ def _assert_valid_operation(self, project, test_add_data=True): ['R0001_1kg', 'F000001_1', '1', 'NA19675_1', 'NA19678', '', 'F'], ['R0001_1kg', 'F000001_1', '1', 'NA19678', '', '', 'M'], ['R0001_1kg', 'F000001_1', '1', 'NA19679', '', '', 'F'], - ['R0001_1kg', 'F000002_2', '2', 'HG00731', 'HG00732', 'HG00733', 'F'], + ['R0001_1kg', 'F000002_2', '2', 'HG00731', 'HG00732', 'HG00733', 'X0'], ['R0001_1kg', 'F000002_2', '2', 'HG00732', '', '', 'M'], ['R0001_1kg', 'F000002_2', '2', 'HG00733', '', '', 'F'], ['R0001_1kg', 'F000003_3', '3', 'NA20870', '', '', 'M'], diff --git a/seqr/views/apis/data_manager_api_tests.py b/seqr/views/apis/data_manager_api_tests.py index 8a50cd5b88..1de7348768 100644 --- a/seqr/views/apis/data_manager_api_tests.py +++ b/seqr/views/apis/data_manager_api_tests.py @@ -390,10 +390,10 @@ PEDIGREE_HEADER = ['Project_GUID', 'Family_GUID', 'Family_ID', 'Individual_ID', 'Paternal_ID', 'Maternal_ID', 'Sex'] EXPECTED_PEDIGREE_ROWS = [ - ['R0001_1kg', 'F000001_1', '1', 'NA19675_1', 'NA19678', 'NA19679', 'M'], + ['R0001_1kg', 'F000001_1', '1', 'NA19675_1', 'NA19678', 'NA19679', 'XXY'], ['R0001_1kg', 'F000001_1', '1', 'NA19678', '', '', 'M'], ['R0001_1kg', 'F000001_1', '1', 'NA19679', '', '', 'F'], - ['R0001_1kg', 'F000002_2', '2', 'HG00731', 'HG00732', 'HG00733', 'F'], + ['R0001_1kg', 'F000002_2', '2', 'HG00731', 'HG00732', 'HG00733', 'X0'], ] PROJECT_OPTION = { diff --git a/seqr/views/apis/individual_api.py b/seqr/views/apis/individual_api.py index 9aad4dc754..fd210ea2c9 100644 --- a/seqr/views/apis/individual_api.py +++ b/seqr/views/apis/individual_api.py @@ -29,12 +29,6 @@ from seqr.views.utils.individual_utils import delete_individuals, add_or_update_individuals_and_families from seqr.views.utils.variant_utils import bulk_create_tagged_variants -_SEX_TO_EXPORTED_VALUE = dict(Individual.SEX_LOOKUP) -_SEX_TO_EXPORTED_VALUE['U'] = '' - -__AFFECTED_TO_EXPORTED_VALUE = dict(Individual.AFFECTED_STATUS_LOOKUP) -__AFFECTED_TO_EXPORTED_VALUE['U'] = '' - @login_and_policies_required def update_individual_handler(request, individual_guid): diff --git a/seqr/views/apis/individual_api_tests.py b/seqr/views/apis/individual_api_tests.py index cae1f5f062..5c10820ee8 100644 --- a/seqr/views/apis/individual_api_tests.py +++ b/seqr/views/apis/individual_api_tests.py @@ -453,7 +453,8 @@ def test_individuals_table_handler_errors(self): rows += [ '"1" "NA19675_1" "NA19675_1" "F" "Father"', - '"2" "NA19675_2" "NA19675_1" "M" ""', + '"2" "NA19675_2" "NA19675_1" "XXX" "Nephew"', + '"2" "NA19677" "NA19675_2" "M" ""', ] response = self.client.post(individuals_url, { 'f': SimpleUploadedFile('test.tsv', '\n'.join(rows).encode('utf-8'))}) @@ -463,8 +464,10 @@ def test_individuals_table_handler_errors(self): 'Invalid proband relationship "Father" for NA19675_1 with given gender Female', 'NA19675_1 is recorded as their own father', 'NA19675_1 is recorded as Female sex and also as the father of NA19675_1', + 'Invalid proband relationship "Nephew" for NA19675_2 with given gender XXX', 'NA19675_1 is recorded as Female sex and also as the father of NA19675_2', 'NA19675_1 is recorded as the father of NA19675_2 but they have different family ids: 1 and 2', + 'NA19675_2 is recorded as XXX sex and also as the father of NA19677', 'NA19675_1 is included as 2 separate records, but must be unique within the project', ], 'warnings': [missing_entry_warning], @@ -477,7 +480,7 @@ def test_individuals_table_handler(self): data = 'Family ID Individual ID Previous Individual ID Paternal ID Maternal ID Sex Affected Status Notes familyNotes\n\ "1" " NA19675_1 " "" "NA19678 " "NA19679" "Female" "Affected" "A affected individual, test1-zsf" ""\n\ -"1" "NA19678" "" "" "" "Male" "Unaffected" "a individual note" ""\n\ +"1" "NA19678" "" "" "" "XXY" "Unaffected" "a individual note" ""\n\ "4" "NA20872_update" "NA20872" "" "" "Male" "Affected" "" ""\n\ "21" " HG00735" "" "" "" "Female" "Unaffected" "" "a new family""' @@ -525,6 +528,7 @@ def test_individuals_table_handler(self): self.assertEqual(response_json['individualsByGuid']['I000001_na19675']['sex'], 'F') self.assertEqual( response_json['individualsByGuid']['I000001_na19675']['notes'], 'A affected individual, test1-zsf') + self.assertEqual(response_json['individualsByGuid']['I000002_na19678']['sex'], 'XXY') self.assertEqual(response_json['individualsByGuid'][new_indiv_guid]['individualId'], 'HG00735') self.assertEqual(response_json['individualsByGuid'][new_indiv_guid]['sex'], 'F') self.assertEqual(response_json['individualsByGuid']['I000008_na20872']['individualId'], 'NA20872_update') @@ -899,7 +903,7 @@ def _is_expected_individuals_metadata_upload(self, response, expected_families=F response_json['individualsByGuid']['I000001_na19675']['absentFeatures'], [{'id': 'HP:0012469', 'category': 'HP:0025031', 'label': 'Infantile spasms'}] ) - self.assertEqual(response_json['individualsByGuid']['I000001_na19675']['sex'], 'M') + self.assertEqual(response_json['individualsByGuid']['I000001_na19675']['sex'], 'XXY') self.assertEqual(response_json['individualsByGuid']['I000001_na19675']['birthYear'], 2000) self.assertTrue(response_json['individualsByGuid']['I000001_na19675']['affectedRelatives']) self.assertEqual(response_json['individualsByGuid']['I000001_na19675']['onsetAge'], 'J') diff --git a/seqr/views/apis/report_api_tests.py b/seqr/views/apis/report_api_tests.py index 767af58fe3..bf1f6c0f98 100644 --- a/seqr/views/apis/report_api_tests.py +++ b/seqr/views/apis/report_api_tests.py @@ -526,11 +526,11 @@ 'missing_variant_case', ], [ 'Broad_NA19675_1', 'Broad_1kg project nme with unide', 'BROAD', 'HMB', 'Yes', 'IKBKAP|CCDC102B|CMA - normal', - '34415322', 'Broad_1', 'Broad_NA19678', 'Broad_NA19679', '', 'Self', '', 'Male', '', + '34415322', 'Broad_1', 'Broad_NA19678', 'Broad_NA19679', '', 'Self', '', 'Male', 'XXY', 'Middle Eastern or North African', '', '', '21', 'Affected', 'myopathy', '18', 'Unsolved', 'No', ], [ 'Broad_HG00731', 'Broad_1kg project nme with unide', 'BROAD', 'HMB', '', '', '', 'Broad_2', 'Broad_HG00732', - 'Broad_HG00733', '', 'Self', '', 'Female', '', '', 'Hispanic or Latino', 'Other', '', 'Affected', + 'Broad_HG00733', '', 'Self', '', 'Female', 'X0', '', 'Hispanic or Latino', 'Other', '', 'Affected', 'microcephaly; seizures', '', 'Unsolved', 'No', ], [ 'Broad_HG00732', 'Broad_1kg project nme with unide', 'BROAD', 'HMB', '', '', '', 'Broad_2', '0', '0', '', @@ -875,14 +875,14 @@ def _test_gregor_export(self, url, mock_subprocess, mock_temp_dir, mock_open, mo [c for c in PARTICIPANT_TABLE[0] if c not in {'pmid_id', 'ancestry_detail', 'age_at_last_observation', 'missing_variant_case'}], [ 'Broad_NA19675_1', 'Broad_1kg project nme with unide', 'BROAD', 'HMB', 'Yes', 'IKBKAP|CCDC102B|CMA - normal', - 'Broad_1', 'Broad_NA19678', 'Broad_NA19679', '', 'Self', '', 'Male', '', 'Middle Eastern or North African', + 'Broad_1', 'Broad_NA19678', 'Broad_NA19679', '', 'Self', '', 'Male', 'XXY', 'Middle Eastern or North African', '', 'Affected', 'myopathy', '18', 'Unsolved', ], [ 'Broad_NA19678', 'Broad_1kg project nme with unide', 'BROAD', 'HMB', '', '', 'Broad_1', '0', '0', '', '', '', 'Male', '', '', '', 'Unaffected', 'myopathy', '', 'Unaffected', ], [ 'Broad_HG00731', 'Broad_1kg project nme with unide', 'BROAD', 'HMB', '', '', 'Broad_2', 'Broad_HG00732', - 'Broad_HG00733', '', 'Self', '', 'Female', '', '', 'Hispanic or Latino', 'Affected', + 'Broad_HG00733', '', 'Self', '', 'Female', 'X0', '', 'Hispanic or Latino', 'Affected', 'microcephaly; seizures', '', 'Unsolved', ]], additional_calls=10) self._assert_expected_file(read_file, [READ_TABLE_HEADER, [ diff --git a/seqr/views/apis/summary_data_api_tests.py b/seqr/views/apis/summary_data_api_tests.py index dde9e17a00..15df14d46c 100644 --- a/seqr/views/apis/summary_data_api_tests.py +++ b/seqr/views/apis/summary_data_api_tests.py @@ -73,6 +73,7 @@ 'seqr_chosen_consequence-1': 'intron_variant', "ancestry": "Ashkenazi Jewish", "sex": "Female", + 'sex_detail': None, "chrom-1": "1", "alt-1": "T", "gene_of_interest-1": "OR4G11P", @@ -154,6 +155,7 @@ 'pmid_id': None, 'proband_relationship': 'Self', 'sex': 'Female', + 'sex_detail': None, 'solve_status': 'Unsolved', 'alt-1': 'T', 'chrom-1': '1', diff --git a/seqr/views/apis/variant_search_api_tests.py b/seqr/views/apis/variant_search_api_tests.py index cf9576bf6e..eef51399c7 100644 --- a/seqr/views/apis/variant_search_api_tests.py +++ b/seqr/views/apis/variant_search_api_tests.py @@ -836,7 +836,7 @@ def test_variant_lookup(self, mock_variant_lookup): 'vlmContactEmail': 'test@broadinstitute.org,vlm@broadinstitute.org', }, 'I2_F0_1-10439-AC-A': { - 'affected': 'A', 'familyGuid': 'F0_1-10439-AC-A', 'individualGuid': 'I2_F0_1-10439-AC-A', 'sex': 'F', + 'affected': 'A', 'familyGuid': 'F0_1-10439-AC-A', 'individualGuid': 'I2_F0_1-10439-AC-A', 'sex': 'X0', 'features': [{'category': 'HP:0000707', 'label': '1 terms'}, {'category': 'HP:0001626', 'label': '1 terms'}], 'vlmContactEmail': 'test@broadinstitute.org,vlm@broadinstitute.org', }, diff --git a/seqr/views/utils/anvil_metadata_utils.py b/seqr/views/utils/anvil_metadata_utils.py index 6ad49d87b1..8874a017eb 100644 --- a/seqr/views/utils/anvil_metadata_utils.py +++ b/seqr/views/utils/anvil_metadata_utils.py @@ -293,7 +293,7 @@ def _get_genotype_zygosity(genotype, individual=None, variant=None): num_alt = genotype.get('numAlt') cn = genotype.get('cn') if num_alt == 2 or cn == 0 or (cn != None and cn > 3): - return HEMI if (variant or {}).get('chrom') == 'X' and individual.sex == Individual.SEX_MALE else HOM_ALT + return HEMI if (variant or {}).get('chrom') == 'X' and individual.sex in Individual.MALE_SEXES else HOM_ALT if num_alt == 1 or cn == 1 or cn == 3: return HET return None @@ -420,9 +420,18 @@ def _get_transcript_field(field, config, transcript): def _get_subject_row(individual, has_dbgap_submission, airtable_metadata, individual_ids_map, get_additional_individual_fields, format_id): paternal_ids = individual_ids_map.get(individual.father_id, ('', '')) maternal_ids = individual_ids_map.get(individual.mother_id, ('', '')) + sex = individual.sex + sex_detail = None + if sex in Individual.MALE_ANEUPLOIDIES: + sex_detail = sex + sex = Individual.SEX_MALE + elif sex in Individual.FEMALE_ANEUPLOIDIES: + sex_detail = sex + sex = Individual.SEX_FEMALE subject_row = { 'participant_id': format_id(individual.individual_id), - 'sex': Individual.SEX_LOOKUP[individual.sex], + 'sex': Individual.SEX_LOOKUP[sex], + 'sex_detail': sex_detail, 'reported_race': ANCESTRY_MAP.get(individual.population, ''), 'ancestry_detail': ANCESTRY_DETAIL_MAP.get(individual.population, ''), 'reported_ethnicity': ETHNICITY_MAP.get(individual.population, ''), diff --git a/seqr/views/utils/individual_utils.py b/seqr/views/utils/individual_utils.py index a9bc940dd4..94d93337e4 100644 --- a/seqr/views/utils/individual_utils.py +++ b/seqr/views/utils/individual_utils.py @@ -13,13 +13,6 @@ from seqr.views.utils.pedigree_info_utils import JsonConstants -_SEX_TO_EXPORTED_VALUE = dict(Individual.SEX_LOOKUP) -_SEX_TO_EXPORTED_VALUE['U'] = '' - -__AFFECTED_TO_EXPORTED_VALUE = dict(Individual.AFFECTED_STATUS_LOOKUP) -__AFFECTED_TO_EXPORTED_VALUE['U'] = '' - - def _get_record_family_id(record): # family id will be in different places in the json depending on whether it comes from a flat uploaded file or from the nested individual object return record.get(JsonConstants.FAMILY_ID_COLUMN) or record.get('family', {})['familyId'] diff --git a/seqr/views/utils/pedigree_info_utils.py b/seqr/views/utils/pedigree_info_utils.py index 91b74f8566..87867602b7 100644 --- a/seqr/views/utils/pedigree_info_utils.py +++ b/seqr/views/utils/pedigree_info_utils.py @@ -134,7 +134,7 @@ def _parse_sex(sex): return 'F' elif sex == '0' or not sex or sex.lower() in {'unknown', 'prefer_not_answer'}: return 'U' - return None + return Individual.SEX_LOOKUP.get(sex) def _parse_affected(affected): @@ -292,15 +292,15 @@ def validate_fam_file_records(project, records, fail_on_warnings=False, errors=N # check proband relationship has valid gender if r.get(JsonConstants.PROBAND_RELATIONSHIP) and r.get(JsonConstants.SEX_COLUMN): invalid_choices = {} - if r[JsonConstants.SEX_COLUMN] == Individual.SEX_MALE: + if r[JsonConstants.SEX_COLUMN] in Individual.MALE_SEXES: invalid_choices = Individual.FEMALE_RELATIONSHIP_CHOICES - elif r[JsonConstants.SEX_COLUMN] == Individual.SEX_FEMALE: + elif r[JsonConstants.SEX_COLUMN] in Individual.FEMALE_SEXES: invalid_choices = Individual.MALE_RELATIONSHIP_CHOICES if invalid_choices and r[JsonConstants.PROBAND_RELATIONSHIP] in invalid_choices: message = 'Invalid proband relationship "{relationship}" for {individual_id} with given gender {sex}'.format( relationship=Individual.RELATIONSHIP_LOOKUP[r[JsonConstants.PROBAND_RELATIONSHIP]], individual_id=individual_id, - sex=dict(Individual.SEX_CHOICES)[r[JsonConstants.SEX_COLUMN]] + sex=Individual.SEX_LOOKUP[r[JsonConstants.SEX_COLUMN]] ) if clear_invalid_values: r[JsonConstants.PROBAND_RELATIONSHIP] = None @@ -310,8 +310,8 @@ def validate_fam_file_records(project, records, fail_on_warnings=False, errors=N # check maternal and paternal ids for consistency for parent in [ - ('father', JsonConstants.PATERNAL_ID_COLUMN, 'M'), - ('mother', JsonConstants.MATERNAL_ID_COLUMN, 'F') + ('father', JsonConstants.PATERNAL_ID_COLUMN, Individual.MALE_SEXES), + ('mother', JsonConstants.MATERNAL_ID_COLUMN, Individual.FEMALE_SEXES) ]: _validate_parent(r, *parent, individual_id, family_id, records_by_id, warnings, errors, clear_invalid_values) @@ -345,7 +345,7 @@ def get_valid_hpo_terms(records, additional_feature_columns=None): return set(HumanPhenotypeOntology.objects.filter(hpo_id__in=all_hpo_terms).values_list('hpo_id', flat=True)) -def _validate_parent(row, parent_id_type, parent_id_field, expected_sex, individual_id, family_id, records_by_id, warnings, errors, clear_invalid_values): +def _validate_parent(row, parent_id_type, parent_id_field, expected_sexes, individual_id, family_id, records_by_id, warnings, errors, clear_invalid_values): parent_id = row.get(parent_id_field) if not parent_id: return @@ -367,8 +367,8 @@ def _validate_parent(row, parent_id_type, parent_id_field, expected_sex, individ # is father male and mother female? if JsonConstants.SEX_COLUMN in records_by_id[parent_id]: actual_sex = records_by_id[parent_id][JsonConstants.SEX_COLUMN] - if actual_sex != expected_sex: - actual_sex_label = dict(Individual.SEX_CHOICES)[actual_sex] + if actual_sex not in expected_sexes: + actual_sex_label = Individual.SEX_LOOKUP[actual_sex] errors.append( "%(parent_id)s is recorded as %(actual_sex_label)s sex and also as the %(parent_id_type)s of %(individual_id)s" % locals()) diff --git a/ui/pages/Project/components/FamilyTable/IndividualRow.jsx b/ui/pages/Project/components/FamilyTable/IndividualRow.jsx index 7cc1def185..1b5bccec3b 100644 --- a/ui/pages/Project/components/FamilyTable/IndividualRow.jsx +++ b/ui/pages/Project/components/FamilyTable/IndividualRow.jsx @@ -65,6 +65,15 @@ const IndividualContainer = styled.div` const PaddedRadioButtonGroup = styled(RadioButtonGroup)` padding: 10px; + + .button { + padding-left: 1em !important; + padding-right: 1em !important; + + &.labeled .label { + margin-left: 0px !important; + } + } ` const POPULATION_MAP = { @@ -414,6 +423,8 @@ const CASE_REVIEW_FIELDS = [ ...INDIVIDUAL_FIELDS, ] +const INDIVIDUAL_FIELD_CONFIG_SEX = INDIVIDUAL_FIELD_CONFIGS[INDIVIDUAL_FIELD_SEX] + const NON_CASE_REVIEW_FIELDS = [ { component: OptionFieldView, @@ -430,6 +441,13 @@ const NON_CASE_REVIEW_FIELDS = [ isVisible: caseReviewStatus === CASE_REVIEW_STATUS_MORE_INFO_NEEDED, }), }, + { + field: INDIVIDUAL_FIELD_SEX, + fieldName: INDIVIDUAL_FIELD_CONFIG_SEX.label, + isEditable: false, + component: OptionFieldView, + tagOptions: INDIVIDUAL_FIELD_CONFIG_SEX.formFieldProps.options, + }, ...[ INDIVIDUAL_FIELD_ANALYTE_TYPE, INDIVIDUAL_FIELD_PRIMARY_BIOSAMPLE, diff --git a/ui/pages/Project/selectors.js b/ui/pages/Project/selectors.js index c619ef3b3b..703423596a 100644 --- a/ui/pages/Project/selectors.js +++ b/ui/pages/Project/selectors.js @@ -12,6 +12,7 @@ import { INDIVIDUAL_HAS_DATA_FIELD, MME_TAG_NAME, TISSUE_DISPLAY, + SIMPLIFIED_SEX_LOOKUP, } from 'shared/utils/constants' import { toCamelcase, toSnakecase, snakecaseToTitlecase } from 'shared/utils/stringUtils' @@ -639,8 +640,8 @@ export const getParentOptionsByIndividual = createSelector( ...individuals.reduce((indAcc, { individualGuid }) => ({ ...indAcc, [individualGuid]: { - M: individuals.filter(i => i.sex === 'M' && i.individualGuid !== individualGuid).map(individualOption), - F: individuals.filter(i => i.sex === 'F' && i.individualGuid !== individualGuid).map(individualOption), + M: individuals.filter(i => SIMPLIFIED_SEX_LOOKUP[i.sex] === 'M' && i.individualGuid !== individualGuid).map(individualOption), + F: individuals.filter(i => SIMPLIFIED_SEX_LOOKUP[i.sex] === 'F' && i.individualGuid !== individualGuid).map(individualOption), }, }), {}), }), {}), diff --git a/ui/pages/SummaryData/components/IndividualMetadata.jsx b/ui/pages/SummaryData/components/IndividualMetadata.jsx index 431c8c6d9b..7460a2a278 100644 --- a/ui/pages/SummaryData/components/IndividualMetadata.jsx +++ b/ui/pages/SummaryData/components/IndividualMetadata.jsx @@ -34,7 +34,7 @@ const CORE_COLUMNS = [ { name: 'paternal_id', secondaryExportColumn: 'paternal_guid' }, { name: 'maternal_id', secondaryExportColumn: 'maternal_guid' }, { name: 'proband_relationship' }, - { name: 'sex' }, + { name: 'sex', format: ({ sex, sex_detail: sexDetail }) => (sexDetail ? `${sex} (${sexDetail})` : sex) }, { name: 'ancestry' }, { name: 'affected_status' }, { name: 'hpo_present', style: { minWidth: '400px' } }, diff --git a/ui/pages/SummaryData/components/IndividualMetadata.test.js b/ui/pages/SummaryData/components/IndividualMetadata.test.js index f6abaaf0ac..1166facee6 100644 --- a/ui/pages/SummaryData/components/IndividualMetadata.test.js +++ b/ui/pages/SummaryData/components/IndividualMetadata.test.js @@ -52,6 +52,7 @@ const DATA = [ 'seqr_chosen_consequence-1': 'intron_variant', ancestry: 'Ashkenazi Jewish', sex: 'Female', + sex_detail: 'XXX', 'chrom-1': '1', 'alt-1': 'T', 'gene_of_interest-1': 'OR4G11P', @@ -99,7 +100,7 @@ test('IndividualMetadata render and export', () => { 'phenotype_contribution-2', 'partial_contribution_explained-2', 'notes-2', 'ClinGen_allele_ID-2']) expect(exportConfig.processRow(DATA[0])).toEqual([ 'Test Reprocessed Project', 'R0003_test', '12', 'F000012_12', 'NA20889', 'I000017_na20889', '', '', '', '', - 'Self', 'Female', 'Ashkenazi Jewish', 'Affected', 'HP:0011675 (Arrhythmia)|HP:0001509 ()', '', 'Yes', null, + 'Self', 'Female (XXX)', 'Ashkenazi Jewish', 'Affected', 'HP:0011675 (Arrhythmia)|HP:0001509 ()', '', 'Yes', null, 'OMIM:616126', 'Immunodeficiency 38', 'Autosomal recessive', null, null, undefined, 'Waiting for data', 'Tier 1', 'WES', '2017-02-05', '', undefined, 'Yes', 'NA20889_1_248367227', undefined, '1', 248367227, null, null, 'TC', 'T', 'OR4G11P', 'ENSG00000240361', 'intron_variant', 'ENST00000505820', 'c.3955G>A', 'c.1586-17C>G', 'Heterozygous', null, undefined, undefined, undefined, diff --git a/ui/shared/components/icons/PedigreeIcon.jsx b/ui/shared/components/icons/PedigreeIcon.jsx index 16becb6eaa..04a35d8ec5 100644 --- a/ui/shared/components/icons/PedigreeIcon.jsx +++ b/ui/shared/components/icons/PedigreeIcon.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import { Icon, Popup } from 'semantic-ui-react' import styled from 'styled-components' -import { SEX_LOOKUP, AFFECTED_LOOKUP } from 'shared/utils/constants' +import { SEX_LOOKUP, SIMPLIFIED_SEX_LOOKUP, AFFECTED_LOOKUP } from 'shared/utils/constants' const RotatedIcon = styled(Icon)` transform: rotate(45deg); @@ -33,7 +33,7 @@ const ICON_LOOKUP = { } const PedigreeIcon = React.memo((props) => { - const iconProps = ICON_LOOKUP[`${props.sex}${props.affected}`] + const iconProps = ICON_LOOKUP[`${SIMPLIFIED_SEX_LOOKUP[props.sex]}${props.affected}`] return ( individual.sex === 'M' && (variant.chrom === 'X' || variant.chrom === 'Y') && + (variant, individual) => SIMPLIFIED_SEX_LOOKUP[individual.sex] === 'M' && (variant.chrom === 'X' || variant.chrom === 'Y') && PAR_REGIONS[variant.genomeVersion][variant.chrom].every(region => variant.pos < region[0] || variant.pos > region[1]) const missingParentVariant = variant => (parentGuid) => { @@ -301,6 +302,10 @@ const Genotype = React.memo(({ variant, individual, isCompoundHet, genesById }) warnings.push('Common low heteroplasmy') } + if ((variant.chrom === 'X' || variant.chrom === 'Y') && SIMPLIFIED_SEX_LOOKUP[individual.sex] !== individual.sex) { + warnings.push(`Sex Aneuploidy - ${individual.sex}`) + } + const warning = warnings.join('. ') let previousCall diff --git a/ui/shared/components/panel/view-pedigree-image/LazyPedigreeImagePanel.jsx b/ui/shared/components/panel/view-pedigree-image/LazyPedigreeImagePanel.jsx index 8e5978e652..85e2bff9d2 100644 --- a/ui/shared/components/panel/view-pedigree-image/LazyPedigreeImagePanel.jsx +++ b/ui/shared/components/panel/view-pedigree-image/LazyPedigreeImagePanel.jsx @@ -14,7 +14,7 @@ import { copy_dataset as copyPedigreeDataset, messages as pedigreeMessages } fro import { getIndividualsByFamily } from 'redux/selectors' import { openModal } from 'redux/utils/modalReducer' -import { INDIVIDUAL_FIELD_CONFIGS, INDIVIDUAL_FIELD_SEX, AFFECTED } from 'shared/utils/constants' +import { INDIVIDUAL_FIELD_CONFIGS, INDIVIDUAL_FIELD_SEX, AFFECTED, SIMPLIFIED_SEX_LOOKUP } from 'shared/utils/constants' import { snakecaseToTitlecase } from 'shared/utils/stringUtils' import FormWrapper from '../../form/FormWrapper' import { BooleanCheckbox, InlineToggle, RadioGroup, YearSelector } from '../../form/Inputs' @@ -84,7 +84,7 @@ const INDIVIDUAL_FIELD_MAP = { const PEDIGREE_JS_OPTS = { background: '#fff', diseases: [], - labels: ['label', 'age'], + labels: ['label', 'age', 'aneuploidy'], zoomIn: 3, zoomOut: 3, zoomSrc: ['button'], @@ -180,6 +180,12 @@ class BasePedigreeImage extends React.PureComponent { val = val === AFFECTED } else if (key === 'status') { val = (!!val || val === 0) ? 1 : 0 + } else if (key === 'sex') { + const aneuploidy = val + val = SIMPLIFIED_SEX_LOOKUP[val] + if (aneuploidy !== val) { + acc.aneuploidy = aneuploidy + } } else if (!val && (key === 'mother' || key === 'father')) { return acc } diff --git a/ui/shared/utils/constants.js b/ui/shared/utils/constants.js index 54f7fc72a3..dde73acbc9 100644 --- a/ui/shared/utils/constants.js +++ b/ui/shared/utils/constants.js @@ -394,13 +394,23 @@ export const CATEGORY_FAMILY_FILTERS = { } // INDIVIDUAL FIELDS - +const SEX_MALE = 'M' +const SEX_FEMALE = 'F' +const MALE_ANEUPLOIDIES = ['XXY', 'XYY'] +const FEMALE_ANEUPLOIDIES = ['XXX', 'X0'] export const SEX_OPTIONS = [ { value: 'M', text: 'Male' }, { value: 'F', text: 'Female' }, { value: 'U', text: '?' }, + ...MALE_ANEUPLOIDIES.map(value => ({ value, text: `Male (${value})` })), + ...FEMALE_ANEUPLOIDIES.map(value => ({ value, text: `Female (${value})` })), ] +export const SIMPLIFIED_SEX_LOOKUP = { + ...[SEX_MALE, ...MALE_ANEUPLOIDIES].reduce((acc, val) => ({ ...acc, [val]: SEX_MALE }), {}), + ...[SEX_FEMALE, ...FEMALE_ANEUPLOIDIES].reduce((acc, val) => ({ ...acc, [val]: SEX_FEMALE }), {}), +} + export const SEX_LOOKUP = SEX_OPTIONS.reduce( (acc, opt) => ({ ...acc, @@ -525,7 +535,7 @@ export const INDIVIDUAL_FIELD_CONFIGS = { format: sex => SEX_LOOKUP[sex], width: 3, description: 'Male, Female, or Unknown', - formFieldProps: { component: RadioGroup, options: SEX_OPTIONS }, + formFieldProps: { component: Select, options: SEX_OPTIONS }, }, [INDIVIDUAL_FIELD_AFFECTED]: { label: 'Affected Status',