From f9af77afbfcc3dfbebb93e615af153b1091204aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:11:39 +0000 Subject: [PATCH 01/39] Bump minimatch in /ui Bumps and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together. Updates `minimatch` from 3.1.2 to 3.1.5 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5) Updates `minimatch` from 5.1.2 to 5.1.9 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: minimatch dependency-version: 3.1.5 dependency-type: indirect - dependency-name: minimatch dependency-version: 5.1.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 6f072b7d8d..df87040c3c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -4525,9 +4525,9 @@ } }, "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -12890,9 +12890,9 @@ "dev": true }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -22564,9 +22564,9 @@ } }, "minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "requires": { "brace-expansion": "^2.0.1" @@ -29354,9 +29354,9 @@ "dev": true }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" From 2c2ba267527e5764a682e1ab856b72c5482cf59d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:12:40 +0000 Subject: [PATCH 02/39] Bump svgo from 2.7.0 to 2.8.2 in /ui Bumps [svgo](https://github.com/svg/svgo) from 2.7.0 to 2.8.2. - [Release notes](https://github.com/svg/svgo/releases) - [Commits](https://github.com/svg/svgo/compare/v2.7.0...v2.8.2) --- updated-dependencies: - dependency-name: svgo dependency-version: 2.8.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 62 ++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 6f072b7d8d..2580427590 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2537,15 +2537,6 @@ "node": ">= 6" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -15772,6 +15763,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "dev": true, + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -17045,17 +17045,17 @@ "dev": true }, "node_modules/svgo": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.7.0.tgz", - "integrity": "sha512-aDLsGkre4fTDCWvolyW+fs8ZJFABpzLXbtdK1y71CKnHzAnpDxKXPj2mNKj+pyOXUCzFHzuxRJ94XOFygOWV3w==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", + "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", "dev": true, "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", - "nanocolors": "^0.1.12", + "picocolors": "^1.0.0", + "sax": "^1.5.0", "stable": "^0.1.8" }, "bin": { @@ -17074,12 +17074,6 @@ "node": ">= 10" } }, - "node_modules/svgo/node_modules/nanocolors": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/nanocolors/-/nanocolors-0.1.12.tgz", - "integrity": "sha512-2nMHqg1x5PU+unxX7PGY7AuYxl2qDx7PSrTRjizr8sxdd3l/3hBuWWaki62qmtYm2U5i4Z5E7GbjlyDFhs9/EQ==", - "dev": true - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -20921,12 +20915,6 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true }, - "@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true - }, "@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -31636,6 +31624,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "dev": true + }, "saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -32701,17 +32695,17 @@ "dev": true }, "svgo": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.7.0.tgz", - "integrity": "sha512-aDLsGkre4fTDCWvolyW+fs8ZJFABpzLXbtdK1y71CKnHzAnpDxKXPj2mNKj+pyOXUCzFHzuxRJ94XOFygOWV3w==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", + "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", "dev": true, "requires": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", - "nanocolors": "^0.1.12", + "picocolors": "^1.0.0", + "sax": "^1.5.0", "stable": "^0.1.8" }, "dependencies": { @@ -32720,12 +32714,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true - }, - "nanocolors": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/nanocolors/-/nanocolors-0.1.12.tgz", - "integrity": "sha512-2nMHqg1x5PU+unxX7PGY7AuYxl2qDx7PSrTRjizr8sxdd3l/3hBuWWaki62qmtYm2U5i4Z5E7GbjlyDFhs9/EQ==", - "dev": true } } }, From 14494eb5b93d22047826c5cc9679b2b74c2dde01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:39:32 +0000 Subject: [PATCH 03/39] Bump flatted from 3.1.1 to 3.4.2 in /ui Bumps [flatted](https://github.com/WebReflection/flatted) from 3.1.1 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.1.1...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 6f072b7d8d..d8ebc69b69 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8531,9 +8531,9 @@ } }, "node_modules/flatted": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "node_modules/follow-redirects": { @@ -25847,9 +25847,9 @@ } }, "flatted": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "follow-redirects": { From a6e22507a703bd0a02236c5a058226d08826d3e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:09:42 +0000 Subject: [PATCH 04/39] Bump follow-redirects from 1.15.9 to 1.16.0 in /ui Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.9 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.9...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index b6edfb300b..e674fbdb0f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8537,9 +8537,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -25853,9 +25853,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true }, "foreach": { From c79e63c7900dcc2303e83a7d38188ba59059c112 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 17 Jun 2026 14:37:03 -0400 Subject: [PATCH 05/39] simplify loading kwargs --- seqr/views/apis/data_manager_api.py | 18 ++++++++---------- seqr/views/apis/data_manager_api_tests.py | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/seqr/views/apis/data_manager_api.py b/seqr/views/apis/data_manager_api.py index 2aa72bd842..38a916d07e 100644 --- a/seqr/views/apis/data_manager_api.py +++ b/seqr/views/apis/data_manager_api.py @@ -304,19 +304,17 @@ def load_data(request): if errors: raise ErrorsWarningsException(errors) - is_local = True - success_message = None - error_message = None - if AirtableSession.is_airtable_enabled(): - is_local = False - success_message = f'*{request.user.email}* triggered loading internal {sample_type} {dataset_type} data for {len(individual_ids)} samples in {len(projects)} projects ({"; ".join(sorted(project_counts))})' - error_message = f'ERROR triggering internal {sample_type} {dataset_type} loading' + loading_kwargs = { + 'skip_check_sex_and_relatedness': request_json.get('skipSRChecks', False), + 'skip_expect_tdr_metrics': False, + 'vcf_sample_id_map': vcf_sample_id_map, + 'success_message': f'*{request.user.email}* triggered loading internal {sample_type} {dataset_type} data for {len(individual_ids)} samples in {len(projects)} projects ({"; ".join(sorted(project_counts))})', + 'error_message': f'ERROR triggering internal {sample_type} {dataset_type} loading', + } if AirtableSession.is_airtable_enabled() else {'raise_error': True} success = trigger_data_loading( projects_by_guid.values(), individual_ids, sample_type, dataset_type, request_json['genomeVersion'], - _callset_path(request_json), user=request.user, - skip_check_sex_and_relatedness=request_json.get('skipSRChecks', False), vcf_sample_id_map=vcf_sample_id_map, - raise_error=is_local, skip_expect_tdr_metrics=is_local, success_message=success_message, error_message=error_message, + _callset_path(request_json), user=request.user, **loading_kwargs, ) return create_json_response({'success': success}) diff --git a/seqr/views/apis/data_manager_api_tests.py b/seqr/views/apis/data_manager_api_tests.py index e5b397a934..3a9fde8b45 100644 --- a/seqr/views/apis/data_manager_api_tests.py +++ b/seqr/views/apis/data_manager_api_tests.py @@ -1216,7 +1216,7 @@ def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_t } if self.SKIP_TDR: body['skip_expect_tdr_metrics'] = True - if skip_check_sex_and_relatedness: + if skip_check_sex_and_relatedness or self.SKIP_TDR: body['skip_check_sex_and_relatedness'] = True self.assertDictEqual(json.loads(responses.calls[-1].request.body), body) From 6ed40f9f53ec897b562935a19506698d8c160039 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Thu, 18 Jun 2026 10:57:13 -0400 Subject: [PATCH 06/39] update UI --- .../DataManagement/components/LoadData.jsx | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/ui/pages/DataManagement/components/LoadData.jsx b/ui/pages/DataManagement/components/LoadData.jsx index 5cbb9ead40..36e7664fdc 100644 --- a/ui/pages/DataManagement/components/LoadData.jsx +++ b/ui/pages/DataManagement/components/LoadData.jsx @@ -6,7 +6,7 @@ import { FormSpy } from 'react-final-form' import { getUser } from 'redux/selectors' import { validators } from 'shared/components/form/FormHelpers' import FormWizard from 'shared/components/form/FormWizard' -import { ButtonRadioGroup, InlineToggle } from 'shared/components/form/Inputs' +import { ButtonRadioGroup, CheckboxGroup, InlineToggle } from 'shared/components/form/Inputs' import LoadOptionsSelect from 'shared/components/form/LoadOptionsSelect' import { SAMPLE_TYPE_EXOME, @@ -55,17 +55,49 @@ const FILE_PATH_FIELD = { const CALLSET_PAGE_FIELDS = [ { - ...GENOME_VERSION_FIELD, + name: 'sampleType', + label: 'Sample Type', component: ButtonRadioGroup, + options: [SAMPLE_TYPE_EXOME, SAMPLE_TYPE_GENOME].map(value => ({ value, text: value })), validate: validators.required, }, { - name: 'sampleType', - label: 'Sample Type', + ...GENOME_VERSION_FIELD, component: ButtonRadioGroup, - options: [SAMPLE_TYPE_EXOME, SAMPLE_TYPE_GENOME].map(value => ({ value, text: value })), validate: validators.required, }, + { + name: 'validationsToSkip', + label: 'Skip Validations', + component: CheckboxGroup, + options: [ + { + value: 'validate_expected_contig_frequency', + text: 'Chromosome Frequency', + description: `By default, VCFs will be checked to ensure they have a reasonable number of variants in each + chromosome, and loading will fail if some chromosomes are missing data. If there is a known reason why some + chromosomes may be missing variants, such as with gene panel data, this validation may be safely skipped.`, + }, + { + value: 'validate_sample_type', + text: 'Sample Type', + description: `By default, VCFs will be checked for a representative sample of coding and non-coding SNPs which + will then be used to assess whether the selected Sample Type aligns with the provided data. If there is a + known reason why this validation would fail, such as WES data that uses broader capture regions, this + validation may be safely skipped.`, + }, + { + value: 'validate_no_duplicate_variants', + text: 'Duplicate Variants (modifies data before loading)', + description: `By default, VCFs will be checked to ensure they have no duplicate variants, as all supported + calling pipelines should have a single row per variant. While there are no well supported reasons why a VCF + with duplicate data should be loaded to seqr, we do provide this option to skip that validation and instead + deduplicate the data before running. NOTE: By selecting this option, variants will be ARBITRARILY DEDUPLICATED. + This means duplicate rows will be dropped at random to leave only one row per variant. If you want a more + deterministic approach to merging/ deduplicating your data, this should be done outside of seqr.`, + }, + ], + }, ] const CALLSET_PAGE = { @@ -91,13 +123,6 @@ const MULTI_DATA_TYPE_CALLSET_PAGE = { placeholder: 'gs://', ...FILE_PATH_FIELD, }, - { - name: 'skipSRChecks', - label: 'Skip Sex and Relatedness Checks', - component: InlineToggle, - asFormInput: true, - }, - ...CALLSET_PAGE_FIELDS, { name: 'datasetType', label: 'Dataset Type', @@ -109,6 +134,19 @@ const MULTI_DATA_TYPE_CALLSET_PAGE = { ].map(value => ({ value, text: value.replace('_', '/') })), validate: validators.required, }, + ...CALLSET_PAGE_FIELDS, + { + name: 'skipSRChecks', + label: 'Skip Sex and Relatedness Checks', + component: InlineToggle, + asFormInput: true, + }, + { + name: 'skipTDR', + label: 'Skip TDR Metrics', + component: InlineToggle, + asFormInput: true, + }, ], } From b0c3e61cbe5fab43ce4366acd5cf74ec36572142 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Thu, 18 Jun 2026 11:19:31 -0400 Subject: [PATCH 07/39] actually pass through new validations --- seqr/utils/add_data_utils.py | 7 ++++--- seqr/views/apis/data_manager_api.py | 5 +++-- seqr/views/apis/data_manager_api_tests.py | 25 +++++++++++++++-------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/seqr/utils/add_data_utils.py b/seqr/utils/add_data_utils.py index 2feefa04fa..ad75e28677 100644 --- a/seqr/utils/add_data_utils.py +++ b/seqr/utils/add_data_utils.py @@ -90,7 +90,7 @@ def trigger_rebuild_gt_stats(project, user): def trigger_data_loading(projects: list[Project], individual_ids: list[int], sample_type: str, dataset_type: str, genome_version: str, data_path: str, user: User, raise_error: bool = False, skip_expect_tdr_metrics: bool = True, - skip_check_sex_and_relatedness: bool = True, vcf_sample_id_map=None, + skip_check_sex_and_relatedness: bool = True, validations_to_skip: list[str] = None, vcf_sample_id_map=None, success_message: str = None, error_message: str = None, success_slack_channel: str = SEQR_SLACK_LOADING_NOTIFICATION_CHANNEL): variables = { 'projects_to_run': sorted([p.guid for p in projects]) if projects else None, @@ -99,11 +99,12 @@ def trigger_data_loading(projects: list[Project], individual_ids: list[int], sam 'callset_path': data_path, 'sample_type': sample_type, } - bool_variables = { + conditional_variables = { 'skip_check_sex_and_relatedness': skip_check_sex_and_relatedness, 'skip_expect_tdr_metrics': skip_expect_tdr_metrics, + 'validations_to_skip': validations_to_skip, } - variables.update({k: v for k, v in bool_variables.items() if v}) + variables.update({k: v for k, v in conditional_variables.items() if v}) file_path = _get_pedigree_path(genome_version, sample_type, dataset_type) _upload_data_loading_files(individual_ids, vcf_sample_id_map or {}, user, file_path, raise_error) _write_gene_id_file(user) diff --git a/seqr/views/apis/data_manager_api.py b/seqr/views/apis/data_manager_api.py index 38a916d07e..915250cf34 100644 --- a/seqr/views/apis/data_manager_api.py +++ b/seqr/views/apis/data_manager_api.py @@ -306,7 +306,7 @@ def load_data(request): loading_kwargs = { 'skip_check_sex_and_relatedness': request_json.get('skipSRChecks', False), - 'skip_expect_tdr_metrics': False, + 'skip_expect_tdr_metrics': request_json.get('skipTDR', False), 'vcf_sample_id_map': vcf_sample_id_map, 'success_message': f'*{request.user.email}* triggered loading internal {sample_type} {dataset_type} data for {len(individual_ids)} samples in {len(projects)} projects ({"; ".join(sorted(project_counts))})', 'error_message': f'ERROR triggering internal {sample_type} {dataset_type} loading', @@ -314,7 +314,8 @@ def load_data(request): success = trigger_data_loading( projects_by_guid.values(), individual_ids, sample_type, dataset_type, request_json['genomeVersion'], - _callset_path(request_json), user=request.user, **loading_kwargs, + _callset_path(request_json), user=request.user, validations_to_skip=request_json.get('validationsToSkip'), + **loading_kwargs, ) return create_json_response({'success': success}) diff --git a/seqr/views/apis/data_manager_api_tests.py b/seqr/views/apis/data_manager_api_tests.py index 3a9fde8b45..6efa69dff4 100644 --- a/seqr/views/apis/data_manager_api_tests.py +++ b/seqr/views/apis/data_manager_api_tests.py @@ -161,6 +161,7 @@ 'filePath': '/callset.vcf', 'sampleType': 'WES', 'genomeVersion': '38', + 'validationsToSkip': ['validate_sample_type', 'validate_no_duplicate_variants'] } AIRTABLE_SAMPLE_RECORDS = { @@ -1117,7 +1118,7 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m mock_temp_dir.return_value.__enter__.return_value = '/mock/tmp' body = {**self.REQUEST_BODY, 'projects': [ json.dumps(option) for option in self.PROJECT_OPTIONS + [{'projectGuid': 'R0005_not_project'}] - ], 'vcfSamples': self.VCF_SAMPLES, 'skipSRChecks': True} + ], 'vcfSamples': self.VCF_SAMPLES} response = self.client.post(url, content_type='application/json', data=json.dumps(body)) self.assertEqual(response.status_code, 400) self.assertDictEqual(response.json(), {'error': 'The following projects are invalid: R0005_not_project'}) @@ -1133,7 +1134,7 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m ) self.assertDictEqual(response.json(), {'success': True}) - self._assert_expected_load_data_requests(sample_type='WES', skip_check_sex_and_relatedness=True) + self._assert_expected_load_data_requests(sample_type='WES', skip_check_sex_and_relatedness=True, validations_to_skip=['validate_sample_type', 'validate_no_duplicate_variants']) self._has_expected_ped_files(mock_open, mock_gzip_open, mock_mkdir, 'SNV_INDEL', sample_type='WES', has_remap=bool(self.MOCK_AIRTABLE_KEY)) variables = { @@ -1146,9 +1147,9 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m 'callset_path': f'{self.TRIGGER_CALLSET_DIR}/callset.vcf', 'sample_type': 'WES', 'skip_check_sex_and_relatedness': True, + 'skip_expect_tdr_metrics': True, + 'validations_to_skip': ['validate_sample_type', 'validate_no_duplicate_variants'], } - if self.SKIP_TDR: - variables['skip_expect_tdr_metrics'] = True self._assert_success_notification(variables) # Test loading trigger error @@ -1159,8 +1160,13 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m responses.calls.reset() self.reset_logs() - del body['skipSRChecks'] + body.pop('skipSRChecks', None) + body.pop('skipTDR', None) + del body['validationsToSkip'] del variables['skip_check_sex_and_relatedness'] + del variables['validations_to_skip'] + if not self.SKIP_TDR: + del variables['skip_expect_tdr_metrics'] body.update({'datasetType': 'SV', 'filePath': f'{self.CALLSET_DIR}/sv_callset.vcf'}) self._trigger_error(url, body, variables, mock_open, mock_gzip_open, mock_mkdir) @@ -1203,7 +1209,7 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m }) self.assertEqual(len(responses.calls), 0) - def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_type='WGS', trigger_error=False, skip_project=False, skip_check_sex_and_relatedness=False): + def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_type='WGS', trigger_error=False, skip_project=False, skip_check_sex_and_relatedness=False, validations_to_skip=None): projects = [PROJECT_GUID, NON_ANALYST_PROJECT_GUID] if skip_project: projects = projects[1:] @@ -1214,9 +1220,10 @@ def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_t 'dataset_type': dataset_type, 'reference_genome': 'GRCh38', } - if self.SKIP_TDR: - body['skip_expect_tdr_metrics'] = True + if validations_to_skip: + body['validations_to_skip'] = validations_to_skip if skip_check_sex_and_relatedness or self.SKIP_TDR: + body['skip_expect_tdr_metrics'] = True body['skip_check_sex_and_relatedness'] = True self.assertDictEqual(json.loads(responses.calls[-1].request.body), body) @@ -1474,6 +1481,8 @@ class AnvilDataManagerAPITest(AnvilAuthenticationTestCase, DataManagerAPITest): **CORE_REQUEST_BODY, 'filePath': CALLSET_DIR + CORE_REQUEST_BODY['filePath'], 'datasetType': 'SNV_INDEL', + 'skipSRChecks': True, + 'skipTDR': True } VCF_SAMPLES = [s for s in VCF_SAMPLES if s != 'NA21234'] From b50a1c69d319098a2894daaa992a8cbee3247064 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:23:09 +0000 Subject: [PATCH 08/39] Bump form-data from 3.0.4 to 3.0.5 in /ui Bumps [form-data](https://github.com/form-data/form-data) from 3.0.4 to 3.0.5. - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v3.0.4...v3.0.5) --- updated-dependencies: - dependency-name: form-data dependency-version: 3.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index cbbc6ace05..97b773bdc4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8747,15 +8747,15 @@ } }, "node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", + "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35" }, "engines": { @@ -9189,9 +9189,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dependencies": { "function-bind": "^1.1.2" }, @@ -26058,15 +26058,15 @@ } }, "form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", + "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, @@ -26387,9 +26387,9 @@ } }, "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "requires": { "function-bind": "^1.1.2" } From 73469cf04f2069083d1a21cc4e0d8a8e994c9ac5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:23:16 +0000 Subject: [PATCH 09/39] Bump launch-editor from 2.9.1 to 2.14.1 in /ui Bumps [launch-editor](https://github.com/vitejs/launch-editor) from 2.9.1 to 2.14.1. - [Commits](https://github.com/vitejs/launch-editor/compare/v2.9.1...v2.14.1) --- updated-dependencies: - dependency-name: launch-editor dependency-version: 2.14.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index cbbc6ace05..fc2b73ffec 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12080,13 +12080,13 @@ } }, "node_modules/launch-editor": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", - "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -28724,13 +28724,13 @@ } }, "launch-editor": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", - "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "requires": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" } }, "leven": { From f88ad8803923c15bb57088f6682d5381865395d5 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Thu, 18 Jun 2026 11:31:25 -0400 Subject: [PATCH 10/39] better unit test --- seqr/views/apis/data_manager_api_tests.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/seqr/views/apis/data_manager_api_tests.py b/seqr/views/apis/data_manager_api_tests.py index 6efa69dff4..feb7326730 100644 --- a/seqr/views/apis/data_manager_api_tests.py +++ b/seqr/views/apis/data_manager_api_tests.py @@ -1147,9 +1147,10 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m 'callset_path': f'{self.TRIGGER_CALLSET_DIR}/callset.vcf', 'sample_type': 'WES', 'skip_check_sex_and_relatedness': True, - 'skip_expect_tdr_metrics': True, 'validations_to_skip': ['validate_sample_type', 'validate_no_duplicate_variants'], } + if self.SKIP_TDR: + variables['skip_expect_tdr_metrics'] = True self._assert_success_notification(variables) # Test loading trigger error @@ -1161,12 +1162,11 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m self.reset_logs() body.pop('skipSRChecks', None) - body.pop('skipTDR', None) del body['validationsToSkip'] + body['skipTDR'] = True del variables['skip_check_sex_and_relatedness'] del variables['validations_to_skip'] - if not self.SKIP_TDR: - del variables['skip_expect_tdr_metrics'] + variables['skip_expect_tdr_metrics'] = True body.update({'datasetType': 'SV', 'filePath': f'{self.CALLSET_DIR}/sv_callset.vcf'}) self._trigger_error(url, body, variables, mock_open, mock_gzip_open, mock_mkdir) @@ -1177,6 +1177,7 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m mock_mkdir.reset_mock() body.update({'sampleType': 'WGS', 'projects': [json.dumps(self.PROJECT_OPTION)], 'vcfSamples': VCF_SAMPLES}) del body['datasetType'] + del body['skipTDR'] response = self.client.post(url, content_type='application/json', data=json.dumps(body)) self._test_load_single_project(mock_open, mock_gzip_open, mock_mkdir, response, url=url, body=body) @@ -1209,7 +1210,7 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m }) self.assertEqual(len(responses.calls), 0) - def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_type='WGS', trigger_error=False, skip_project=False, skip_check_sex_and_relatedness=False, validations_to_skip=None): + def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_type='WGS', trigger_error=False, skip_project=False, skip_check_sex_and_relatedness=False, skip_tdr=False, validations_to_skip=None): projects = [PROJECT_GUID, NON_ANALYST_PROJECT_GUID] if skip_project: projects = projects[1:] @@ -1222,15 +1223,16 @@ def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_t } if validations_to_skip: body['validations_to_skip'] = validations_to_skip - if skip_check_sex_and_relatedness or self.SKIP_TDR: + if skip_tdr or self.SKIP_TDR: body['skip_expect_tdr_metrics'] = True + if skip_check_sex_and_relatedness or self.SKIP_TDR: body['skip_check_sex_and_relatedness'] = True self.assertDictEqual(json.loads(responses.calls[-1].request.body), body) def _trigger_error(self, url, body, variables, mock_open, mock_gzip_open, mock_mkdir): responses.add(responses.POST, PIPELINE_RUNNER_URL, status=400) response = self.client.post(url, content_type='application/json', data=json.dumps(body)) - self._assert_expected_load_data_requests(trigger_error=True, dataset_type='GCNV', sample_type='WES') + self._assert_expected_load_data_requests(trigger_error=True, dataset_type='GCNV', sample_type='WES', skip_tdr=True) self._assert_trigger_error(response, body, variables, response_body={ 'error': f'400 Client Error: Bad Request for url: {PIPELINE_RUNNER_URL}' }) @@ -1482,7 +1484,6 @@ class AnvilDataManagerAPITest(AnvilAuthenticationTestCase, DataManagerAPITest): 'filePath': CALLSET_DIR + CORE_REQUEST_BODY['filePath'], 'datasetType': 'SNV_INDEL', 'skipSRChecks': True, - 'skipTDR': True } VCF_SAMPLES = [s for s in VCF_SAMPLES if s != 'NA21234'] From 5f2c7760f01249c66982d741d1cf661cbdd77f17 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Thu, 18 Jun 2026 11:50:21 -0400 Subject: [PATCH 11/39] pr feedback --- ui/shared/components/form/Inputs.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/shared/components/form/Inputs.jsx b/ui/shared/components/form/Inputs.jsx index ed4bd2f797..e374e6c206 100644 --- a/ui/shared/components/form/Inputs.jsx +++ b/ui/shared/components/form/Inputs.jsx @@ -315,7 +315,7 @@ const InlineFormGroup = styled(Form.Group).attrs({ inline: true })` ` const selectAll = (onChange, value, options) => ({ checked }) => { - const remainValue = value.filter(val => !options.find(opt => opt.value === val)) + const remainValue = (value || []).filter(val => !options.find(opt => opt.value === val)) if (checked) { onChange(options.map(option => option.value).concat(remainValue)) } else { @@ -325,9 +325,9 @@ const selectAll = (onChange, value, options) => ({ checked }) => { const selectCheckbox = (onChange, value, option) => ({ checked }) => { if (checked) { - onChange([...value, option.value]) + onChange([...(value || []), option.value]) } else { - onChange(value.filter(val => val !== option.value)) + onChange((value || []).filter(val => val !== option.value)) } } @@ -340,7 +340,7 @@ const chunkArray = (arr, maxChunkSize) => { export const CheckboxGroup = React.memo((props) => { const { value, label, groupLabel, onChange, maxOptionsPerColumn, inline, ...baseProps } = props const options = props.options.map(styledOption) - const numSelected = options.filter(opt => value.includes(opt.value)).length + const numSelected = options.filter(opt => value?.includes(opt.value)).length const optionGroups = maxOptionsPerColumn && options.length > maxOptionsPerColumn ? chunkArray(options, maxOptionsPerColumn) : [options] const optionLists = optionGroups.map(optionGroup => ( @@ -350,7 +350,7 @@ export const CheckboxGroup = React.memo((props) => { From b5ba447dfcf71909eb562625a26cf137727ae5e1 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Mon, 22 Jun 2026 12:05:22 -0400 Subject: [PATCH 12/39] validate skip validation strings are valid --- seqr/utils/add_data_utils.py | 14 ++++++- seqr/views/apis/data_manager_api_tests.py | 51 ++++++++++++++++------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/seqr/utils/add_data_utils.py b/seqr/utils/add_data_utils.py index ad75e28677..81dc2e50a7 100644 --- a/seqr/utils/add_data_utils.py +++ b/seqr/utils/add_data_utils.py @@ -102,7 +102,7 @@ def trigger_data_loading(projects: list[Project], individual_ids: list[int], sam conditional_variables = { 'skip_check_sex_and_relatedness': skip_check_sex_and_relatedness, 'skip_expect_tdr_metrics': skip_expect_tdr_metrics, - 'validations_to_skip': validations_to_skip, + 'validations_to_skip': _valid_validation_to_skip(validations_to_skip, user), } variables.update({k: v for k, v in conditional_variables.items() if v}) file_path = _get_pedigree_path(genome_version, sample_type, dataset_type) @@ -127,6 +127,16 @@ def trigger_data_loading(projects: list[Project], individual_ids: list[int], sam return success +def _valid_validation_to_skip(validations_to_skip: list[str], user: User) -> list[str]: + invalid_validations = [validation for validation in (validations_to_skip or []) if validation not in { + 'validate_expected_contig_frequency', 'validate_sample_type', 'validate_no_duplicate_variants', + }] + if invalid_validations: + validations_to_skip = [validation for validation in validations_to_skip if validation not in invalid_validations] + logger.error(f'Omitted invalid skip validations from loading request: {", ".join(invalid_validations)}', user) + return validations_to_skip + + def _enqueue_pipeline_request(name: str, variables: dict, user: User, raise_error: bool = True, log_error: bool = True): response = None error = None @@ -186,7 +196,7 @@ def _upload_data_loading_files(individual_ids: list[int], vcf_sample_id_map: dic def _write_gene_id_file(user): file_name = 'db_id_to_gene_id' - if does_file_exist(f'{LOADING_DATASETS_DIR}/{file_name}.csv.gz'): + if does_file_exist(f'{LOADING_DATASETS_DIR}/{file_name}.csv.gz', user): return gene_data_loaded = (GeneInfo.objects.filter(gencode_release=int(GeneInfo.CURRENT_VERSION)).exists() and diff --git a/seqr/views/apis/data_manager_api_tests.py b/seqr/views/apis/data_manager_api_tests.py index feb7326730..783029bd83 100644 --- a/seqr/views/apis/data_manager_api_tests.py +++ b/seqr/views/apis/data_manager_api_tests.py @@ -161,7 +161,7 @@ 'filePath': '/callset.vcf', 'sampleType': 'WES', 'genomeVersion': '38', - 'validationsToSkip': ['validate_sample_type', 'validate_no_duplicate_variants'] + 'validationsToSkip': ['validate_sample_type', 'validate_no_duplicate_variants', 'validate_something_else'] } AIRTABLE_SAMPLE_RECORDS = { @@ -1134,8 +1134,8 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m ) self.assertDictEqual(response.json(), {'success': True}) - self._assert_expected_load_data_requests(sample_type='WES', skip_check_sex_and_relatedness=True, validations_to_skip=['validate_sample_type', 'validate_no_duplicate_variants']) - self._has_expected_ped_files(mock_open, mock_gzip_open, mock_mkdir, 'SNV_INDEL', sample_type='WES', has_remap=bool(self.MOCK_AIRTABLE_KEY)) + load_data_logs = self._assert_expected_load_data_requests(sample_type='WES', skip_check_sex_and_relatedness=True, validations_to_skip=['validate_sample_type', 'validate_no_duplicate_variants']) + ped_file_logs = self._has_expected_ped_files(mock_open, mock_gzip_open, mock_mkdir, 'SNV_INDEL', sample_type='WES', has_remap=bool(self.MOCK_AIRTABLE_KEY)) variables = { 'projects_to_run': [ @@ -1153,6 +1153,15 @@ def test_load_data(self, mock_temp_dir, mock_open, mock_gzip_open, mock_mkdir, m variables['skip_expect_tdr_metrics'] = True self._assert_success_notification(variables) + self.assert_json_logs(self.data_manager_user if load_data_logs else self.pm_user, [ + (log, None) for log in load_data_logs + ] + [ + ('Omitted invalid skip validations from loading request: validate_something_else', { + 'severity': 'ERROR', + '@type': 'type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent', + }), + ] + ped_file_logs + [('Triggered Loading Pipeline', {'detail': variables})]) + # Test loading trigger error self._set_file_not_found(has_mv_commands=True) mock_open.reset_mock() @@ -1228,6 +1237,7 @@ def _assert_expected_load_data_requests(self, dataset_type='SNV_INDEL', sample_t if skip_check_sex_and_relatedness or self.SKIP_TDR: body['skip_check_sex_and_relatedness'] = True self.assertDictEqual(json.loads(responses.calls[-1].request.body), body) + return [] def _trigger_error(self, url, body, variables, mock_open, mock_gzip_open, mock_mkdir): responses.add(responses.POST, PIPELINE_RUNNER_URL, status=400) @@ -1284,6 +1294,7 @@ def _has_expected_ped_files(self, mock_open, mock_gzip_open, mock_mkdir, dataset ['R0004_non_analyst_project', 'F000014_14', 'fam14', 'NA21234', '', '', 'F'] + (['ABC123'] if has_remap else []), ['R0004_non_analyst_project', 'F000014_14', 'fam14', 'NA21987', '', '', 'M'] + ([''] if has_remap else []), ]) + return [] def _test_load_single_project(self, mock_open, mock_gzip_open, mock_mkdir, response, *args, **kwargs): self.assertEqual(response.status_code, 200) @@ -1397,23 +1408,23 @@ def _assert_expected_get_projects_requests(self): def _assert_expected_load_data_requests(self, *args, **kwargs): self.assertEqual(len(responses.calls), 1) - super()._assert_expected_load_data_requests(*args, **kwargs) + return super()._assert_expected_load_data_requests(*args, **kwargs) @staticmethod def _local_pedigree_path(dataset_type, sample_type): return f'/local_datasets/GRCh38/{dataset_type}/pedigrees/{sample_type}' def _has_expected_ped_files(self, mock_open, mock_gzip_open, mock_mkdir, dataset_type, *args, sample_type='WGS', has_gene_id_file=False, **kwargs): - super()._has_expected_ped_files(mock_open, mock_gzip_open, mock_mkdir, dataset_type, *args, sample_type, has_gene_id_file=has_gene_id_file, **kwargs) + logs = super()._has_expected_ped_files(mock_open, mock_gzip_open, mock_mkdir, dataset_type, *args, sample_type, has_gene_id_file=has_gene_id_file, **kwargs) call_paths = [self._local_pedigree_path(dataset_type, sample_type)] if not has_gene_id_file: call_paths.append(self.LOCAL_WRITE_DIR) self.assertEqual(mock_mkdir.call_count, len(call_paths)) mock_mkdir.assert_has_calls([mock.call(call_path, exist_ok=True) for call_path in call_paths]) + return logs def _assert_success_notification(self, variables): - self.maxDiff = None - self.assert_json_logs(self.pm_user, [('Triggered Loading Pipeline', {'detail': variables})]) + pass def _trigger_error(self, url, body, variables, mock_open, mock_gzip_open, mock_mkdir): super()._trigger_error(url, body, variables, mock_open, mock_gzip_open, mock_mkdir) @@ -1567,6 +1578,7 @@ def _assert_expected_pm_access(self, get_response, *args, **kwargs): self.assert_json_logs(self.pm_user, [ ('PermissionDenied: Error: To access RDG airtable user must login with Broad email.', {'severity': 'WARNING'}) ]) + self.reset_logs() self.login_data_manager_user() return super()._assert_expected_pm_access(get_response, *args, **kwargs) @@ -1574,15 +1586,19 @@ def _assert_expected_load_data_requests(self, *args, dataset_type='SNV_INDEL', s num_calls = 1 is_gcnv = dataset_type == 'GCNV' required_sample_field = 'gCNV_CallsetPath' if is_gcnv else None + logs = [] if not skip_project: self._assert_expected_airtable_call(required_sample_field, 'R0001_1kg') num_calls += 1 + logs += ['Fetching Samples records 0-2 from airtable', 'Fetched 6 Samples records from airtable'] if (not is_gcnv) and (not skip_project): self._assert_expected_airtable_vcf_id_call(required_sample_field, call_index=1) num_calls += 1 + logs += ['Fetching Samples records 0-4 from airtable', 'Fetched 6 Samples records from airtable'] self.assertEqual(len(responses.calls), num_calls) super()._assert_expected_load_data_requests(*args, dataset_type=dataset_type, skip_project=skip_project, **kwargs) + return logs def _assert_expected_airtable_call(self, required_sample_field, project_guid, call_index=0, additional_filter=None, additional_pdo_statuses='', additional_fields=None): airtable_filters = [ @@ -1707,19 +1723,22 @@ def _has_expected_ped_files(self, mock_open, mock_gzip_open, mock_mkdir, dataset super()._has_expected_ped_files(mock_open, mock_gzip_open, mock_mkdir, dataset_type, sample_type, has_gene_id_file=has_gene_id_file, **kwargs) mock_mkdir.assert_not_called() - expected_calls = [mock.call( + expected_calls = [ f'gsutil mv /mock/tmp/* gs://seqr-loading-temp/v3.1/GRCh38/{dataset_type}/pedigrees/{sample_type}/', - stdout=-1, stderr=-2, shell=True, # nosec - ), mock.call( - 'gsutil ls gs://seqr-loading-temp/v3.1/db_id_to_gene_id.csv.gz', stdout=-1, stderr=-2, shell=True, # nosec - )] + 'gsutil ls gs://seqr-loading-temp/v3.1/db_id_to_gene_id.csv.gz', + ] if not has_gene_id_file: - expected_calls.append(mock.call( - 'gsutil mv /mock/tmp/* gs://seqr-loading-temp/v3.1/', stdout=-1, stderr=-2, shell=True, # nosec - )) + expected_calls.append( + 'gsutil mv /mock/tmp/* gs://seqr-loading-temp/v3.1/', + ) self.assertEqual(self.mock_subprocess.call_count, len(expected_calls)) - self.mock_subprocess.assert_has_calls(expected_calls) + self.mock_subprocess.assert_has_calls([ + mock.call(call, stdout=-1, stderr=-2, shell=True) for call in expected_calls # nosec + ]) self.mock_subprocess.reset_mock() + logs = [(f'==> {call}', None) for call in expected_calls] + logs.insert(2, ('CommandException: One or more URLs matched no objects', {'severity': 'WARNING'})) + return logs def _assert_write_pedigree_error(self, response): self.assertEqual(response.status_code, 200) From a6ae1dfb6c0d9313d17bf30e065a05154ea064df Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 13:34:54 -0400 Subject: [PATCH 13/39] only update variants with caids --- .../management/commands/register_caids.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/clickhouse_search/management/commands/register_caids.py b/clickhouse_search/management/commands/register_caids.py index 72d13929e6..e0cd9a8c34 100644 --- a/clickhouse_search/management/commands/register_caids.py +++ b/clickhouse_search/management/commands/register_caids.py @@ -224,7 +224,7 @@ def handle_api_response( def register_caids( genome_version: Literal[GENOME_VERSION_GRCh37, GENOME_VERSION_GRCh38], variants: Union[list[VariantDetailsGRCh37SnvIndel], list[VariantDetailsSnvIndel]], -) -> int: +) -> tuple[int, Union[list[VariantDetailsGRCh37SnvIndel], list[VariantDetailsSnvIndel]]]: rows = list(ALLELE_REGISTRY_HEADERS[genome_version]) # NB: new copy of the list for variant in variants: chrom, pos, ref, alt = variant.variant_id.split("-") @@ -254,10 +254,14 @@ def register_caids( ) mapped_variants = handle_api_response(genome_version, res) max_key_id = -1 + update_variants = [] for variant in variants: - variant.caid = mapped_variants.get(variant.variant_id, None) + caid = mapped_variants.get(variant.variant_id) + if caid: + variant.caid = caid + update_variants.append(variant) max_key_id = max(max_key_id, variant.key_id) - return max_key_id + return max_key_id, update_variants def join_series(model: Union[VariantDetailsSnvIndel, VariantDetailsGRCh37SnvIndel], min_: int, max_: int): table = model._meta.db_table @@ -316,8 +320,8 @@ def handle(self, *args, **options): break try: - max_key = register_caids(genome_version, variants) - variant_details_model.objects.using('clickhouse_write').bulk_update(variants, ["caid"]) + max_key, update_variants = register_caids(genome_version, variants) + variant_details_model.objects.using('clickhouse_write').bulk_update(update_variants, ["caid"]) # Save current key on every iteration curr_key = max_key version_obj.version = curr_key From e08a433f7220650d5cb73f586ff890d596e188f4 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 14:24:27 -0400 Subject: [PATCH 14/39] return user counts in stats --- seqr/views/apis/report_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index 3d2589a250..4952336479 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from django.db.models import Count, Q, F, Value +from django.contrib.auth.models import User from django.contrib.postgres.aggregates import ArrayAgg import json import re @@ -71,6 +72,14 @@ def seqr_stats(request): for k, v in samples_counts.items(): grouped_sample_counts[k][project_key] = v + user_counts = User.objects.filter(is_active=True).aggregate( + total=Count('id'), + multipleLogins=Count('id', filter=Q(last_login__isnull=False) & ~Q(last_login=F('date_joined'))), + lastMonth=Count('id', filter=Q(last_login__gte=datetime.now() - timedelta(days=30))), + lastYear=Count('id', filter=Q(last_login__gte=datetime.now() - timedelta(days=365))), + thisYear=Count('id', filter=Q(last_login__gte=datetime(datetime.now().year, 1, 1))), + ) + return create_json_response({ 'projectsCount': {k: projects.count() for k, projects in project_models.items()}, 'familiesCount': { @@ -79,6 +88,7 @@ def seqr_stats(request): 'individualsCount': { k: Individual.objects.filter(family__project__in=projects).count() for k, projects in project_models.items() }, + 'usersCounts': user_counts, 'sampleCountsByType': grouped_sample_counts, }) From e43776c38d391494ad2bea4936e8fc067881e166 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 14:36:05 -0400 Subject: [PATCH 15/39] show user stats --- ui/pages/Report/components/SeqrStats.jsx | 85 ++++++++++++++++-------- 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/ui/pages/Report/components/SeqrStats.jsx b/ui/pages/Report/components/SeqrStats.jsx index 26dfb7e6bd..136cda89ce 100644 --- a/ui/pages/Report/components/SeqrStats.jsx +++ b/ui/pages/Report/components/SeqrStats.jsx @@ -1,7 +1,7 @@ import React from 'react' import { connect } from 'react-redux' import PropTypes from 'prop-types' -import { Header, Table } from 'semantic-ui-react' +import { Header, Table, Grid } from 'semantic-ui-react' import { getUser } from 'redux/selectors' import DataLoader from 'shared/components/DataLoader' @@ -22,38 +22,67 @@ const COLUMN_MAP = { DEMO_COLUMN, ], } +const USER_ROWS = [ + { title: 'Total', key: 'total' }, + { title: 'Logged In More Than Once', key: 'multipleLogins' }, + { title: 'Logged In Within 30 Days', key: 'lastMonth' }, + { title: 'Logged In Within 365 Days', key: 'lastYear' }, + { title: 'Logged In This Year', key: 'thisYear' }, +] const SeqrStats = React.memo(({ stats, error, loading, load, user }) => (
- - - - - {COLUMN_MAP[user.isAnvil].map(({ title }) => )} - - - {['Projects', 'Families', 'Individuals'].map(field => ( - - - {COLUMN_MAP[user.isAnvil].map(({ key }) => ( - - ))} - - ))} - {Object.keys(stats.sampleCountsByType || {}).sort().map(sampleTypes => ( - - - {COLUMN_MAP[user.isAnvil].map(({ key }) => ( - - ))} - - ))} -
+ + + + + + + + {COLUMN_MAP[user.isAnvil].map(({ title }) => )} + + + {['Projects', 'Families', 'Individuals'].map(field => ( + + + {COLUMN_MAP[user.isAnvil].map(({ key }) => ( + + ))} + + ))} + {Object.keys(stats.sampleCountsByType || {}).sort().map(sampleTypes => ( + + + {COLUMN_MAP[user.isAnvil].map(({ key }) => ( + + ))} + + ))} +
+
+ + + + + + + + {USER_ROWS.map(({ title, key }) => ( + + + + + ))} + +
+
+
+
)) From 69c078ae0c4a0a5ff61a2c8019a75768ddf05465 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 14:49:58 -0400 Subject: [PATCH 16/39] ad ddownload link --- ui/pages/Report/components/SeqrStats.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/pages/Report/components/SeqrStats.jsx b/ui/pages/Report/components/SeqrStats.jsx index 136cda89ce..7e4732e742 100644 --- a/ui/pages/Report/components/SeqrStats.jsx +++ b/ui/pages/Report/components/SeqrStats.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types' import { Header, Table, Grid } from 'semantic-ui-react' import { getUser } from 'redux/selectors' +import ExportTableButton from 'shared/components/buttons/ExportTableButton' import DataLoader from 'shared/components/DataLoader' import { DATASET_TITLE_LOOKUP } from 'shared/utils/constants' import { getSeqrStatsLoading, getSeqrStatsLoadingError, getSeqrStats } from '../selectors' @@ -30,6 +31,8 @@ const USER_ROWS = [ { title: 'Logged In This Year', key: 'thisYear' }, ] +const EXPORT_SAMPLES_CONFIG = [{ name: 'Sample History', url: '/api/report/sample_stats_download' }] + const SeqrStats = React.memo(({ stats, error, loading, load, user }) => (
@@ -64,6 +67,7 @@ const SeqrStats = React.memo(({ stats, error, loading, load, user }) => ( ))} + From 3535174e93c0f27fa81b1ae7d9a5acce3977b1b6 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 15:01:28 -0400 Subject: [PATCH 17/39] update unit tests --- seqr/views/apis/report_api_tests.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/seqr/views/apis/report_api_tests.py b/seqr/views/apis/report_api_tests.py index 3e46cf9680..55f5641763 100644 --- a/seqr/views/apis/report_api_tests.py +++ b/seqr/views/apis/report_api_tests.py @@ -683,11 +683,14 @@ def test_seqr_stats(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) response_json = response.json() - self.assertSetEqual(set(response_json.keys()), {'projectsCount', 'individualsCount', 'familiesCount', 'sampleCountsByType'}) + self.assertSetEqual(set(response_json.keys()), {'projectsCount', 'individualsCount', 'familiesCount', 'sampleCountsByType', 'usersCounts'}) self.assertDictEqual(response_json['projectsCount'], self.STATS_DATA['projectsCount']) self.assertDictEqual(response_json['individualsCount'], self.STATS_DATA['individualsCount']) self.assertDictEqual(response_json['familiesCount'], self.STATS_DATA['familiesCount']) self.assertDictEqual(response_json['sampleCountsByType'], self.STATS_DATA['sampleCountsByType']) + self.assertDictEqual(response_json['usersCounts'], { + 'total': 10, 'multipleLogins': 7, 'lastMonth': 5, 'lastYear': 5, 'thisYear': 5, + }) self.check_no_analyst_no_access(url, has_override=self.HAS_PM_OVERRIDE) From d7e112c4c11f3a57d27d54cedd49d9e7ff955b90 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 17:21:05 -0400 Subject: [PATCH 18/39] use aggregates and helpr functions for stat aggs --- seqr/views/apis/report_api.py | 93 ++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 41 deletions(-) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index 4952336479..822f6aa935 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from django.db.models import Count, Q, F, Value +from django.db.models.expressions import Case, When from django.contrib.auth.models import User from django.contrib.postgres.aggregates import ArrayAgg import json @@ -40,37 +41,26 @@ @pm_or_analyst_required def seqr_stats(request): - non_demo_projects = Project.objects.filter(is_demo=False) - - project_models = { - 'demo': Project.objects.filter(is_demo=True), - } - if anvil_enabled(): - is_anvil_q = Q(workspace_namespace='') | Q(workspace_namespace__isnull=True) - anvil_projects = non_demo_projects.exclude(is_anvil_q) - internal_ids = get_internal_projects().values_list('id', flat=True) - project_models.update({ - 'internal': anvil_projects.filter(id__in=internal_ids), - 'external': anvil_projects.exclude(id__in=internal_ids), - 'no_anvil': non_demo_projects.filter(is_anvil_q), - }) - else: - project_models.update({ - 'non_demo': non_demo_projects, - }) + project_qs, *dataset_qs = _get_project_aggregated_qs() grouped_sample_counts = defaultdict(dict) - for project_key, projects in project_models.items(): - samples_counts = _get_sample_counts( - Dataset.objects.filter(active_individuals__family__project__in=projects), - count=Count('active_individuals'), - ) - samples_counts.update(_get_sample_counts( - RnaSample.objects.filter(individual__family__project__in=projects, is_active=True).annotate(sample_type=Value('RNA')), - data_type_key='data_type', count=Count('*')) - ) - for k, v in samples_counts.items(): - grouped_sample_counts[k][project_key] = v + for qs in dataset_qs: + for agg in qs.annotate(count=Count('active_individuals')): + grouped_sample_counts[f"{agg['sample_type']}__{agg['dataset_type']}"][_agg_key(agg)] = agg['count'] + + project_aggs = project_qs.annotate( + count=Count('id', distinct=True), + family_count=Count('family', distinct=True), + individual_count=Count('family__individual', distinct=True), + ) + project_counts = {} + families_count = {} + individuals_count = {} + for agg in project_aggs: + key = _agg_key(agg) + project_counts[key] = agg['count'] + families_count[key] = agg['family_count'] + individuals_count[key] = agg['individual_count'] user_counts = User.objects.filter(is_active=True).aggregate( total=Count('id'), @@ -81,23 +71,44 @@ def seqr_stats(request): ) return create_json_response({ - 'projectsCount': {k: projects.count() for k, projects in project_models.items()}, - 'familiesCount': { - k: Family.objects.filter(project__in=projects).count() for k, projects in project_models.items() - }, - 'individualsCount': { - k: Individual.objects.filter(family__project__in=projects).count() for k, projects in project_models.items() - }, + 'projectsCount': project_counts, + 'familiesCount': families_count, + 'individualsCount': individuals_count, 'usersCounts': user_counts, 'sampleCountsByType': grouped_sample_counts, }) -def _get_sample_counts(sample_q, count=None, data_type_key='dataset_type'): - samples_agg = sample_q.values('sample_type', data_type_key).annotate(count=count) - return { - f'{sample_agg["sample_type"]}__{sample_agg[data_type_key]}': sample_agg['count'] for sample_agg in samples_agg - } +def _get_project_aggregated_qs(): + prefixes = ['', 'active_individuals__family__project__', 'individual__family__project__'] + agg_fields = [{'demo': F(f'{prefix}is_demo')} for prefix in prefixes] + if anvil_enabled(): + internal_ids = get_internal_projects().values_list('id', flat=True) + for i, prefix in enumerate(prefixes): + agg_fields[i].update({ + 'no_anvil': Q(**{f'{prefix}workspace_namespace': ''}) | Q(**{f'{prefix}workspace_namespace__isnull': True}), + 'is_internal': Case(When(**{f'{prefix}id__in': internal_ids, 'then': Value(True)}), default=Value(False)), + }) + + model_classes = [ + (Project.objects, []), + (Dataset.objects, ['sample_type', 'dataset_type']), + (RnaSample.objects.annotate(sample_type=Value('RNA'), dataset_type=F('data_type'), active_individuals=F('individual_id')), ['sample_type', 'dataset_type']), + ] + return [ + models.annotate(**agg_fields[i]).filter(demo__isnull=False).values(*agg_fields[i], *values) + for i, (models, values) in enumerate(model_classes) + ] + + +def _agg_key(agg): + if agg['demo']: + return 'demo' + elif agg.get('no_anvil'): + return 'no_anvil' + elif 'is_internal' in agg: + return 'internal' if agg['is_internal'] else 'external' + return 'non_demo' # AnVIL metadata From e97ca6a7142d56337497062551c39390414f8802 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 17:29:42 -0400 Subject: [PATCH 19/39] better logic abstraction --- seqr/views/apis/report_api.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index 822f6aa935..e4212cc7e3 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -41,11 +41,11 @@ @pm_or_analyst_required def seqr_stats(request): - project_qs, *dataset_qs = _get_project_aggregated_qs() + project_qs, dataset_qs = _get_project_aggregated_qs(additional_model=(Project, '')) grouped_sample_counts = defaultdict(dict) for qs in dataset_qs: - for agg in qs.annotate(count=Count('active_individuals')): + for agg in qs: grouped_sample_counts[f"{agg['sample_type']}__{agg['dataset_type']}"][_agg_key(agg)] = agg['count'] project_aggs = project_qs.annotate( @@ -79,8 +79,10 @@ def seqr_stats(request): }) -def _get_project_aggregated_qs(): - prefixes = ['', 'active_individuals__family__project__', 'individual__family__project__'] +def _get_project_aggregated_qs(additional_model=None): + prefixes = ['active_individuals__family__project__', 'individual__family__project__'] + if additional_model: + prefixes.append(additional_model[1]) agg_fields = [{'demo': F(f'{prefix}is_demo')} for prefix in prefixes] if anvil_enabled(): internal_ids = get_internal_projects().values_list('id', flat=True) @@ -90,16 +92,21 @@ def _get_project_aggregated_qs(): 'is_internal': Case(When(**{f'{prefix}id__in': internal_ids, 'then': Value(True)}), default=Value(False)), }) - model_classes = [ - (Project.objects, []), - (Dataset.objects, ['sample_type', 'dataset_type']), - (RnaSample.objects.annotate(sample_type=Value('RNA'), dataset_type=F('data_type'), active_individuals=F('individual_id')), ['sample_type', 'dataset_type']), - ] - return [ - models.annotate(**agg_fields[i]).filter(demo__isnull=False).values(*agg_fields[i], *values) - for i, (models, values) in enumerate(model_classes) + dataset_qs = [ + models.annotate(**agg_fields[i]).filter(demo__isnull=False).values( + 'sample_type', 'dataset_type', *agg_fields[i], + ).annotate(count=Count('active_individuals')) for i, models in enumerate([ + Dataset.objects, + RnaSample.objects.annotate(sample_type=Value('RNA'), dataset_type=F('data_type'), active_individuals=F('individual_id')), + ]) ] + if additional_model: + fields = agg_fields[-1] + return additional_model[0].objects.annotate(**fields).values(*fields), dataset_qs + + return dataset_qs + def _agg_key(agg): if agg['demo']: From 39886b4688d06d90c2052946e6af0181ad031751 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 23 Jun 2026 17:36:39 -0400 Subject: [PATCH 20/39] better variable name --- seqr/views/apis/report_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index e4212cc7e3..bb592968b9 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -41,10 +41,10 @@ @pm_or_analyst_required def seqr_stats(request): - project_qs, dataset_qs = _get_project_aggregated_qs(additional_model=(Project, '')) + project_qs, dataset_qs_list = _get_project_aggregated_qs(additional_model=(Project, '')) grouped_sample_counts = defaultdict(dict) - for qs in dataset_qs: + for qs in dataset_qs_list: for agg in qs: grouped_sample_counts[f"{agg['sample_type']}__{agg['dataset_type']}"][_agg_key(agg)] = agg['count'] From d8c2feb3ecbcc835ac6ca4060e27f6765ff40ad8 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 24 Jun 2026 16:14:23 -0400 Subject: [PATCH 21/39] add sample stats download endpoint --- seqr/urls.py | 2 ++ seqr/views/apis/report_api.py | 43 ++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/seqr/urls.py b/seqr/urls.py index 5c6bf5cef7..0f4acf20e9 100644 --- a/seqr/urls.py +++ b/seqr/urls.py @@ -125,6 +125,7 @@ family_metadata, \ variant_metadata, \ gregor_export, \ + sample_stats_download, \ seqr_stats from seqr.views.apis.summary_data_api import success_story, saved_variants_page, mme_details, hpo_summary_data, \ bulk_update_family_external_analysis, individual_metadata @@ -325,6 +326,7 @@ 'report/variant_metadata/(?P[^/]+)': variant_metadata, 'report/gregor': gregor_export, 'report/seqr_stats': seqr_stats, + 'report/sample_stats_download': sample_stats_download, 'data_management/get_all_users': get_all_users, 'data_management/update_rna_seq': update_rna_seq, diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index bb592968b9..f045c1bd94 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from django.db.models import Count, Q, F, Value from django.db.models.expressions import Case, When +from django.db.models.functions import TruncDate from django.contrib.auth.models import User from django.contrib.postgres.aggregates import ArrayAgg import json @@ -18,7 +19,7 @@ from seqr.views.utils.anvil_metadata_utils import parse_anvil_metadata, anvil_export_airtable_fields, \ FAMILY_ROW_TYPE, SUBJECT_ROW_TYPE, SAMPLE_ROW_TYPE, DISCOVERY_ROW_TYPE, PARTICIPANT_TABLE, PHENOTYPE_TABLE, \ EXPERIMENT_TABLE, EXPERIMENT_LOOKUP_TABLE, FINDINGS_TABLE, GENE_COLUMN, FAMILY_INDIVIDUAL_FIELDS -from seqr.views.utils.export_utils import export_multiple_files, write_multiple_files +from seqr.views.utils.export_utils import export_multiple_files, write_multiple_files, export_table from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_queryset from seqr.views.utils.permissions_utils import user_is_analyst, get_project_and_check_permissions, \ @@ -79,7 +80,7 @@ def seqr_stats(request): }) -def _get_project_aggregated_qs(additional_model=None): +def _get_project_aggregated_qs(additional_model=None, additional_aggs=None): prefixes = ['active_individuals__family__project__', 'individual__family__project__'] if additional_model: prefixes.append(additional_model[1]) @@ -93,11 +94,12 @@ def _get_project_aggregated_qs(additional_model=None): }) dataset_qs = [ - models.annotate(**agg_fields[i]).filter(demo__isnull=False).values( - 'sample_type', 'dataset_type', *agg_fields[i], + models.annotate(**agg_fields[i], **(additional_aggs or {})).filter(demo__isnull=False).values( + 'sample_type', 'dataset_type', *agg_fields[i], *(additional_aggs or []), ).annotate(count=Count('active_individuals')) for i, models in enumerate([ - Dataset.objects, - RnaSample.objects.annotate(sample_type=Value('RNA'), dataset_type=F('data_type'), active_individuals=F('individual_id')), + Dataset.objects, RnaSample.objects.annotate( + sample_type=Value('RNA'), dataset_type=F('data_type'), active_individuals=F('individual_id'), loaded_date=F('created_date'), + ), ]) ] @@ -118,6 +120,35 @@ def _agg_key(agg): return 'non_demo' +HEADER = ['date_loaded', 'num_samples', 'sample_type', 'dataset_type', 'is_demo'] +DATASET_TYPE_LOOKUP = { + **RnaSample.DATA_TYPE_LOOKUP, + **{k: v.split(' ')[0] for k, v in Dataset.DATASET_TYPE_LOOKUP.items()}, +} + + +@pm_or_analyst_required +def sample_stats_download(request): + dataset_qs_list = _get_project_aggregated_qs(additional_aggs={'loaded': TruncDate('loaded_date')}) + rows = sorted([_format_export_row(**item) for qs in dataset_qs_list for item in qs], key=lambda row: row[0]) + + header = HEADER + if anvil_enabled(): + header = header + ['anvil_status'] + + file_format = request.GET.get('file_format', 'tsv') + return export_table('seqr_sample_loading', header, rows, file_format) + + +def _format_export_row(loaded, count, sample_type, dataset_type, demo, no_anvil=0, is_internal=0): + row = [loaded.strftime('%Y-%m-%d'), count, sample_type, DATASET_TYPE_LOOKUP.get(dataset_type, 'Unknown'), demo] + if no_anvil: + row.append('No AnVIL') + elif is_internal is not 0: + row.append('Internal' if is_internal else 'External') + return row + + # AnVIL metadata SUBJECT_TABLE_COLUMNS = [ From 4d148e6965fa491024a19d4de339b7d05a263f6f Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 24 Jun 2026 16:50:19 -0400 Subject: [PATCH 22/39] test download --- seqr/views/apis/report_api.py | 5 +++- seqr/views/apis/report_api_tests.py | 41 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index f045c1bd94..77da8520d8 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -130,7 +130,10 @@ def _agg_key(agg): @pm_or_analyst_required def sample_stats_download(request): dataset_qs_list = _get_project_aggregated_qs(additional_aggs={'loaded': TruncDate('loaded_date')}) - rows = sorted([_format_export_row(**item) for qs in dataset_qs_list for item in qs], key=lambda row: row[0]) + rows = sorted( + [_format_export_row(**item) for qs in dataset_qs_list for item in qs], + key=lambda row: (row[0], -row[1], *row[2:]), + ) header = HEADER if anvil_enabled(): diff --git a/seqr/views/apis/report_api_tests.py b/seqr/views/apis/report_api_tests.py index 55f5641763..50492f9214 100644 --- a/seqr/views/apis/report_api_tests.py +++ b/seqr/views/apis/report_api_tests.py @@ -5,7 +5,7 @@ from settings import AIRTABLE_URL from seqr.models import Project, RnaSample -from seqr.views.apis.report_api import seqr_stats, anvil_export, gregor_export, family_metadata, variant_metadata +from seqr.views.apis.report_api import seqr_stats, sample_stats_download, anvil_export, gregor_export, family_metadata, variant_metadata from seqr.views.utils.test_utils import AuthenticationTestCase, AnvilAuthenticationTestCase, AirtableTest @@ -694,6 +694,22 @@ def test_seqr_stats(self): self.check_no_analyst_no_access(url, has_override=self.HAS_PM_OVERRIDE) + def test_sample_stats_download(self): + no_access_project = Project.objects.get(id=3) + no_access_project.workspace_namespace = None + no_access_project.save() + + url = reverse(sample_stats_download) + self.check_analyst_login(url) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertListEqual( + [line.split('\t') for line in response.content.decode().strip('\n').split('\n')], self.EXPORT_DATA, + ) + + self.check_no_analyst_no_access(url, has_override=self.HAS_PM_OVERRIDE) + @mock.patch('seqr.views.utils.export_utils.zipfile.ZipFile') @responses.activate def test_anvil_export(self, mock_zip): @@ -1560,6 +1576,17 @@ class LocalReportAPITest(AuthenticationTestCase, ReportAPITest): 'RNA__E': {'non_demo': 1}, }, } + EXPORT_DATA = [ + ['date_loaded', 'num_samples', 'sample_type', 'dataset_type', 'is_demo'], + ['2017-02-05', '7', 'WES', 'Variant', 'False'], + ['2017-02-05', '3', 'RNA', 'Splice Outlier', 'False'], + ['2017-02-05', '2', 'RNA', 'TPM', 'False'], + ['2017-02-05', '1', 'RNA', 'Expression Outlier', 'False'], + ['2018-02-05', '3', 'WES', 'SV', 'False'], + ['2018-02-05', '3', 'WGS', 'SV', 'False'], + ['2020-02-05', '1', 'WGS', 'Variant', 'True'], + ['2022-02-05', '1', 'WES', 'Mitochondria', 'False'], + ] def _check_anvil_export_response(self, response, *args): self.assertEqual(response.status_code, 403) @@ -1587,3 +1614,15 @@ class AnvilReportAPITest(AnvilAuthenticationTestCase, ReportAPITest): 'RNA__E': {'internal': 1}, }, } + EXPORT_DATA = [ + ['date_loaded', 'num_samples', 'sample_type', 'dataset_type', 'is_demo', 'anvil_status'], + ['2017-02-05', '7', 'WES', 'Variant', 'False', 'Internal'], + ['2017-02-05', '2', 'RNA', 'Splice Outlier', 'False', 'Internal'], + ['2017-02-05', '2', 'RNA', 'TPM', 'False', 'Internal'], + ['2017-02-05', '1', 'RNA', 'Expression Outlier', 'False', 'Internal'], + ['2017-02-05', '1', 'RNA', 'Splice Outlier', 'False', 'External'], + ['2018-02-05', '3', 'WES', 'SV', 'False', 'Internal'], + ['2018-02-05', '3', 'WGS', 'SV', 'False', 'External'], + ['2020-02-05', '1', 'WGS', 'Variant', 'True', 'No AnVIL'], + ['2022-02-05', '1', 'WES', 'Mitochondria', 'False', 'Internal'], + ] From ef887da4d7c8799400b28b92e658b8341290e507 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Thu, 25 Jun 2026 17:29:07 -0400 Subject: [PATCH 23/39] pr feedback --- seqr/views/apis/report_api.py | 13 +++++++------ ui/pages/Report/components/SeqrStats.jsx | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index 77da8520d8..c72c98633d 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -63,12 +63,13 @@ def seqr_stats(request): families_count[key] = agg['family_count'] individuals_count[key] = agg['individual_count'] + now = datetime.now() user_counts = User.objects.filter(is_active=True).aggregate( total=Count('id'), multipleLogins=Count('id', filter=Q(last_login__isnull=False) & ~Q(last_login=F('date_joined'))), - lastMonth=Count('id', filter=Q(last_login__gte=datetime.now() - timedelta(days=30))), - lastYear=Count('id', filter=Q(last_login__gte=datetime.now() - timedelta(days=365))), - thisYear=Count('id', filter=Q(last_login__gte=datetime(datetime.now().year, 1, 1))), + lastMonth=Count('id', filter=Q(last_login__gte=now - timedelta(days=30))), + lastYear=Count('id', filter=Q(last_login__gte=now - timedelta(days=365))), + thisYear=Count('id', filter=Q(last_login__gte=datetime(now.year, 1, 1))), ) return create_json_response({ @@ -97,7 +98,7 @@ def _get_project_aggregated_qs(additional_model=None, additional_aggs=None): models.annotate(**agg_fields[i], **(additional_aggs or {})).filter(demo__isnull=False).values( 'sample_type', 'dataset_type', *agg_fields[i], *(additional_aggs or []), ).annotate(count=Count('active_individuals')) for i, models in enumerate([ - Dataset.objects, RnaSample.objects.annotate( + Dataset.objects, RnaSample.objects.filter(is_active=True).annotate( sample_type=Value('RNA'), dataset_type=F('data_type'), active_individuals=F('individual_id'), loaded_date=F('created_date'), ), ]) @@ -143,11 +144,11 @@ def sample_stats_download(request): return export_table('seqr_sample_loading', header, rows, file_format) -def _format_export_row(loaded, count, sample_type, dataset_type, demo, no_anvil=0, is_internal=0): +def _format_export_row(loaded, count, sample_type, dataset_type, demo, no_anvil='', is_internal=''): row = [loaded.strftime('%Y-%m-%d'), count, sample_type, DATASET_TYPE_LOOKUP.get(dataset_type, 'Unknown'), demo] if no_anvil: row.append('No AnVIL') - elif is_internal is not 0: + elif is_internal != '': row.append('Internal' if is_internal else 'External') return row diff --git a/ui/pages/Report/components/SeqrStats.jsx b/ui/pages/Report/components/SeqrStats.jsx index 7e4732e742..ae030eae88 100644 --- a/ui/pages/Report/components/SeqrStats.jsx +++ b/ui/pages/Report/components/SeqrStats.jsx @@ -76,13 +76,15 @@ const SeqrStats = React.memo(({ stats, error, loading, load, user }) => ( + + {USER_ROWS.map(({ title, key }) => ( ))} - +
From 36533ce5409da1f8780dc5f0754e6bb10ffb421d Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 12:11:47 -0400 Subject: [PATCH 24/39] handle multiple anvil statuses for deom projects --- seqr/views/apis/report_api.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index c72c98633d..b99627cc5a 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -44,24 +44,24 @@ def seqr_stats(request): project_qs, dataset_qs_list = _get_project_aggregated_qs(additional_model=(Project, '')) - grouped_sample_counts = defaultdict(dict) + grouped_sample_counts = defaultdict(lambda: defaultdict(int)) for qs in dataset_qs_list: for agg in qs: - grouped_sample_counts[f"{agg['sample_type']}__{agg['dataset_type']}"][_agg_key(agg)] = agg['count'] + grouped_sample_counts[f"{agg['sample_type']}__{agg['dataset_type']}"][_agg_key(agg)] += agg['count'] project_aggs = project_qs.annotate( count=Count('id', distinct=True), family_count=Count('family', distinct=True), individual_count=Count('family__individual', distinct=True), ) - project_counts = {} - families_count = {} - individuals_count = {} + project_counts = defaultdict(int) + families_count = defaultdict(int) + individuals_count = defaultdict(int) for agg in project_aggs: key = _agg_key(agg) - project_counts[key] = agg['count'] - families_count[key] = agg['family_count'] - individuals_count[key] = agg['individual_count'] + project_counts[key] += agg['count'] + families_count[key] += agg['family_count'] + individuals_count[key] += agg['individual_count'] now = datetime.now() user_counts = User.objects.filter(is_active=True).aggregate( From 071b2d21b198b8c1046d70a93df57fe89858fbc4 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 12:23:44 -0400 Subject: [PATCH 25/39] regression test --- .../fixtures/variant_details_for_update.json | 2 +- .../management/tests/register_caids_tests.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/clickhouse_search/fixtures/variant_details_for_update.json b/clickhouse_search/fixtures/variant_details_for_update.json index d8249f0296..45fe5e5d9f 100644 --- a/clickhouse_search/fixtures/variant_details_for_update.json +++ b/clickhouse_search/fixtures/variant_details_for_update.json @@ -83,7 +83,7 @@ "key": 6, "variant_id": "1-94818-T-C", "rsid": null, - "caid": null, + "caid": "CA16717153", "lifted_over_chrom": "1", "lifted_over_pos": 94818, "sorted_regulatory_feature_consequences": [], diff --git a/clickhouse_search/management/tests/register_caids_tests.py b/clickhouse_search/management/tests/register_caids_tests.py index d0c27f257f..927d72e3ee 100644 --- a/clickhouse_search/management/tests/register_caids_tests.py +++ b/clickhouse_search/management/tests/register_caids_tests.py @@ -269,8 +269,16 @@ def test_register_caids(self, mock_safe_post_to_slack, mock_logger): 'MESSAGE: 1\t10469\trs370233998\tC\tG\t.\t.\t.\n' 'INPUT_LINE: Cannot align NC_000001.10 [10468,10469).', ) - vd = VariantDetailsSnvIndel.objects.get(variant_id='1-91511686-T-G') - self.assertEqual(vd.caid, 'CA997563840') + self.assertListEqual(list(VariantDetailsSnvIndel.objects.order_by('key').values_list('variant_id', 'caid')), [ + ('1-10439-AC-A', 'CA16717152'), + ('1-38724419-T-G', None), + ('1-91502721-G-A', None), + ('1-91511686-T-G', 'CA997563840'), + ('1-10146-ACC-A', 'CA997563845'), + ('1-94818-T-C', 'CA16717153'), + ('7-143270172-A-G', None), + ('7-9310123-T-C', None) + ]) # Ensure re-calling is a no-op responses.reset() From b505af0fecd5640af61f34f1d3126633f894d9e6 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 15:59:56 -0400 Subject: [PATCH 26/39] family perm helper --- matchmaker/views/matchmaker_api.py | 4 ++-- seqr/views/utils/permissions_utils.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/matchmaker/views/matchmaker_api.py b/matchmaker/views/matchmaker_api.py index be0ea94e25..0e55152f47 100644 --- a/matchmaker/views/matchmaker_api.py +++ b/matchmaker/views/matchmaker_api.py @@ -36,7 +36,7 @@ @login_and_policies_required def get_individual_mme_matches(request, submission_guid): submission = MatchmakerSubmission.objects.get(guid=submission_guid) - project = check_mme_permissions(submission, request.user) + genome_version = check_mme_permissions(submission, request.user) results = MatchmakerResult.objects.filter(submission=submission) @@ -51,7 +51,7 @@ def get_individual_mme_matches(request, submission_guid): variants = get_json_for_saved_variants( SavedVariant.objects.filter(guid__in=variant_guids), additional_values={ - 'genomeVersion': Value(project.genome_version), + 'genomeVersion': Value(genome_version), 'selectedMainTranscript': F('main_transcript'), 'xposEnd': F('xpos_end'), }, diff --git a/seqr/views/utils/permissions_utils.py b/seqr/views/utils/permissions_utils.py index d5a03f97cd..6f2fce851c 100644 --- a/seqr/views/utils/permissions_utils.py +++ b/seqr/views/utils/permissions_utils.py @@ -210,6 +210,11 @@ def check_project_permissions(project, user, **kwargs): user=user, project=project)) +def check_family_view_permissions(family, user): + check_project_permissions(family.project, user) + # TODO check analysis group perms + + def _is_user_created_object(obj, user): return obj.created_by == user @@ -268,11 +273,12 @@ def get_project_guids_user_can_view(user, limit_data_manager=True): def check_mme_permissions(submission, user): - project = submission.individual.family.project - check_project_permissions(project, user) + family = submission.individual.family + project = family.project + check_family_view_permissions(family, user) if not (project.is_mme_enabled and not project.is_demo): raise PermissionDenied('Matchmaker is not enabled') - return project + return project.genome_version def has_case_review_permissions(project, user): if not project.has_case_review: From 7c7aa9864fe7d074e6f71797b276671952dc7b47 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 16:25:10 -0400 Subject: [PATCH 27/39] family specific helper for check perm where possible --- matchmaker/views/matchmaker_api.py | 4 ++-- seqr/views/apis/family_api.py | 30 +++++++++++++-------------- seqr/views/apis/individual_api.py | 14 ++++++------- seqr/views/apis/saved_variant_api.py | 26 +++++++++++------------ seqr/views/apis/variant_search_api.py | 4 ++-- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/matchmaker/views/matchmaker_api.py b/matchmaker/views/matchmaker_api.py index 0e55152f47..c6c0532681 100644 --- a/matchmaker/views/matchmaker_api.py +++ b/matchmaker/views/matchmaker_api.py @@ -20,7 +20,7 @@ from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import _get_json_for_model, get_json_for_saved_variants, \ get_json_for_matchmaker_submission, get_json_for_matchmaker_submissions -from seqr.views.utils.permissions_utils import check_mme_permissions, check_project_permissions, analyst_required, \ +from seqr.views.utils.permissions_utils import check_mme_permissions, check_family_view_permissions, analyst_required, \ has_project_permissions, login_and_policies_required, get_project_and_check_permissions from settings import BASE_URL, MME_ACCEPT_HEADER, MME_NODES, MME_DEFAULT_CONTACT_EMAIL, \ @@ -273,7 +273,7 @@ def update_mme_submission(request, submission_guid=None): if not individual_guid: return create_json_response({}, status=400, reason='Individual is required for a new submission') individual = Individual.objects.get(guid=individual_guid) - check_project_permissions(individual.family.project, request.user) + check_family_view_permissions(individual.family, request.user) submission = create_model_from_json(MatchmakerSubmission, { 'individual': individual, 'submission_id': individual.guid, diff --git a/seqr/views/apis/family_api.py b/seqr/views/apis/family_api.py index 493bc8a380..8456e8306f 100644 --- a/seqr/views/apis/family_api.py +++ b/seqr/views/apis/family_api.py @@ -23,7 +23,7 @@ PhenotypePrioritization, Project, RnaSample from seqr.views.utils.permissions_utils import check_project_permissions, get_project_and_check_pm_permissions, \ login_and_policies_required, user_is_analyst, has_case_review_permissions, external_anvil_project_can_edit, \ - get_internal_projects, get_project_guids_user_can_view + get_internal_projects, get_project_guids_user_can_view, check_family_view_permissions from seqr.views.utils.terra_api_utils import anvil_enabled from seqr.views.utils.variant_utils import get_phenotype_prioritization, get_omim_intervals_query, DISCOVERY_CATEGORY from seqr.utils.xpos_utils import get_chrom_pos @@ -39,7 +39,7 @@ def family_page_data(request, family_guid): families = Family.objects.filter(guid=family_guid) family = families.get(guid=family_guid) project = family.project - check_project_permissions(project, request.user) + check_family_view_permissions(family, request.user) is_analyst = user_is_analyst(request.user) has_case_review_perm = has_case_review_permissions(project, request.user) @@ -122,8 +122,7 @@ def _add_parsed_omims(omims, omim_map, intervals=None): @login_and_policies_required def family_variant_tag_summary(request, family_guid): family = Family.objects.get(guid=family_guid) - project = family.project - check_project_permissions(project, request.user) + check_family_view_permissions(family, request.user) response = families_discovery_tags([{'familyGuid': family_guid}]) @@ -135,6 +134,7 @@ def family_variant_tag_summary(request, family_guid): response['familyTagTypeCounts'][family_guid][MME_TAG_NAME] = tags.filter( saved_variants__matchmakersubmissiongenes__isnull=False).values('saved_variants__guid').distinct().count() + project = family.project response['projectsByGuid'] = {project.guid: {}} add_project_tag_types(response['projectsByGuid'], project=project) @@ -230,8 +230,8 @@ def delete_families_handler(request, project_guid): def update_family_fields_handler(request, family_guid): family = Family.objects.get(guid=family_guid) - # check permission - can be edited by anyone with access to the project - check_project_permissions(family.project, request.user) + # check permission - can be edited by anyone with access to the family + check_family_view_permissions(family, request.user) request_json = json.loads(request.body) immutable_keys = [] if external_anvil_project_can_edit(family.project, request.user) else ['family_id'] @@ -254,8 +254,8 @@ def _set_display_name(family_json, family_model): @login_and_policies_required def update_family_assigned_analyst(request, family_guid): family = Family.objects.get(guid=family_guid) - # assigned_analyst can be edited by anyone with access to the project - check_project_permissions(family.project, request.user, can_edit=False) + # assigned_analyst can be edited by anyone with access to the family + check_family_view_permissions(family, request.user, can_edit=False) request_json = json.loads(request.body) assigned_analyst_username = request_json.get('assigned_analyst_username') @@ -281,8 +281,8 @@ def update_family_assigned_analyst(request, family_guid): @login_and_policies_required def update_family_analysed_by(request, family_guid): family = Family.objects.get(guid=family_guid) - # analysed_by can be edited by anyone with access to the project - check_project_permissions(family.project, request.user, can_edit=False) + # analysed_by can be edited by anyone with access to the family + check_family_view_permissions(family, request.user) request_json = json.loads(request.body) create_model_from_json(FamilyAnalysedBy, {'family': family, 'data_type': request_json['dataType']}, request.user) @@ -430,7 +430,7 @@ def _get_family_column_map(record): @login_and_policies_required def create_family_note(request, family_guid): family = Family.objects.get(guid=family_guid) - check_project_permissions(family.project, request.user) + check_family_view_permissions(family, request.user) return create_note_handler( request, FamilyNote, parent_fields={'family': family}, additional_note_fields=['noteType'], @@ -456,7 +456,7 @@ def delete_family_note(request, family_guid, note_guid): @login_and_policies_required def get_family_rna_seq_data(request, family_guid, gene_id): family = Family.objects.get(guid=family_guid) - check_project_permissions(family.project, request.user) + check_family_view_permissions(family, request.user) response = defaultdict(lambda: defaultdict(lambda: {'individualData': {}})) tpm_data = RnaSeqTpm.objects.filter( @@ -483,12 +483,12 @@ def get_family_rna_seq_data(request, family_guid, gene_id): @login_and_policies_required def get_family_phenotype_gene_scores(request, family_guid): - project = Project.objects.get(family__guid=family_guid) - check_project_permissions(project, request.user) + family = Family.objects.get(guid=family_guid) + check_family_view_permissions(family, request.user) phenotype_prioritization = get_phenotype_prioritization([family_guid]) gene_ids = {gene_id for indiv in phenotype_prioritization.values() for gene_id in indiv.keys()} return create_json_response({ 'phenotypeGeneScores': phenotype_prioritization, - 'genesById': get_genes_for_variant_display(gene_ids, project.genome_version), + 'genesById': get_genes_for_variant_display(gene_ids, family.project.genome_version), }) diff --git a/seqr/views/apis/individual_api.py b/seqr/views/apis/individual_api.py index 9d7a0697b8..f7a90e474f 100644 --- a/seqr/views/apis/individual_api.py +++ b/seqr/views/apis/individual_api.py @@ -26,7 +26,7 @@ get_valid_hpo_terms, JsonConstants, ErrorsWarningsException from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_permissions, \ get_project_and_check_pm_permissions, login_and_policies_required, has_project_permissions, external_anvil_project_can_edit, \ - pm_or_data_manager_required, check_workspace_perm + pm_or_data_manager_required, check_workspace_perm, check_family_view_permissions from seqr.views.utils.project_context_utils import add_project_tag_type_counts 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, get_saved_variant_annotations @@ -57,10 +57,10 @@ def update_individual_handler(request, individual_guid): individual = Individual.objects.get(guid=individual_guid) - project = individual.family.project + family = individual.family.project - check_project_permissions(project, request.user) - can_edit = has_project_permissions(project, request.user, can_edit=True) + check_family_view_permissions(family, request.user) + can_edit = has_project_permissions(family.project, request.user, can_edit=True) request_json = json.loads(request.body) update_json = request_json if can_edit else {k: v for k, v in request_json.items() if k in {'notes'}} @@ -883,8 +883,8 @@ def _parse_participant_val(column, value, participant_sample_lookup): @login_and_policies_required def get_individual_rna_seq_data(request, individual_guid): individual = Individual.objects.get(guid=individual_guid) - project = individual.family.project - check_project_permissions(project, request.user) + family = individual.family + check_family_view_permissions(family, request.user) filters = {'sample__individual': individual} outlier_data = get_json_for_rna_seq_outliers(filters, significant_only=False, individual_guid=individual_guid) @@ -892,7 +892,7 @@ def get_individual_rna_seq_data(request, individual_guid): genes_to_show = get_genes({ gene_id for rna_data in outlier_data.get(individual_guid, {}).values() for gene_id, data in rna_data.items() if any([d['isSignificant'] for d in (data if isinstance(data, list) else [data])]) - }, genome_version=project.genome_version) + }, genome_version=family.project.genome_version) return create_json_response({ 'rnaSeqData': outlier_data, diff --git a/seqr/views/apis/saved_variant_api.py b/seqr/views/apis/saved_variant_api.py index 68f99caf28..37c61e6015 100644 --- a/seqr/views/apis/saved_variant_api.py +++ b/seqr/views/apis/saved_variant_api.py @@ -13,7 +13,7 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_variant_note, \ get_json_for_saved_variants_child_entities, get_json_for_gene_notes_by_gene_id, STRUCTURED_METADATA_TAG_TYPES from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_permissions, \ - login_and_policies_required + login_and_policies_required, check_family_view_permissions from seqr.views.utils.variant_utils import get_variants_response, parse_saved_variant_json, DISCOVERY_CATEGORY @@ -51,7 +51,7 @@ def saved_variant_data(request, project_guid, variant_guids=None): @login_and_policies_required def create_manual_saved_variant_handler(request, family_guid): family = Family.objects.get(guid=family_guid) - check_project_permissions(family.project, request.user) + check_family_view_permissions(family, request.user) variant_json = json.loads(request.body) tags = variant_json.pop('tags', []) @@ -98,7 +98,7 @@ def create_saved_variant_handler(request): family_guid = variant_json['familyGuid'] family = Family.objects.get(guid=family_guid) - check_project_permissions(family.project, request.user) + check_family_view_permissions(family, request.user) variants_json = variant_json['variant'] if not isinstance(variant_json['variant'], list): @@ -130,7 +130,7 @@ def create_variant_note_handler(request, variant_guids): family_guid = request_json.pop('familyGuid') family = Family.objects.get(guid=family_guid) - check_project_permissions(family.project, request.user) + check_family_view_permissions(family, request.user) all_variant_guids = variant_guids.split(',') saved_variants = SavedVariant.objects.filter(guid__in=all_variant_guids) @@ -180,9 +180,9 @@ def _create_variant_note(saved_variants, note_json, user): @login_and_policies_required def update_variant_note_handler(request, variant_guids, note_guid): note = VariantNote.objects.get(guid=note_guid) - projects = {saved_variant.family.project for saved_variant in note.saved_variants.all()} - for project in projects: - check_project_permissions(project, request.user) + families = {saved_variant.family for saved_variant in note.saved_variants.all()} + for family in families: + check_family_view_permissions(family, request.user) request_json = json.loads(request.body) update_model_from_json(note, request_json, user=request.user, allow_unknown_keys=True) @@ -198,9 +198,9 @@ def update_variant_note_handler(request, variant_guids, note_guid): def delete_variant_note_handler(request, variant_guids, note_guid): variant_guids = variant_guids.split(',') note = VariantNote.objects.get(guid=note_guid) - projects = {saved_variant.family.project for saved_variant in note.saved_variants.all()} - for project in projects: - check_project_permissions(project, request.user) + families = {saved_variant.family for saved_variant in note.saved_variants.all()} + for family in families: + check_family_view_permissions(family, request.user) note.delete_model(request.user, user_can_delete=True) saved_variants_by_guid = {} @@ -238,7 +238,7 @@ def update_variant_acmg_classification_handler(request, variant_guid): def _update_variant_acmg_classification(request, variant_guid): saved_variant = SavedVariant.objects.get(guid=variant_guid) - check_project_permissions(saved_variant.family.project, request.user) + check_family_view_permissions(saved_variant.family, request.user) request_json = json.loads(request.body) variant = request_json.get('variant') @@ -263,8 +263,8 @@ def _update_variant_tag_models(request, variant_guids, tag_key, model_cls, get_t request_json = json.loads(request.body) family_guid = request_json.pop('familyGuid') - project = Project.objects.get(family__guid=family_guid) - check_project_permissions(project, request.user) + family = Family.objects.get(guid=family_guid) + check_family_view_permissions(family, request.user) all_variant_guids = set(variant_guids.split(',')) saved_variants = SavedVariant.objects.filter(guid__in=all_variant_guids) diff --git a/seqr/views/apis/variant_search_api.py b/seqr/views/apis/variant_search_api.py index ded007b9a8..68aa3eb7f8 100644 --- a/seqr/views/apis/variant_search_api.py +++ b/seqr/views/apis/variant_search_api.py @@ -28,7 +28,7 @@ create_model_from_json from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_saved_search,\ get_json_for_saved_searches, FAMILY_ADDITIONAL_VALUES -from seqr.views.utils.permissions_utils import check_project_permissions, get_project_guids_user_can_view, \ +from seqr.views.utils.permissions_utils import check_family_view_permissions, get_project_guids_user_can_view, \ login_and_policies_required, check_user_created_object_permissions, check_projects_view_permission, user_is_analyst from seqr.views.utils.project_context_utils import get_projects_child_entities from seqr.views.utils.variant_utils import get_variants_response, variant_dataset_type @@ -173,7 +173,7 @@ def _get_exclude_keys(search_hash, user): def query_single_variant_handler(request, variant_id): families = Family.objects.filter(guid=request.GET.get('familyGuid')) family = families.first() - check_project_permissions(family.project, request.user) + check_family_view_permissions(family, request.user) variants = get_clickhouse_variants(families, request.user, raw_variant_items=variant_id, variant_ids=[variant_id]) if not variants: From c8eb8464a1cd1046df5bd5117930a6d16ec157fd Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 16:47:25 -0400 Subject: [PATCH 28/39] set correct permissions for metadata bulk update --- seqr/views/apis/individual_api.py | 6 +++--- seqr/views/apis/individual_api_tests.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/seqr/views/apis/individual_api.py b/seqr/views/apis/individual_api.py index f7a90e474f..d12fe6369a 100644 --- a/seqr/views/apis/individual_api.py +++ b/seqr/views/apis/individual_api.py @@ -57,7 +57,7 @@ def update_individual_handler(request, individual_guid): individual = Individual.objects.get(guid=individual_guid) - family = individual.family.project + family = individual.family check_family_view_permissions(family, request.user) can_edit = has_project_permissions(family.project, request.user, can_edit=True) @@ -422,7 +422,7 @@ def receive_individuals_metadata_handler(request, project_guid): project_guid (string): project GUID """ - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) def process_records(json_records, filename=''): records, errors, warnings = _process_hpo_records(json_records, filename, project, request.user) @@ -651,7 +651,7 @@ def save_individuals_metadata_table_handler(request, project_guid, upload_file_i """ Handler for 'save' requests to apply HPO terms tables previously uploaded through receive_individuals_metadata_handler """ - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) json_records, _ = load_uploaded_file(upload_file_id) diff --git a/seqr/views/apis/individual_api_tests.py b/seqr/views/apis/individual_api_tests.py index 910478d944..479231a8cc 100644 --- a/seqr/views/apis/individual_api_tests.py +++ b/seqr/views/apis/individual_api_tests.py @@ -1060,7 +1060,7 @@ def _is_expected_individuals_metadata_upload(self, response, expected_families=F def test_individuals_metadata_table_handler(self): url = reverse(receive_individuals_metadata_handler, args=['R0001_1kg']) - self.check_collaborator_login(url) + self.check_manager_login(url) # Send invalid requests header = 'family_id,indiv_id,hpo_term_yes,hpo_term_no' @@ -1106,7 +1106,7 @@ def test_individuals_metadata_table_handler(self): read_tmp_table_logs = self._read_tmp_table_logs('6aea3d1f1bfc295340aa504370102fd5') offset = 2 if read_tmp_table_logs else 1 self.assert_json_logs(None, read_tmp_table_logs, offset=offset) - self.assert_json_logs(self.collaborator_user, [ + self.assert_json_logs(self.manager_user, [ ('update Individual I000002_na19678', {'dbUpdate': mock.ANY}), ('update Individual I000001_na19675', {'dbUpdate': mock.ANY}), ('Reloading dictionary seqrdb_individual_metadata_dict', None), @@ -1114,7 +1114,7 @@ def test_individuals_metadata_table_handler(self): def test_individuals_metadata_hpo_term_number_table_handler(self): url = reverse(receive_individuals_metadata_handler, args=['R0001_1kg']) - self.check_collaborator_login(url) + self.check_manager_login(url) header = 'family_id,individual_id,affected,hpo_number,hpo_number,sex,birth,other affected relatives,onset,expected inheritance,maternal ancestry,candidate genes' rows = [ From 3fd79a27a20bf27fd6f0e70a1ab8dcb2d87de994 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 17:18:16 -0400 Subject: [PATCH 29/39] split get project helper by edit --- matchmaker/views/matchmaker_api.py | 4 ++-- seqr/views/apis/analysis_group_api.py | 7 ++++--- seqr/views/apis/igv_api.py | 4 ++-- seqr/views/apis/individual_api.py | 8 ++++---- seqr/views/apis/locus_list_api.py | 5 +++-- seqr/views/apis/project_api.py | 4 ++-- seqr/views/apis/users_api.py | 13 +++++++------ seqr/views/utils/permissions_utils.py | 8 +++++--- 8 files changed, 29 insertions(+), 24 deletions(-) diff --git a/matchmaker/views/matchmaker_api.py b/matchmaker/views/matchmaker_api.py index c6c0532681..f3a21eceb3 100644 --- a/matchmaker/views/matchmaker_api.py +++ b/matchmaker/views/matchmaker_api.py @@ -21,7 +21,7 @@ from seqr.views.utils.orm_to_json_utils import _get_json_for_model, get_json_for_saved_variants, \ get_json_for_matchmaker_submission, get_json_for_matchmaker_submissions from seqr.views.utils.permissions_utils import check_mme_permissions, check_family_view_permissions, analyst_required, \ - has_project_permissions, login_and_policies_required, get_project_and_check_permissions + has_project_permissions, login_and_policies_required, get_project_and_check_edit_permission from settings import BASE_URL, MME_ACCEPT_HEADER, MME_NODES, MME_DEFAULT_CONTACT_EMAIL, \ MME_SLACK_SEQR_MATCH_NOTIFICATION_CHANNEL, MME_SLACK_ALERT_NOTIFICATION_CHANNEL, VLM_SEND_EMAIL @@ -390,7 +390,7 @@ def send_mme_contact_email(request, matchmaker_result_guid): @login_and_policies_required def update_mme_project_contact(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) contact = (request_json.get('contact') or '').strip() diff --git a/seqr/views/apis/analysis_group_api.py b/seqr/views/apis/analysis_group_api.py index 90322da56c..336b416bc8 100644 --- a/seqr/views/apis/analysis_group_api.py +++ b/seqr/views/apis/analysis_group_api.py @@ -1,10 +1,11 @@ +from django.core.exceptions import PermissionDenied import json from seqr.models import AnalysisGroup, DynamicAnalysisGroup, Family from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.json_to_orm_utils import update_model_from_json, get_or_create_model_from_json from seqr.views.utils.orm_to_json_utils import get_json_for_analysis_group -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, login_and_policies_required +from seqr.views.utils.permissions_utils import get_project_and_check_edit_permission, login_and_policies_required REQUIRED_FIELDS = {'name': 'Name', 'familyGuids': 'Families'} @@ -12,7 +13,7 @@ def _update_analysis_group(request, project_guid, analysis_group_guid, model_cls, required_fields, is_dynamic=False, validate_body=lambda x: None, post_process_model=lambda x: None): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) missing_fields = [field for field in required_fields.keys() if not request_json.get(field)] @@ -79,7 +80,7 @@ def update_dynamic_analysis_group_handler(request, project_guid, analysis_group_ @login_and_policies_required def delete_analysis_group_handler(request, project_guid, analysis_group_guid, model_cls=AnalysisGroup): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) model_cls.objects.get(guid=analysis_group_guid, project=project).delete_model(request.user, user_can_delete=True) return create_json_response({'analysisGroupsByGuid': {analysis_group_guid: None}}) diff --git a/seqr/views/apis/igv_api.py b/seqr/views/apis/igv_api.py index fab12d3bea..1f1467a62d 100644 --- a/seqr/views/apis/igv_api.py +++ b/seqr/views/apis/igv_api.py @@ -15,7 +15,7 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_sample from seqr.views.utils.permissions_utils import get_project_and_check_permissions, external_anvil_project_can_edit, \ login_and_policies_required, pm_or_data_manager_required, get_project_guids_user_can_view, user_is_data_manager, \ - user_is_pm + user_is_pm, get_project_and_check_edit_permission GS_STORAGE_ACCESS_CACHE_KEY = 'gs_storage_access_cache_entry' GS_STORAGE_URL = 'https://storage.googleapis.com' @@ -92,7 +92,7 @@ def _process_igv_table_handler(parse_uploaded_file, get_valid_matched_individual @pm_or_data_manager_required def receive_igv_table_handler(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) def _get_valid_matched_individuals(individual_dataset_mapping): matched_individuals = Individual.objects.filter( diff --git a/seqr/views/apis/individual_api.py b/seqr/views/apis/individual_api.py index d12fe6369a..3ccac16d68 100644 --- a/seqr/views/apis/individual_api.py +++ b/seqr/views/apis/individual_api.py @@ -24,7 +24,7 @@ GREGOR_FINDING_TAG_TYPE from seqr.views.utils.pedigree_info_utils import parse_pedigree_table, validate_fam_file_records, parse_hpo_terms, \ get_valid_hpo_terms, JsonConstants, ErrorsWarningsException -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_permissions, \ +from seqr.views.utils.permissions_utils import get_project_and_check_edit_permission, check_project_permissions, \ get_project_and_check_pm_permissions, login_and_policies_required, has_project_permissions, external_anvil_project_can_edit, \ pm_or_data_manager_required, check_workspace_perm, check_family_view_permissions from seqr.views.utils.project_context_utils import add_project_tag_type_counts @@ -422,7 +422,7 @@ def receive_individuals_metadata_handler(request, project_guid): project_guid (string): project GUID """ - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) def process_records(json_records, filename=''): records, errors, warnings = _process_hpo_records(json_records, filename, project, request.user) @@ -651,7 +651,7 @@ def save_individuals_metadata_table_handler(request, project_guid, upload_file_i """ Handler for 'save' requests to apply HPO terms tables previously uploaded through receive_individuals_metadata_handler """ - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) json_records, _ = load_uploaded_file(upload_file_id) @@ -698,7 +698,7 @@ def save_individuals_metadata_table_handler(request, project_guid, upload_file_i def import_gregor_metadata(request, project_guid): request_json = json.loads(request.body) sample_type = request_json.get('sampleType', 'genome') - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) workspace_meta = check_workspace_perm( request.user, CAN_VIEW, request_json['workspaceNamespace'], request_json['workspaceName'], meta_fields=['workspace.bucketName'] diff --git a/seqr/views/apis/locus_list_api.py b/seqr/views/apis/locus_list_api.py index 625de636c5..103868c6da 100644 --- a/seqr/views/apis/locus_list_api.py +++ b/seqr/views/apis/locus_list_api.py @@ -13,7 +13,8 @@ from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_locus_lists, get_json_for_locus_list from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_locus_list_permissions, \ - check_user_created_object_permissions, login_and_policies_required, get_project_guids_user_can_view + check_user_created_object_permissions, login_and_policies_required, get_project_guids_user_can_view, \ + get_project_and_check_edit_permission logger = SeqrLogger(__name__) @@ -151,7 +152,7 @@ def add_project_locus_lists(request, project_guid): @login_and_policies_required def delete_project_locus_lists(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) locus_lists = LocusList.objects.filter(guid__in=request_json['locusListGuids']) for locus_list in locus_lists: diff --git a/seqr/views/apis/project_api.py b/seqr/views/apis/project_api.py index 3585ae625e..8db89036af 100644 --- a/seqr/views/apis/project_api.py +++ b/seqr/views/apis/project_api.py @@ -25,7 +25,7 @@ from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_permissions, \ check_user_created_object_permissions, pm_required, user_is_pm, login_and_policies_required, \ has_workspace_perm, has_case_review_permissions, is_internal_anvil_project, get_project_and_check_pm_permissions, \ - check_project_pm_permission, user_is_data_manager, external_anvil_project_can_edit + check_project_pm_permission, user_is_data_manager, external_anvil_project_can_edit, get_project_and_check_edit_permission from seqr.views.utils.project_context_utils import families_discovery_tags, \ add_project_tag_type_counts, get_project_analysis_groups, get_project_locus_lists from seqr.views.utils.terra_api_utils import is_anvil_authenticated, anvil_enabled @@ -106,7 +106,7 @@ def update_project_workspace(request, project_guid): if not is_anvil_authenticated(request.user): raise PermissionDenied() - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) if not _is_valid_anvil_workspace(request_json, request.user): diff --git a/seqr/views/apis/users_api.py b/seqr/views/apis/users_api.py index 8e21cee8c2..c70e8ee25f 100644 --- a/seqr/views/apis/users_api.py +++ b/seqr/views/apis/users_api.py @@ -15,7 +15,8 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_user, get_json_for_project_collaborator_list, \ get_project_collaborators_by_username, get_json_for_project_collaborator_groups, PROJECT_ACCESS_GROUP_NAMES from seqr.views.utils.permissions_utils import get_project_guids_user_can_view, get_project_and_check_permissions, \ - login_and_policies_required, login_active_required, active_user_has_policies_and_passes_test + login_and_policies_required, login_active_required, active_user_has_policies_and_passes_test, \ + get_project_and_check_edit_permission from seqr.views.utils.terra_api_utils import oauth_enabled, anvil_enabled from settings import BASE_URL, SEQR_TOS_VERSION, SEQR_PRIVACY_VERSION @@ -136,7 +137,7 @@ def update_policies(request): @require_anvil_disabled def create_project_collaborator(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) if not request_json.get('email'): @@ -185,7 +186,7 @@ def _update_existing_user(user, project, request_json): @require_anvil_disabled def update_project_collaborator(request, project_guid, username): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) user = User.objects.get(username=username) request_json = json.loads(request.body) @@ -194,7 +195,7 @@ def update_project_collaborator(request, project_guid, username): @require_anvil_disabled def delete_project_collaborator(request, project_guid, username): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) user = User.objects.get(username=username) project.can_view_group.user_set.remove(user) @@ -207,7 +208,7 @@ def delete_project_collaborator(request, project_guid, username): @require_anvil_disabled def update_project_collaborator_group(request, project_guid, name): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) group = Group.objects.get(name=name) request_json = json.loads(request.body) @@ -224,7 +225,7 @@ def update_project_collaborator_group(request, project_guid, name): @require_anvil_disabled def delete_project_collaborator_group(request, project_guid, name): - project = get_project_and_check_permissions(project_guid, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) group = Group.objects.get(name=name) remove_perm(user_or_group=group, perm=CAN_VIEW, obj=project) diff --git a/seqr/views/utils/permissions_utils.py b/seqr/views/utils/permissions_utils.py index 6f2fce851c..673a79c0e1 100644 --- a/seqr/views/utils/permissions_utils.py +++ b/seqr/views/utils/permissions_utils.py @@ -110,9 +110,11 @@ def get_internal_projects(): return Project.objects.filter(workspace_namespace__in=INTERNAL_NAMESPACES) return Project.objects.all() +def get_project_and_check_edit_permission(project_guid, user): + return _get_project_and_check_permissions(project_guid, user, check_project_permissions, can_edit=True) -def get_project_and_check_permissions(project_guid, user, **kwargs): - return _get_project_and_check_permissions(project_guid, user, check_project_permissions, **kwargs) +def get_project_and_check_permissions(project_guid, user): + return _get_project_and_check_permissions(project_guid, user, check_project_permissions) def get_project_and_check_pm_permissions(project_guid, user, override_permission_func=None): return _get_project_and_check_permissions(project_guid, user, check_project_pm_permission, @@ -202,7 +204,7 @@ def _user_project_permission(user, permission_level, project): return user.has_perm(permission_level, project) -def check_project_permissions(project, user, **kwargs): +def check_project_permissions(project, user, **kwargs): # TODO edit/view split helper if has_project_permissions(project, user, **kwargs): return From 77d791061abeb78e3dcceedf380e95996c37c536 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 30 Jun 2026 17:28:01 -0400 Subject: [PATCH 30/39] permission helper cleanup --- matchmaker/views/matchmaker_api.py | 4 ++-- seqr/views/apis/family_api.py | 24 +++++++++++------------ seqr/views/apis/individual_api.py | 10 +++++----- seqr/views/apis/project_api.py | 6 ++---- seqr/views/apis/project_categories_api.py | 9 +++------ seqr/views/apis/saved_variant_api.py | 20 +++++++++---------- seqr/views/apis/variant_search_api.py | 4 ++-- seqr/views/utils/permissions_utils.py | 22 ++++++++++++++------- 8 files changed, 51 insertions(+), 48 deletions(-) diff --git a/matchmaker/views/matchmaker_api.py b/matchmaker/views/matchmaker_api.py index f3a21eceb3..cb95b73f87 100644 --- a/matchmaker/views/matchmaker_api.py +++ b/matchmaker/views/matchmaker_api.py @@ -20,7 +20,7 @@ from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import _get_json_for_model, get_json_for_saved_variants, \ get_json_for_matchmaker_submission, get_json_for_matchmaker_submissions -from seqr.views.utils.permissions_utils import check_mme_permissions, check_family_view_permissions, analyst_required, \ +from seqr.views.utils.permissions_utils import check_mme_permissions, check_family_view_permission, analyst_required, \ has_project_permissions, login_and_policies_required, get_project_and_check_edit_permission from settings import BASE_URL, MME_ACCEPT_HEADER, MME_NODES, MME_DEFAULT_CONTACT_EMAIL, \ @@ -273,7 +273,7 @@ def update_mme_submission(request, submission_guid=None): if not individual_guid: return create_json_response({}, status=400, reason='Individual is required for a new submission') individual = Individual.objects.get(guid=individual_guid) - check_family_view_permissions(individual.family, request.user) + check_family_view_permission(individual.family, request.user) submission = create_model_from_json(MatchmakerSubmission, { 'individual': individual, 'submission_id': individual.guid, diff --git a/seqr/views/apis/family_api.py b/seqr/views/apis/family_api.py index 8456e8306f..508f00d29a 100644 --- a/seqr/views/apis/family_api.py +++ b/seqr/views/apis/family_api.py @@ -21,9 +21,9 @@ MME_TAG_NAME from seqr.models import Family, FamilyAnalysedBy, Individual, FamilyNote, Dataset, VariantTag, AnalysisGroup, RnaSeqTpm, \ PhenotypePrioritization, Project, RnaSample -from seqr.views.utils.permissions_utils import check_project_permissions, get_project_and_check_pm_permissions, \ +from seqr.views.utils.permissions_utils import check_project_edit_permission, get_project_and_check_pm_permissions, \ login_and_policies_required, user_is_analyst, has_case_review_permissions, external_anvil_project_can_edit, \ - get_internal_projects, get_project_guids_user_can_view, check_family_view_permissions + get_internal_projects, get_project_guids_user_can_view, check_family_view_permission from seqr.views.utils.terra_api_utils import anvil_enabled from seqr.views.utils.variant_utils import get_phenotype_prioritization, get_omim_intervals_query, DISCOVERY_CATEGORY from seqr.utils.xpos_utils import get_chrom_pos @@ -39,7 +39,7 @@ def family_page_data(request, family_guid): families = Family.objects.filter(guid=family_guid) family = families.get(guid=family_guid) project = family.project - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) is_analyst = user_is_analyst(request.user) has_case_review_perm = has_case_review_permissions(project, request.user) @@ -122,7 +122,7 @@ def _add_parsed_omims(omims, omim_map, intervals=None): @login_and_policies_required def family_variant_tag_summary(request, family_guid): family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) response = families_discovery_tags([{'familyGuid': family_guid}]) @@ -231,7 +231,7 @@ def update_family_fields_handler(request, family_guid): family = Family.objects.get(guid=family_guid) # check permission - can be edited by anyone with access to the family - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) request_json = json.loads(request.body) immutable_keys = [] if external_anvil_project_can_edit(family.project, request.user) else ['family_id'] @@ -255,7 +255,7 @@ def _set_display_name(family_json, family_model): def update_family_assigned_analyst(request, family_guid): family = Family.objects.get(guid=family_guid) # assigned_analyst can be edited by anyone with access to the family - check_family_view_permissions(family, request.user, can_edit=False) + check_family_view_permission(family, request.user, can_edit=False) request_json = json.loads(request.body) assigned_analyst_username = request_json.get('assigned_analyst_username') @@ -282,7 +282,7 @@ def update_family_assigned_analyst(request, family_guid): def update_family_analysed_by(request, family_guid): family = Family.objects.get(guid=family_guid) # analysed_by can be edited by anyone with access to the family - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) request_json = json.loads(request.body) create_model_from_json(FamilyAnalysedBy, {'family': family, 'data_type': request_json['dataType']}, request.user) @@ -297,7 +297,7 @@ def update_family_pedigree_image(request, family_guid): family = Family.objects.get(guid=family_guid) # check permission - check_project_permissions(family.project, request.user, can_edit=True) + check_project_edit_permission(family.project, request.user) if len(request.FILES) == 0: pedigree_image = None @@ -324,7 +324,7 @@ def update_family_pedigree_image(request, family_guid): def update_family_analysis_groups(request, family_guid): family = Family.objects.get(guid=family_guid) project = family.project - check_project_permissions(project, request.user, can_edit=True) + check_project_edit_permission(project, request.user) request_json = json.loads(request.body) analysis_group_guids = {ag['analysisGroupGuid'] for ag in request_json.get('analysisGroups', [])} @@ -430,7 +430,7 @@ def _get_family_column_map(record): @login_and_policies_required def create_family_note(request, family_guid): family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) return create_note_handler( request, FamilyNote, parent_fields={'family': family}, additional_note_fields=['noteType'], @@ -456,7 +456,7 @@ def delete_family_note(request, family_guid, note_guid): @login_and_policies_required def get_family_rna_seq_data(request, family_guid, gene_id): family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) response = defaultdict(lambda: defaultdict(lambda: {'individualData': {}})) tpm_data = RnaSeqTpm.objects.filter( @@ -484,7 +484,7 @@ def get_family_rna_seq_data(request, family_guid, gene_id): @login_and_policies_required def get_family_phenotype_gene_scores(request, family_guid): family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) phenotype_prioritization = get_phenotype_prioritization([family_guid]) gene_ids = {gene_id for indiv in phenotype_prioritization.values() for gene_id in indiv.keys()} diff --git a/seqr/views/apis/individual_api.py b/seqr/views/apis/individual_api.py index 3ccac16d68..ab29cddf73 100644 --- a/seqr/views/apis/individual_api.py +++ b/seqr/views/apis/individual_api.py @@ -24,9 +24,9 @@ GREGOR_FINDING_TAG_TYPE from seqr.views.utils.pedigree_info_utils import parse_pedigree_table, validate_fam_file_records, parse_hpo_terms, \ get_valid_hpo_terms, JsonConstants, ErrorsWarningsException -from seqr.views.utils.permissions_utils import get_project_and_check_edit_permission, check_project_permissions, \ +from seqr.views.utils.permissions_utils import get_project_and_check_edit_permission, check_project_edit_permission, \ get_project_and_check_pm_permissions, login_and_policies_required, has_project_permissions, external_anvil_project_can_edit, \ - pm_or_data_manager_required, check_workspace_perm, check_family_view_permissions + pm_or_data_manager_required, check_workspace_perm, check_family_view_permission from seqr.views.utils.project_context_utils import add_project_tag_type_counts 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, get_saved_variant_annotations @@ -59,7 +59,7 @@ def update_individual_handler(request, individual_guid): family = individual.family - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) can_edit = has_project_permissions(family.project, request.user, can_edit=True) request_json = json.loads(request.body) @@ -84,7 +84,7 @@ def update_individual_hpo_terms(request, individual_guid): project = individual.family.project - check_project_permissions(project, request.user, can_edit=True) + check_project_edit_permission(project, request.user) request_json = json.loads(request.body) @@ -884,7 +884,7 @@ def _parse_participant_val(column, value, participant_sample_lookup): def get_individual_rna_seq_data(request, individual_guid): individual = Individual.objects.get(guid=individual_guid) family = individual.family - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) filters = {'sample__individual': individual} outlier_data = get_json_for_rna_seq_outliers(filters, significant_only=False, individual_guid=individual_guid) diff --git a/seqr/views/apis/project_api.py b/seqr/views/apis/project_api.py index 8db89036af..17383a67c9 100644 --- a/seqr/views/apis/project_api.py +++ b/seqr/views/apis/project_api.py @@ -22,7 +22,7 @@ get_json_for_project_collaborator_list, get_json_for_matchmaker_submissions, \ get_json_for_family_notes, _get_json_for_individuals, get_json_for_project_collaborator_groups, \ FAMILY_ADDITIONAL_VALUES -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_permissions, \ +from seqr.views.utils.permissions_utils import get_project_and_check_permissions, get_project_and_check_edit_permission, \ check_user_created_object_permissions, pm_required, user_is_pm, login_and_policies_required, \ has_workspace_perm, has_case_review_permissions, is_internal_anvil_project, get_project_and_check_pm_permissions, \ check_project_pm_permission, user_is_data_manager, external_anvil_project_can_edit, get_project_and_check_edit_permission @@ -77,9 +77,7 @@ def _is_valid_anvil_workspace(request_json, user): @login_and_policies_required def update_project_handler(request, project_guid): - project = Project.objects.get(guid=project_guid) - - check_project_permissions(project, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) updated_fields = set() diff --git a/seqr/views/apis/project_categories_api.py b/seqr/views/apis/project_categories_api.py index 55f15ea3c2..e0804dafc4 100644 --- a/seqr/views/apis/project_categories_api.py +++ b/seqr/views/apis/project_categories_api.py @@ -1,18 +1,15 @@ import json -from seqr.models import Project, ProjectCategory +from seqr.models import ProjectCategory from seqr.views.utils.json_to_orm_utils import create_model_from_json from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import _get_json_for_project -from seqr.views.utils.permissions_utils import check_project_permissions, login_and_policies_required +from seqr.views.utils.permissions_utils import get_project_and_check_edit_permission, login_and_policies_required @login_and_policies_required def update_project_categories_handler(request, project_guid): - project = Project.objects.get(guid=project_guid) - - # check permissions - check_project_permissions(project, request.user, can_edit=True) + project = get_project_and_check_edit_permission(project_guid, request.user) request_json = json.loads(request.body) diff --git a/seqr/views/apis/saved_variant_api.py b/seqr/views/apis/saved_variant_api.py index 37c61e6015..11c28f58a0 100644 --- a/seqr/views/apis/saved_variant_api.py +++ b/seqr/views/apis/saved_variant_api.py @@ -12,8 +12,8 @@ from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_variant_note, \ get_json_for_saved_variants_child_entities, get_json_for_gene_notes_by_gene_id, STRUCTURED_METADATA_TAG_TYPES -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_permissions, \ - login_and_policies_required, check_family_view_permissions +from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_edit_permission, \ + login_and_policies_required, check_family_view_permission from seqr.views.utils.variant_utils import get_variants_response, parse_saved_variant_json, DISCOVERY_CATEGORY @@ -51,7 +51,7 @@ def saved_variant_data(request, project_guid, variant_guids=None): @login_and_policies_required def create_manual_saved_variant_handler(request, family_guid): family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) variant_json = json.loads(request.body) tags = variant_json.pop('tags', []) @@ -98,7 +98,7 @@ def create_saved_variant_handler(request): family_guid = variant_json['familyGuid'] family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) variants_json = variant_json['variant'] if not isinstance(variant_json['variant'], list): @@ -130,7 +130,7 @@ def create_variant_note_handler(request, variant_guids): family_guid = request_json.pop('familyGuid') family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) all_variant_guids = variant_guids.split(',') saved_variants = SavedVariant.objects.filter(guid__in=all_variant_guids) @@ -182,7 +182,7 @@ def update_variant_note_handler(request, variant_guids, note_guid): note = VariantNote.objects.get(guid=note_guid) families = {saved_variant.family for saved_variant in note.saved_variants.all()} for family in families: - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) request_json = json.loads(request.body) update_model_from_json(note, request_json, user=request.user, allow_unknown_keys=True) @@ -200,7 +200,7 @@ def delete_variant_note_handler(request, variant_guids, note_guid): note = VariantNote.objects.get(guid=note_guid) families = {saved_variant.family for saved_variant in note.saved_variants.all()} for family in families: - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) note.delete_model(request.user, user_can_delete=True) saved_variants_by_guid = {} @@ -238,7 +238,7 @@ def update_variant_acmg_classification_handler(request, variant_guid): def _update_variant_acmg_classification(request, variant_guid): saved_variant = SavedVariant.objects.get(guid=variant_guid) - check_family_view_permissions(saved_variant.family, request.user) + check_family_view_permission(saved_variant.family, request.user) request_json = json.loads(request.body) variant = request_json.get('variant') @@ -264,7 +264,7 @@ def _update_variant_tag_models(request, variant_guids, tag_key, model_cls, get_t family_guid = request_json.pop('familyGuid') family = Family.objects.get(guid=family_guid) - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) all_variant_guids = set(variant_guids.split(',')) saved_variants = SavedVariant.objects.filter(guid__in=all_variant_guids) @@ -357,7 +357,7 @@ def _update_tags(saved_variants, tags_json, user, tag_key='tags', model_cls=Vari @login_and_policies_required def update_variant_main_transcript(request, variant_guid, transcript_id): saved_variant = SavedVariant.objects.get(guid=variant_guid) - check_project_permissions(saved_variant.family.project, request.user, can_edit=True) + check_project_edit_permission(saved_variant.family.project, request.user) update_model_from_json(saved_variant, { 'selected_main_transcript_id': transcript_id, diff --git a/seqr/views/apis/variant_search_api.py b/seqr/views/apis/variant_search_api.py index 68aa3eb7f8..eba0f7cdee 100644 --- a/seqr/views/apis/variant_search_api.py +++ b/seqr/views/apis/variant_search_api.py @@ -28,7 +28,7 @@ create_model_from_json from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_saved_search,\ get_json_for_saved_searches, FAMILY_ADDITIONAL_VALUES -from seqr.views.utils.permissions_utils import check_family_view_permissions, get_project_guids_user_can_view, \ +from seqr.views.utils.permissions_utils import check_family_view_permission, get_project_guids_user_can_view, \ login_and_policies_required, check_user_created_object_permissions, check_projects_view_permission, user_is_analyst from seqr.views.utils.project_context_utils import get_projects_child_entities from seqr.views.utils.variant_utils import get_variants_response, variant_dataset_type @@ -173,7 +173,7 @@ def _get_exclude_keys(search_hash, user): def query_single_variant_handler(request, variant_id): families = Family.objects.filter(guid=request.GET.get('familyGuid')) family = families.first() - check_family_view_permissions(family, request.user) + check_family_view_permission(family, request.user) variants = get_clickhouse_variants(families, request.user, raw_variant_items=variant_id, variant_ids=[variant_id]) if not variants: diff --git a/seqr/views/utils/permissions_utils.py b/seqr/views/utils/permissions_utils.py index 673a79c0e1..46b321132b 100644 --- a/seqr/views/utils/permissions_utils.py +++ b/seqr/views/utils/permissions_utils.py @@ -111,10 +111,10 @@ def get_internal_projects(): return Project.objects.all() def get_project_and_check_edit_permission(project_guid, user): - return _get_project_and_check_permissions(project_guid, user, check_project_permissions, can_edit=True) + return _get_project_and_check_permissions(project_guid, user, check_project_edit_permission,) def get_project_and_check_permissions(project_guid, user): - return _get_project_and_check_permissions(project_guid, user, check_project_permissions) + return _get_project_and_check_permissions(project_guid, user, _check_project_view_permission) def get_project_and_check_pm_permissions(project_guid, user, override_permission_func=None): return _get_project_and_check_permissions(project_guid, user, check_project_pm_permission, @@ -204,16 +204,24 @@ def _user_project_permission(user, permission_level, project): return user.has_perm(permission_level, project) -def check_project_permissions(project, user, **kwargs): # TODO edit/view split helper - if has_project_permissions(project, user, **kwargs): +def check_project_edit_permission(project, user): + if has_project_permissions(project, user, can_edit=True): return raise PermissionDenied("{user} does not have sufficient permissions for {project}".format( user=user, project=project)) -def check_family_view_permissions(family, user): - check_project_permissions(family.project, user) +def _check_project_view_permission(project, user): + if has_project_permissions(project, user): + return + + raise PermissionDenied("{user} does not have sufficient permissions for {project}".format( + user=user, project=project)) + + +def check_family_view_permission(family, user): + _check_project_view_permission(family.project, user) # TODO check analysis group perms @@ -277,7 +285,7 @@ def get_project_guids_user_can_view(user, limit_data_manager=True): def check_mme_permissions(submission, user): family = submission.individual.family project = family.project - check_family_view_permissions(family, user) + check_family_view_permission(family, user) if not (project.is_mme_enabled and not project.is_demo): raise PermissionDenied('Matchmaker is not enabled') return project.genome_version From 7963ddc31d4916d4af3c81bfbc31709aac88bcf1 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 11:03:53 -0400 Subject: [PATCH 31/39] clean up --- seqr/views/apis/family_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seqr/views/apis/family_api.py b/seqr/views/apis/family_api.py index 508f00d29a..e0f079aa0c 100644 --- a/seqr/views/apis/family_api.py +++ b/seqr/views/apis/family_api.py @@ -20,7 +20,7 @@ from seqr.views.utils.project_context_utils import add_families_context, families_discovery_tags, add_project_tag_types, \ MME_TAG_NAME from seqr.models import Family, FamilyAnalysedBy, Individual, FamilyNote, Dataset, VariantTag, AnalysisGroup, RnaSeqTpm, \ - PhenotypePrioritization, Project, RnaSample + PhenotypePrioritization, RnaSample from seqr.views.utils.permissions_utils import check_project_edit_permission, get_project_and_check_pm_permissions, \ login_and_policies_required, user_is_analyst, has_case_review_permissions, external_anvil_project_can_edit, \ get_internal_projects, get_project_guids_user_can_view, check_family_view_permission @@ -255,7 +255,7 @@ def _set_display_name(family_json, family_model): def update_family_assigned_analyst(request, family_guid): family = Family.objects.get(guid=family_guid) # assigned_analyst can be edited by anyone with access to the family - check_family_view_permission(family, request.user, can_edit=False) + check_family_view_permission(family, request.user) request_json = json.loads(request.body) assigned_analyst_username = request_json.get('assigned_analyst_username') From 251a6f168009a499471f2b6f2b150aae399b2604 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 11:18:18 -0400 Subject: [PATCH 32/39] has permission refactor --- matchmaker/views/matchmaker_api.py | 4 +-- seqr/views/apis/individual_api.py | 4 +-- seqr/views/utils/orm_to_json_utils.py | 4 +-- seqr/views/utils/permissions_utils.py | 38 ++++++++++++++++----------- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/matchmaker/views/matchmaker_api.py b/matchmaker/views/matchmaker_api.py index cb95b73f87..540f20f455 100644 --- a/matchmaker/views/matchmaker_api.py +++ b/matchmaker/views/matchmaker_api.py @@ -21,7 +21,7 @@ from seqr.views.utils.orm_to_json_utils import _get_json_for_model, get_json_for_saved_variants, \ get_json_for_matchmaker_submission, get_json_for_matchmaker_submissions from seqr.views.utils.permissions_utils import check_mme_permissions, check_family_view_permission, analyst_required, \ - has_project_permissions, login_and_policies_required, get_project_and_check_edit_permission + has_family_view_permission, login_and_policies_required, get_project_and_check_edit_permission from settings import BASE_URL, MME_ACCEPT_HEADER, MME_NODES, MME_DEFAULT_CONTACT_EMAIL, \ MME_SLACK_SEQR_MATCH_NOTIFICATION_CHANNEL, MME_SLACK_ALERT_NOTIFICATION_CHANNEL, VLM_SEND_EMAIL @@ -434,7 +434,7 @@ def _parse_mme_results(submission, saved_results, user, additional_genes=None, r result['matchStatus'] = _get_json_for_model(result_model) if result_model.originating_submission: originating_family = result_model.originating_submission.individual.family - if has_project_permissions(originating_family.project, user): + if has_family_view_permission(originating_family, user): result['originatingSubmission'] = { 'originatingSubmissionGuid': result_model.originating_submission.guid, 'familyGuid': originating_family.guid, diff --git a/seqr/views/apis/individual_api.py b/seqr/views/apis/individual_api.py index ab29cddf73..f17effc715 100644 --- a/seqr/views/apis/individual_api.py +++ b/seqr/views/apis/individual_api.py @@ -25,7 +25,7 @@ from seqr.views.utils.pedigree_info_utils import parse_pedigree_table, validate_fam_file_records, parse_hpo_terms, \ get_valid_hpo_terms, JsonConstants, ErrorsWarningsException from seqr.views.utils.permissions_utils import get_project_and_check_edit_permission, check_project_edit_permission, \ - get_project_and_check_pm_permissions, login_and_policies_required, has_project_permissions, external_anvil_project_can_edit, \ + get_project_and_check_pm_permissions, login_and_policies_required, has_project_edit_permission, external_anvil_project_can_edit, \ pm_or_data_manager_required, check_workspace_perm, check_family_view_permission from seqr.views.utils.project_context_utils import add_project_tag_type_counts from seqr.views.utils.individual_utils import delete_individuals, add_or_update_individuals_and_families @@ -60,7 +60,7 @@ def update_individual_handler(request, individual_guid): family = individual.family check_family_view_permission(family, request.user) - can_edit = has_project_permissions(family.project, request.user, can_edit=True) + can_edit = has_project_edit_permission(family.project, request.user) request_json = json.loads(request.body) update_json = request_json if can_edit else {k: v for k, v in request_json.items() if k in {'notes'}} diff --git a/seqr/views/utils/orm_to_json_utils.py b/seqr/views/utils/orm_to_json_utils.py index 4e15831650..4ad0add591 100644 --- a/seqr/views/utils/orm_to_json_utils.py +++ b/seqr/views/utils/orm_to_json_utils.py @@ -12,7 +12,7 @@ get_audit_field_names, RnaSeqOutlier, RnaSeqSpliceOutlier, VariantSearchResults from seqr.utils.xpos_utils import get_chrom_pos from seqr.views.utils.json_utils import _to_camel_case -from seqr.views.utils.permissions_utils import has_project_permissions, \ +from seqr.views.utils.permissions_utils import has_project_edit_permission, \ project_has_anvil, get_workspace_collaborator_perms, user_is_analyst, user_is_data_manager, user_is_pm, \ is_internal_anvil_project, get_anvil_analyst_user_emails from seqr.views.utils.terra_api_utils import is_anvil_authenticated, anvil_enabled @@ -154,7 +154,7 @@ def _process_result(result, project): 'isAnalystProject': is_internal_anvil_project(project), }) if add_permissions: - result['canEdit'] = has_project_permissions(project, user, can_edit=True) + result['canEdit'] = has_project_edit_permission(project, user) if add_project_category_guids_field: result['projectCategoryGuids'] = list(project.projectcategory_set.values_list('guid', flat=True)) diff --git a/seqr/views/utils/permissions_utils.py b/seqr/views/utils/permissions_utils.py index 46b321132b..a438cc7de0 100644 --- a/seqr/views/utils/permissions_utils.py +++ b/seqr/views/utils/permissions_utils.py @@ -126,7 +126,7 @@ def _get_project_and_check_permissions(project_guid, user, _check_permission_fun return project def check_project_pm_permission(project, user, override_permission_func=None, **kwargs): - if user_is_pm(user) or (project.has_case_review and has_project_permissions(project, user, can_edit=True)): + if user_is_pm(user) or (project.has_case_review and has_project_edit_permission(project, user)): return if override_permission_func and override_permission_func(project, user): @@ -140,7 +140,7 @@ def project_has_anvil(project): def external_anvil_project_can_edit(project, user): - return project_has_anvil(project) and has_project_permissions(project, user, can_edit=True) and not \ + return project_has_anvil(project) and has_project_edit_permission(project, user) and not \ is_internal_anvil_project(project) @@ -188,14 +188,19 @@ def get_workspace_collaborator_perms(user, workspace_namespace, workspace_name): return permission_levels -def has_project_permissions(project, user, can_edit=False): - permission_level = CAN_VIEW - if can_edit: - permission_level = CAN_EDIT +def has_project_edit_permission(project, user): + return _has_project_permissions(project, user, CAN_EDIT) - return user_is_data_manager(user) or \ - (not can_edit and project.all_user_demo and project.is_demo) or \ - _user_project_permission(user, permission_level, project) +def _has_project_view_permission(project, user): + if project.all_user_demo and project.is_demo: + return True + return _has_project_permissions(project, user, CAN_VIEW) + +def has_family_view_permission(family, user): + return _has_project_view_permission(family.project, user) + +def _has_project_permissions(project, user, permission_level): + return user_is_data_manager(user) or _user_project_permission(user, permission_level, project) def _user_project_permission(user, permission_level, project): @@ -205,7 +210,7 @@ def _user_project_permission(user, permission_level, project): def check_project_edit_permission(project, user): - if has_project_permissions(project, user, can_edit=True): + if has_project_edit_permission(project, user): return raise PermissionDenied("{user} does not have sufficient permissions for {project}".format( @@ -213,16 +218,17 @@ def check_project_edit_permission(project, user): def _check_project_view_permission(project, user): - if has_project_permissions(project, user): + if _has_project_view_permission(project, user): return - raise PermissionDenied("{user} does not have sufficient permissions for {project}".format( - user=user, project=project)) + raise PermissionDenied(f'{user} does not have sufficient permissions for {project}') def check_family_view_permission(family, user): - _check_project_view_permission(family.project, user) - # TODO check analysis group perms + if has_family_view_permission(family, user): + return + + raise PermissionDenied(f'{user} does not have sufficient permissions for {family}') def _is_user_created_object(obj, user): @@ -293,4 +299,4 @@ def check_mme_permissions(submission, user): def has_case_review_permissions(project, user): if not project.has_case_review: return False - return has_project_permissions(project, user, can_edit=True) + return has_project_edit_permission(project, user) From 12ad278d33a57faae8afc9e73d4721f038c18dc2 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 11:39:46 -0400 Subject: [PATCH 33/39] rename helper --- seqr/views/apis/igv_api.py | 4 ++-- seqr/views/apis/locus_list_api.py | 4 ++-- seqr/views/apis/project_api.py | 26 +++++++++++++------------- seqr/views/apis/report_api.py | 6 +++--- seqr/views/apis/saved_variant_api.py | 4 ++-- seqr/views/apis/summary_data_api.py | 4 ++-- seqr/views/apis/users_api.py | 4 ++-- seqr/views/utils/permissions_utils.py | 4 ++-- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/seqr/views/apis/igv_api.py b/seqr/views/apis/igv_api.py index 1f1467a62d..2fb4becd71 100644 --- a/seqr/views/apis/igv_api.py +++ b/seqr/views/apis/igv_api.py @@ -13,7 +13,7 @@ from seqr.views.utils.json_to_orm_utils import get_or_create_model_from_json from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_sample -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, external_anvil_project_can_edit, \ +from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, external_anvil_project_can_edit, \ login_and_policies_required, pm_or_data_manager_required, get_project_guids_user_can_view, user_is_data_manager, \ user_is_pm, get_project_and_check_edit_permission @@ -185,7 +185,7 @@ def update_individual_igv_sample(request, individual_guid): @login_and_policies_required def fetch_igv_track(request, project_guid, igv_track_path): - get_project_and_check_permissions(project_guid, request.user) + get_project_and_check_view_permission(project_guid, request.user) if igv_track_path.endswith('.bam.bai') and not does_file_exist(igv_track_path, user=request.user): igv_track_path = igv_track_path.replace('.bam.bai', '.bai') diff --git a/seqr/views/apis/locus_list_api.py b/seqr/views/apis/locus_list_api.py index 103868c6da..f8c2e3dda3 100644 --- a/seqr/views/apis/locus_list_api.py +++ b/seqr/views/apis/locus_list_api.py @@ -12,7 +12,7 @@ create_model_from_json from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_locus_lists, get_json_for_locus_list -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_locus_list_permissions, \ +from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, check_locus_list_permissions, \ check_user_created_object_permissions, login_and_policies_required, get_project_guids_user_can_view, \ get_project_and_check_edit_permission @@ -136,7 +136,7 @@ def delete_locus_list_handler(request, locus_list_guid): @login_and_policies_required def add_project_locus_lists(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) request_json = json.loads(request.body) locus_lists = LocusList.objects.filter(guid__in=request_json['locusListGuids']) for locus_list in locus_lists: diff --git a/seqr/views/apis/project_api.py b/seqr/views/apis/project_api.py index 17383a67c9..0c6b7bef67 100644 --- a/seqr/views/apis/project_api.py +++ b/seqr/views/apis/project_api.py @@ -22,7 +22,7 @@ get_json_for_project_collaborator_list, get_json_for_matchmaker_submissions, \ get_json_for_family_notes, _get_json_for_individuals, get_json_for_project_collaborator_groups, \ FAMILY_ADDITIONAL_VALUES -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, get_project_and_check_edit_permission, \ +from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, get_project_and_check_edit_permission, \ check_user_created_object_permissions, pm_required, user_is_pm, login_and_policies_required, \ has_workspace_perm, has_case_review_permissions, is_internal_anvil_project, get_project_and_check_pm_permissions, \ check_project_pm_permission, user_is_data_manager, external_anvil_project_can_edit, get_project_and_check_edit_permission @@ -129,7 +129,7 @@ def delete_project_handler(request, project_guid): @login_and_policies_required def project_page_data(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) update_project_from_json(project, {'last_accessed_date': timezone.now()}, request.user) return create_json_response({ 'projectsByGuid': { @@ -169,7 +169,7 @@ def _get_formatted_value(value, config, *args): @login_and_policies_required def project_families(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) family_models = Family.objects.filter(project=project) families = family_models.values( @@ -213,7 +213,7 @@ def project_families(request, project_guid): @login_and_policies_required def project_overview(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) datasets = Dataset.objects.filter( Q(active_individuals__family__project=project) | Q(inactive_individuals__family__project=project) @@ -250,7 +250,7 @@ def project_overview(request, project_guid): @login_and_policies_required def project_collaborators(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) return create_json_response({ 'projectsByGuid': {project_guid: { @@ -262,7 +262,7 @@ def project_collaborators(request, project_guid): @login_and_policies_required def project_individuals(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) individuals = _get_json_for_individuals( Individual.objects.filter(family__project=project), user=request.user, project_guid=project_guid, add_hpo_details=True, has_case_review_perm=has_case_review_permissions(project, request.user)) @@ -274,7 +274,7 @@ def project_individuals(request, project_guid): @login_and_policies_required def project_analysis_groups(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) return create_json_response({ 'analysisGroupsByGuid': get_project_analysis_groups([project], project_guid) @@ -283,7 +283,7 @@ def project_analysis_groups(request, project_guid): @login_and_policies_required def project_locus_lists(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) locus_list_json, _ = get_project_locus_lists([project], request.user, include_metadata=True) return create_json_response({ @@ -294,7 +294,7 @@ def project_locus_lists(request, project_guid): @login_and_policies_required def project_family_notes(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) family_notes = get_json_for_family_notes(FamilyNote.objects.filter(family__project=project), is_analyst=False) return create_json_response({ @@ -303,7 +303,7 @@ def project_family_notes(request, project_guid): @login_and_policies_required def project_mme_submisssions(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) models = MatchmakerSubmission.objects.filter( individual__family__project=project).prefetch_related('matchmakersubmissiongenes_set') @@ -323,7 +323,7 @@ def project_mme_submisssions(request, project_guid): @login_and_policies_required def project_notifications(request, project_guid, read_status): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) is_subscriber = project.subscribers.user_set.filter(id=request.user.id).exists() if not is_subscriber: max_loaded = _project_notifications( @@ -358,14 +358,14 @@ def _project_notifications(project, notifications): @login_and_policies_required def mark_read_project_notifications(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) _project_notifications(project, request.user.notifications).mark_all_as_read() return create_json_response({'readCount': request.user.notifications.read().count(), 'unreadNotifications': []}) @login_and_policies_required def subscribe_project_notifications(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) request.user.groups.add(project.subscribers) return create_json_response({'isSubscriber': True}) diff --git a/seqr/views/apis/report_api.py b/seqr/views/apis/report_api.py index b99627cc5a..d619525d28 100644 --- a/seqr/views/apis/report_api.py +++ b/seqr/views/apis/report_api.py @@ -22,7 +22,7 @@ from seqr.views.utils.export_utils import export_multiple_files, write_multiple_files, export_table from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_queryset -from seqr.views.utils.permissions_utils import user_is_analyst, get_project_and_check_permissions, \ +from seqr.views.utils.permissions_utils import user_is_analyst, get_project_and_check_view_permission, \ get_project_guids_user_can_view, get_internal_projects, pm_or_analyst_required, active_user_has_policies_and_passes_test from seqr.views.utils.terra_api_utils import anvil_enabled from seqr.views.utils.variant_utils import DISCOVERY_CATEGORY @@ -185,7 +185,7 @@ def _format_export_row(loaded, count, sample_type, dataset_type, demo, no_anvil= @airtable_enabled_analyst_required def anvil_export(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) parsed_rows = defaultdict(list) family_id_map = {} @@ -1031,7 +1031,7 @@ def _get_metadata_projects(project_guid, user): return get_internal_projects().filter(guid__in=get_project_guids_user_can_view(user)) if project_guid == GREGOR_CATEGORY.lower(): return Project.objects.filter(projectcategory__name=GREGOR_CATEGORY) - return [get_project_and_check_permissions(project_guid, user)] + return [get_project_and_check_view_permission(project_guid, user)] ANALYSIS_DATA_TYPE_LOOKUP = dict(FamilyAnalysedBy.DATA_TYPE_CHOICES) diff --git a/seqr/views/apis/saved_variant_api.py b/seqr/views/apis/saved_variant_api.py index 11c28f58a0..56ec4840c2 100644 --- a/seqr/views/apis/saved_variant_api.py +++ b/seqr/views/apis/saved_variant_api.py @@ -12,7 +12,7 @@ from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_variant_note, \ get_json_for_saved_variants_child_entities, get_json_for_gene_notes_by_gene_id, STRUCTURED_METADATA_TAG_TYPES -from seqr.views.utils.permissions_utils import get_project_and_check_permissions, check_project_edit_permission, \ +from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, check_project_edit_permission, \ login_and_policies_required, check_family_view_permission from seqr.views.utils.variant_utils import get_variants_response, parse_saved_variant_json, DISCOVERY_CATEGORY @@ -23,7 +23,7 @@ @login_and_policies_required def saved_variant_data(request, project_guid, variant_guids=None): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) family_guids = request.GET['families'].split(',') if request.GET.get('families') else None variant_guids = variant_guids.split(',') if variant_guids else None diff --git a/seqr/views/apis/summary_data_api.py b/seqr/views/apis/summary_data_api.py index 6316d0167b..50d7647b7a 100644 --- a/seqr/views/apis/summary_data_api.py +++ b/seqr/views/apis/summary_data_api.py @@ -22,7 +22,7 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_matchmaker_submissions, get_json_for_saved_variants,\ add_individual_hpo_details, INDIVIDUAL_DISPLAY_NAME_EXPR, AIP_TAG_TYPE from seqr.views.utils.permissions_utils import analyst_required, user_is_analyst, get_project_guids_user_can_view, \ - login_and_policies_required, get_project_and_check_permissions, get_internal_projects + login_and_policies_required, get_project_and_check_view_permission, get_internal_projects from seqr.views.utils.anvil_metadata_utils import parse_anvil_metadata, anvil_export_airtable_fields, FAMILY_ROW_TYPE, SUBJECT_ROW_TYPE, DISCOVERY_ROW_TYPE from seqr.views.utils.variant_utils import get_variants_response, bulk_create_tagged_variants, get_saved_variant_annotations, DISCOVERY_CATEGORY from settings import SEQR_SLACK_DATA_ALERTS_NOTIFICATION_CHANNEL @@ -305,7 +305,7 @@ def _get_metadata_projects(request, project_guid): raise PermissionDenied() projects = Project.objects.filter(projectcategory__name__iexact=GREGOR_CATEGORY) else: - projects = [get_project_and_check_permissions(project_guid, request.user)] + projects = [get_project_and_check_view_permission(project_guid, request.user)] return projects, include_airtable diff --git a/seqr/views/apis/users_api.py b/seqr/views/apis/users_api.py index c70e8ee25f..ed258a894c 100644 --- a/seqr/views/apis/users_api.py +++ b/seqr/views/apis/users_api.py @@ -14,7 +14,7 @@ from seqr.views.utils.json_utils import create_json_response from seqr.views.utils.orm_to_json_utils import get_json_for_user, get_json_for_project_collaborator_list, \ get_project_collaborators_by_username, get_json_for_project_collaborator_groups, PROJECT_ACCESS_GROUP_NAMES -from seqr.views.utils.permissions_utils import get_project_guids_user_can_view, get_project_and_check_permissions, \ +from seqr.views.utils.permissions_utils import get_project_guids_user_can_view, get_project_and_check_view_permission, \ login_and_policies_required, login_active_required, active_user_has_policies_and_passes_test, \ get_project_and_check_edit_permission from seqr.views.utils.terra_api_utils import oauth_enabled, anvil_enabled @@ -49,7 +49,7 @@ def get_all_user_group_options(request): @login_and_policies_required def get_project_collaborator_options(request, project_guid): - project = get_project_and_check_permissions(project_guid, request.user) + project = get_project_and_check_view_permission(project_guid, request.user) user_fields = {'display_name', 'username', 'email'} users = get_project_collaborators_by_username( request.user, project, fields=user_fields, expand_user_groups=True, diff --git a/seqr/views/utils/permissions_utils.py b/seqr/views/utils/permissions_utils.py index a438cc7de0..e01cd5248a 100644 --- a/seqr/views/utils/permissions_utils.py +++ b/seqr/views/utils/permissions_utils.py @@ -111,9 +111,9 @@ def get_internal_projects(): return Project.objects.all() def get_project_and_check_edit_permission(project_guid, user): - return _get_project_and_check_permissions(project_guid, user, check_project_edit_permission,) + return _get_project_and_check_permissions(project_guid, user, check_project_edit_permission) -def get_project_and_check_permissions(project_guid, user): +def get_project_and_check_view_permission(project_guid, user): return _get_project_and_check_permissions(project_guid, user, _check_project_view_permission) def get_project_and_check_pm_permissions(project_guid, user, override_permission_func=None): From b7fd17f0221ccfd74336697a64affd12a8b8b4c0 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 12:14:29 -0400 Subject: [PATCH 34/39] better multi project permission check --- seqr/views/apis/analysis_group_api.py | 1 - seqr/views/apis/saved_variant_api.py | 12 +++++------- seqr/views/apis/saved_variant_api_tests.py | 10 ++++++++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/seqr/views/apis/analysis_group_api.py b/seqr/views/apis/analysis_group_api.py index 336b416bc8..dd88951653 100644 --- a/seqr/views/apis/analysis_group_api.py +++ b/seqr/views/apis/analysis_group_api.py @@ -1,4 +1,3 @@ -from django.core.exceptions import PermissionDenied import json from seqr.models import AnalysisGroup, DynamicAnalysisGroup, Family diff --git a/seqr/views/apis/saved_variant_api.py b/seqr/views/apis/saved_variant_api.py index 56ec4840c2..4267032914 100644 --- a/seqr/views/apis/saved_variant_api.py +++ b/seqr/views/apis/saved_variant_api.py @@ -13,7 +13,7 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_variant_note, \ get_json_for_saved_variants_child_entities, get_json_for_gene_notes_by_gene_id, STRUCTURED_METADATA_TAG_TYPES from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, check_project_edit_permission, \ - login_and_policies_required, check_family_view_permission + login_and_policies_required, check_family_view_permission, check_projects_view_permission from seqr.views.utils.variant_utils import get_variants_response, parse_saved_variant_json, DISCOVERY_CATEGORY @@ -180,9 +180,8 @@ def _create_variant_note(saved_variants, note_json, user): @login_and_policies_required def update_variant_note_handler(request, variant_guids, note_guid): note = VariantNote.objects.get(guid=note_guid) - families = {saved_variant.family for saved_variant in note.saved_variants.all()} - for family in families: - check_family_view_permission(family, request.user) + projects = Project.objects.filter(family__in=note.saved_variants.values_list('family_id', flat=True)).distinct() + check_projects_view_permission(projects, request.user) request_json = json.loads(request.body) update_model_from_json(note, request_json, user=request.user, allow_unknown_keys=True) @@ -198,9 +197,8 @@ def update_variant_note_handler(request, variant_guids, note_guid): def delete_variant_note_handler(request, variant_guids, note_guid): variant_guids = variant_guids.split(',') note = VariantNote.objects.get(guid=note_guid) - families = {saved_variant.family for saved_variant in note.saved_variants.all()} - for family in families: - check_family_view_permission(family, request.user) + projects = Project.objects.filter(family__in=note.saved_variants.values_list('family_id', flat=True)).distinct() + check_projects_view_permission(projects, request.user) note.delete_model(request.user, user_can_delete=True) saved_variants_by_guid = {} diff --git a/seqr/views/apis/saved_variant_api_tests.py b/seqr/views/apis/saved_variant_api_tests.py index c13cca28f2..6e560f2c00 100644 --- a/seqr/views/apis/saved_variant_api_tests.py +++ b/seqr/views/apis/saved_variant_api_tests.py @@ -786,7 +786,10 @@ def test_create_update_and_delete_variant_note(self): new_variant_note = VariantNote.objects.filter(guid=updated_note_response['noteGuid']) self.assertEqual(len(new_variant_note), 0) - self.assert_no_list_ws_has_al(8) + self.mock_list_workspaces.assert_called_with(self.collaborator_user) + self.assertEqual(self.mock_list_workspaces.call_count, 2) + self.assertEqual(self.mock_get_ws_access_level.call_count, 6) + self.assert_no_extra_anvil_calls() def test_create_partially_saved_compound_het_variant_note(self): # compound het 5 is not saved, whereas compound het 1 is saved @@ -940,7 +943,10 @@ def test_create_update_and_delete_compound_hets_variant_note(self): variants = SavedVariant.objects.filter(guid__in=[COMPOUND_HET_1_GUID, COMPOUND_HET_2_GUID]) self.assertEqual(len(variants), 0) - self.assert_no_list_ws_has_al(7) + self.mock_list_workspaces.assert_called_with(self.collaborator_user) + self.assertEqual(self.mock_list_workspaces.call_count, 3) + self.assertEqual(self.mock_get_ws_access_level.call_count, 4) + self.assert_no_extra_anvil_calls() def test_update_variant_tags(self): variant_tags = VariantTag.objects.filter(saved_variants__guid__contains=VARIANT_GUID) From 32b6c682801fafe115b9ec302ce755f660e37e6b Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 12:17:30 -0400 Subject: [PATCH 35/39] remove dup import --- seqr/views/apis/project_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seqr/views/apis/project_api.py b/seqr/views/apis/project_api.py index 0c6b7bef67..4f0669ca6a 100644 --- a/seqr/views/apis/project_api.py +++ b/seqr/views/apis/project_api.py @@ -25,7 +25,7 @@ from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, get_project_and_check_edit_permission, \ check_user_created_object_permissions, pm_required, user_is_pm, login_and_policies_required, \ has_workspace_perm, has_case_review_permissions, is_internal_anvil_project, get_project_and_check_pm_permissions, \ - check_project_pm_permission, user_is_data_manager, external_anvil_project_can_edit, get_project_and_check_edit_permission + check_project_pm_permission, user_is_data_manager, external_anvil_project_can_edit from seqr.views.utils.project_context_utils import families_discovery_tags, \ add_project_tag_type_counts, get_project_analysis_groups, get_project_locus_lists from seqr.views.utils.terra_api_utils import is_anvil_authenticated, anvil_enabled From f9b678faf6c5fc68f87063eeb7e9250747adf5d0 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 15:06:59 -0400 Subject: [PATCH 36/39] check multiple families permissions --- seqr/views/apis/saved_variant_api.py | 12 ++++++------ seqr/views/apis/variant_search_api.py | 25 +++++++++++++------------ seqr/views/utils/permissions_utils.py | 4 ++-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/seqr/views/apis/saved_variant_api.py b/seqr/views/apis/saved_variant_api.py index 4267032914..27f8761597 100644 --- a/seqr/views/apis/saved_variant_api.py +++ b/seqr/views/apis/saved_variant_api.py @@ -5,7 +5,7 @@ from clickhouse_search.models.postgres_dicts import DiscoveryVariantDict, ExcludedVariantDict from seqr.models import SavedVariant, VariantTagType, VariantTag, VariantNote, VariantFunctionalData,\ - Family, GeneNote, Project, Dataset + Family, GeneNote, Dataset from seqr.utils.xpos_utils import get_xpos from seqr.views.utils.json_to_orm_utils import update_model_from_json, get_or_create_model_from_json, \ create_model_from_json @@ -13,7 +13,7 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_variant_note, \ get_json_for_saved_variants_child_entities, get_json_for_gene_notes_by_gene_id, STRUCTURED_METADATA_TAG_TYPES from seqr.views.utils.permissions_utils import get_project_and_check_view_permission, check_project_edit_permission, \ - login_and_policies_required, check_family_view_permission, check_projects_view_permission + login_and_policies_required, check_family_view_permission, check_families_view_permission from seqr.views.utils.variant_utils import get_variants_response, parse_saved_variant_json, DISCOVERY_CATEGORY @@ -180,8 +180,8 @@ def _create_variant_note(saved_variants, note_json, user): @login_and_policies_required def update_variant_note_handler(request, variant_guids, note_guid): note = VariantNote.objects.get(guid=note_guid) - projects = Project.objects.filter(family__in=note.saved_variants.values_list('family_id', flat=True)).distinct() - check_projects_view_permission(projects, request.user) + families = Family.objects.filter(id__in=note.saved_variants.values_list('family_id', flat=True)) + check_families_view_permission(families, request.user) request_json = json.loads(request.body) update_model_from_json(note, request_json, user=request.user, allow_unknown_keys=True) @@ -197,8 +197,8 @@ def update_variant_note_handler(request, variant_guids, note_guid): def delete_variant_note_handler(request, variant_guids, note_guid): variant_guids = variant_guids.split(',') note = VariantNote.objects.get(guid=note_guid) - projects = Project.objects.filter(family__in=note.saved_variants.values_list('family_id', flat=True)).distinct() - check_projects_view_permission(projects, request.user) + families = Family.objects.filter(id__in=note.saved_variants.values_list('family_id', flat=True)) + check_families_view_permission(families, request.user) note.delete_model(request.user, user_can_delete=True) saved_variants_by_guid = {} diff --git a/seqr/views/apis/variant_search_api.py b/seqr/views/apis/variant_search_api.py index eba0f7cdee..2c23852e17 100644 --- a/seqr/views/apis/variant_search_api.py +++ b/seqr/views/apis/variant_search_api.py @@ -29,7 +29,7 @@ from seqr.views.utils.orm_to_json_utils import get_json_for_saved_variants_with_tags, get_json_for_saved_search,\ get_json_for_saved_searches, FAMILY_ADDITIONAL_VALUES from seqr.views.utils.permissions_utils import check_family_view_permission, get_project_guids_user_can_view, \ - login_and_policies_required, check_user_created_object_permissions, check_projects_view_permission, user_is_analyst + login_and_policies_required, check_user_created_object_permissions, check_families_view_permission, user_is_analyst from seqr.views.utils.project_context_utils import get_projects_child_entities from seqr.views.utils.variant_utils import get_variants_response, variant_dataset_type from seqr.views.utils.vlm_utils import vlm_lookup @@ -392,29 +392,30 @@ def search_context_handler(request): response = _get_saved_searches(request.user) context = json.loads(request.body) - projects = None + families = None if context.get('projectGuid'): - projects = Project.objects.filter(guid=context.get('projectGuid')) + families = Family.objects.filter(project__guid=context.get('projectGuid')) elif context.get('familyGuid'): - projects = Project.objects.filter(family__guid=context.get('familyGuid')) + families = Family.objects.filter(guid=context.get('familyGuid')) elif context.get('analysisGroupGuid'): - projects = Project.objects.filter(analysisgroup__guid=context.get('analysisGroupGuid')) + families = Family.objects.filter(analysisgroup__guid=context.get('analysisGroupGuid')) elif context.get('projectCategoryGuid'): - projects = Project.objects.filter(projectcategory__guid=context.get('projectCategoryGuid')) + families = Family.objects.filter(project__projectcategory__guid=context.get('projectCategoryGuid')) elif context.get('searchHash'): search_context = context.get('searchParams') try: results_model = _get_or_create_results_model(context['searchHash'], search_context, request.user) except Exception as e: return create_json_response({'error': str(e)}, status=400, reason=str(e)) - projects = Project.objects.filter(family__in=results_model.families.all()).distinct() + families = results_model.families.all() - if not projects: + if not families: error = 'Invalid context params: {}'.format(json.dumps(context)) return create_json_response({'error': error}, status=400, reason=error) - check_projects_view_permission(projects, request.user) + check_families_view_permission(families, request.user) + projects = Project.objects.filter(family__in=families).distinct() project_guid = projects[0].guid if len(projects) == 1 else None response.update(get_projects_child_entities(projects, project_guid, request.user)) @@ -511,10 +512,10 @@ def delete_saved_search_handler(request, saved_search_guid): def _check_results_permission(results_model, user, project_perm_check=None): - projects = Project.objects.filter(family__variantsearchresults=results_model) - check_projects_view_permission(projects, user) + families = Family.objects.filter(variantsearchresults=results_model) + check_families_view_permission(families, user) if project_perm_check: - for project in projects: + for project in {f.project for f in families.prefetch_related('project')}: if not project_perm_check(project): raise PermissionDenied() return projects diff --git a/seqr/views/utils/permissions_utils.py b/seqr/views/utils/permissions_utils.py index e01cd5248a..b3e80a4d26 100644 --- a/seqr/views/utils/permissions_utils.py +++ b/seqr/views/utils/permissions_utils.py @@ -245,8 +245,8 @@ def _get_all_can_view_project_guids_set(user): return set(get_project_guids_user_can_view(user, limit_data_manager=False)) -def check_projects_view_permission(projects, user): - no_access_projects = set(projects.values_list('guid', flat=True)) - _get_all_can_view_project_guids_set(user) +def check_families_view_permission(families, user): + no_access_projects = set(families.values_list('project__guid', flat=True).distinct()) - _get_all_can_view_project_guids_set(user) if no_access_projects: raise PermissionDenied(f"{user} does not have sufficient permissions for {','.join(no_access_projects)}") From af6f769df6e9c9af864c27e7986d2638ffbbf9b4 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 15:21:59 -0400 Subject: [PATCH 37/39] fix func return --- seqr/views/apis/variant_search_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/seqr/views/apis/variant_search_api.py b/seqr/views/apis/variant_search_api.py index 2c23852e17..f5ff227453 100644 --- a/seqr/views/apis/variant_search_api.py +++ b/seqr/views/apis/variant_search_api.py @@ -276,7 +276,7 @@ def _process_variants(variants, families, request, add_all_context=False, add_lo @login_and_policies_required def get_variant_gene_breakdown(request, search_hash): results_model = VariantSearchResults.objects.get(search_hash=search_hash) - projects = _check_results_permission(results_model, request.user) + genome_version = _check_results_permission(results_model, request.user) results = _get_variants_with_cache(_get_search_cache_key, _query_variants, results_model, request.user) flat_variants = [ @@ -292,7 +292,7 @@ def get_variant_gene_breakdown(request, search_hash): return create_json_response({ 'searchGeneBreakdown': {search_hash: gene_counts}, - 'genesById': get_genes_for_variant_display(list(gene_counts.keys()), projects.first().genome_version), + 'genesById': get_genes_for_variant_display(list(gene_counts.keys()), genome_version), }) @@ -518,7 +518,7 @@ def _check_results_permission(results_model, user, project_perm_check=None): for project in {f.project for f in families.prefetch_related('project')}: if not project_perm_check(project): raise PermissionDenied() - return projects + return families.first().project.genome_version def _get_search_context(results_model): From ab3698b8be2f119f0aef6124ef9be3ec36ff7f17 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Wed, 1 Jul 2026 15:40:31 -0400 Subject: [PATCH 38/39] fix empty result set bug --- seqr/views/apis/variant_search_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/seqr/views/apis/variant_search_api.py b/seqr/views/apis/variant_search_api.py index f5ff227453..1a8fcf2504 100644 --- a/seqr/views/apis/variant_search_api.py +++ b/seqr/views/apis/variant_search_api.py @@ -518,7 +518,8 @@ def _check_results_permission(results_model, user, project_perm_check=None): for project in {f.project for f in families.prefetch_related('project')}: if not project_perm_check(project): raise PermissionDenied() - return families.first().project.genome_version + family = families.first() + return family.project.genome_version if family else None def _get_search_context(results_model): From b982966f02533bd54e09e6c11df53dde62212f42 Mon Sep 17 00:00:00 2001 From: Hana Snow Date: Tue, 7 Jul 2026 13:39:13 -0400 Subject: [PATCH 39/39] fix gene breakdown results in external projects --- clickhouse_search/all_search_tests.py | 11 +++++++++-- seqr/views/apis/variant_search_api.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/clickhouse_search/all_search_tests.py b/clickhouse_search/all_search_tests.py index 74d4c11442..f367ba0f60 100644 --- a/clickhouse_search/all_search_tests.py +++ b/clickhouse_search/all_search_tests.py @@ -2408,7 +2408,7 @@ def test_gene_variant_lookup(self): cached_variant_fields=[{'selectedTranscript': CACHED_CONSEQUENCES_BY_KEY[4][1]}], annotations=annotations, freqs=freqs, locus=locus, project_families=[], export_data=[ EXPORT_DATA[0][:27], EXPORT_DATA[4][:24] + ['3 Families', '', ''], - ], + ], gene_counts={'ENSG00000097046': {'total': 1, 'families': {}}}, ) freqs = {'callset': freqs['callset']} @@ -2443,6 +2443,10 @@ def test_gene_variant_lookup(self): {'selectedTranscript': CACHED_CONSEQUENCES_BY_KEY[4][1]}, ], annotations=annotations, freqs=freqs, locus=locus, inheritance_mode='de_novo', project_families=[], + gene_counts={ + 'ENSG00000097046': {'total': 2, 'families': {}}, + 'ENSG00000177000': {'total': 1, 'families': {}}, + }, ) self.login_collaborator() @@ -2455,7 +2459,10 @@ def test_gene_variant_lookup(self): {'selectedTranscript': CACHED_CONSEQUENCES_BY_KEY[3][3]}, {'selectedTranscript': CACHED_CONSEQUENCES_BY_KEY[4][1]}, ], - annotations=annotations, freqs=freqs, locus=locus, inheritance_mode='de_novo', + annotations=annotations, freqs=freqs, locus=locus, inheritance_mode='de_novo', gene_counts={ + 'ENSG00000097046': {'total': 2, 'families': {'F000002_2': 1, 'F000003_3': 1}}, + 'ENSG00000177000': {'total': 1, 'families': {'F000003_3': 1}}, + }, ) locus['rawItems'] = 'ENSG00000171621' diff --git a/seqr/views/apis/variant_search_api.py b/seqr/views/apis/variant_search_api.py index 1a8fcf2504..3a00b5a078 100644 --- a/seqr/views/apis/variant_search_api.py +++ b/seqr/views/apis/variant_search_api.py @@ -287,7 +287,7 @@ def get_variant_gene_breakdown(request, search_hash): gene_ids = var['transcripts'].keys() if 'transcripts' in var else {t['geneId'] for t in var['sortedTranscriptConsequences']} for gene_id in gene_ids: gene_counts[gene_id]['total'] += 1 - for family_guid in var['familyGuids']: + for family_guid in var.get('familyGuids', []): gene_counts[gene_id]['families'][family_guid] += 1 return create_json_response({