From f9d5199a52929b81d0d5bc3f88f07b883bcc98ac Mon Sep 17 00:00:00 2001 From: svarona Date: Mon, 29 Jun 2026 10:41:38 +0200 Subject: [PATCH 01/21] increased max_length for bioinfo analysis values --- core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/models.py b/core/models.py index 3ecbe12a..ed092c5b 100644 --- a/core/models.py +++ b/core/models.py @@ -413,7 +413,7 @@ def create_new_value(self, data): class BioinfoAnalysisValue(models.Model): - value = models.CharField(max_length=400, null=True, blank=True) + value = models.CharField(max_length=1000, null=True, blank=True) bioinfo_analysis_fieldID = models.ForeignKey( BioinfoAnalysisField, on_delete=models.CASCADE ) From 2a468139c7a965a1b1983086d1ec91f94794c115 Mon Sep 17 00:00:00 2001 From: svarona Date: Mon, 29 Jun 2026 12:46:47 +0200 Subject: [PATCH 02/21] Added multiple selection check box to Collecting Institution --- core/templates/core/searchSample.html | 161 +++++++++++++++++++++++++- core/views.py | 15 ++- 2 files changed, 170 insertions(+), 6 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index 6f90c5e5..723d7121 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -25,12 +25,83 @@ #sample_list th:nth-child(2) input { white-space: nowrap; } + #sample_list thead tr.filter-row th { + vertical-align: top; + } + #sample_list thead tr.filter-row th input, + #sample_list thead tr.filter-row th select { + margin-top: 0; + } #sample_list th:last-child, #sample_list td:last-child { min-width: 260px; + white-space: normal; + overflow-wrap: anywhere; + } + #sample_list thead tr.filter-row th:last-child .institution-search-wrapper, + #sample_list thead tr.filter-row th:last-child button:not(.institution-search-clear), + #sample_list .institution-filter-options { + width: 100%; + max-width: 100%; + box-sizing: border-box; + } + #sample_list .institution-search-wrapper { + position: relative; + } + #sample_list .institution-search-wrapper input { + padding-right: 1.8rem; + } + #sample_list .institution-search-clear { + position: absolute; + top: 50%; + right: 0.45rem; + transform: translateY(-50%); + border: 0; + background: transparent; + color: #6c757d; + line-height: 1; + padding: 0; + width: 1rem; + height: 1rem; } - #sample_list thead tr.filter-row th:last-child select { - min-width: 220px; + #sample_list .institution-search-clear:hover, + #sample_list .institution-search-clear:focus { + color: var(--color-primary); + outline: none; + } + #sample_list thead tr.filter-row th:last-child button:not(.institution-search-clear) { + white-space: normal; + } + #sample_list .institution-filter-options { + max-height: 8rem; + overflow-x: hidden; + overflow-y: auto; + border: 1px solid #ced4da; + border-radius: 0.25rem; + background: #fff; + padding: 0.35rem 0.5rem; + } + #sample_list .institution-filter-option { + display: flex; + align-items: flex-start; + gap: 0.4rem; + font-size: 0.82rem; + font-weight: 400; + line-height: 1.15; + padding: 0.2rem 0; + white-space: normal; + overflow-wrap: anywhere; + } + #sample_list .institution-filter-option input { + flex: 0 0 auto; + margin-top: 0.15rem; + } + #sample_list .institution-filter-option span { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + overflow-wrap: anywhere; } #sample_list a.sample-browser-link { color: var(--color-blue); @@ -207,7 +278,7 @@

Sample Browser

}); }); - [2, 3].forEach(function(index) { + [2].forEach(function(index) { var column = api.column(index); var headerCell = $('.filter-row th').eq(index); // Use the empty row for filters var columnName = $('.dataTable thead tr:first th').eq(index).text().trim(); @@ -223,11 +294,91 @@

Sample Browser

column.search($(this).val(), false, false).draw(); }); - var options = index === 2 ? lineageOptions : institutionOptions; - options.forEach(function (d) { + lineageOptions.forEach(function (d) { select.append('') }); }); + + [3].forEach(function(index) { + var column = api.column(index); + var headerCell = $('.filter-row th').eq(index); + var columnName = $('.dataTable thead tr:first th').eq(index).text().trim(); + var selectedInstitutions = []; + headerCell.empty(); + var searchWrapper = $('
') + .appendTo(headerCell) + .on('click mousedown keydown touchstart', function (event) { + event.stopPropagation(); + }); + var searchInput = $('') + .appendTo(searchWrapper); + $('') + .appendTo(searchWrapper) + .on('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + searchInput.val(''); + renderInstitutionOptions(''); + searchInput.trigger('focus'); + }); + $('') + .appendTo(headerCell) + .on('click mousedown keydown touchstart', function (event) { + event.stopPropagation(); + }) + .on('click', function () { + selectedInstitutions = []; + searchInput.val(''); + column.search('', false, false).draw(); + renderInstitutionOptions(''); + }); + var optionContainer = $('
') + .appendTo(headerCell) + .on('click mousedown keydown touchstart', function (event) { + event.stopPropagation(); + }); + + function updateInstitutionSearch() { + column.search(selectedInstitutions.length ? JSON.stringify(selectedInstitutions) : '', false, false).draw(); + } + + function renderInstitutionOptions(searchText) { + var orderedOptions = selectedInstitutions.concat( + institutionOptions.filter(function (d) { + return selectedInstitutions.indexOf(d) === -1; + }) + ); + optionContainer.empty(); + orderedOptions.forEach(function (d) { + if (searchText && d.toLowerCase().indexOf(searchText) === -1 && selectedInstitutions.indexOf(d) === -1) { + return; + } + var optionId = 'institution-filter-' + Math.abs(d.split('').reduce(function (acc, char) { + return ((acc << 5) - acc) + char.charCodeAt(0); + }, 0)); + var label = $(''); + var checkbox = $('') + .prop('checked', selectedInstitutions.indexOf(d) !== -1) + .on('change', function () { + if (this.checked && selectedInstitutions.indexOf(d) === -1) { + selectedInstitutions.push(d); + } else if (!this.checked) { + selectedInstitutions = selectedInstitutions.filter(function (value) { + return value !== d; + }); + } + updateInstitutionSearch(); + renderInstitutionOptions(searchInput.val().toLowerCase()); + }); + label.append(checkbox).append($('').text(d)); + optionContainer.append(label); + }); + } + renderInstitutionOptions(''); + searchInput.on('keyup change clear', function () { + renderInstitutionOptions($(this).val().toLowerCase()); + }); + }); // Hide the loading message $('#sample_loading_msg').hide(); // Now show the table diff --git a/core/views.py b/core/views.py index e7c4411c..2c168e23 100644 --- a/core/views.py +++ b/core/views.py @@ -1,5 +1,6 @@ # Generic imports from datetime import datetime +import json from collections import defaultdict, OrderedDict import re from django.shortcuts import render, redirect @@ -65,10 +66,17 @@ def _get_search_sample_rows_for_user(user_obj): ] -def _normalize_datatable_search_value(value, regex=False): +def _normalize_datatable_search_value(value, regex=False, json_list=False): """Normalize DataTables search values, including escaped exact-match regexes.""" if not value: return "" + if json_list and value.startswith("[") and value.endswith("]"): + try: + return [ + str(item).strip() for item in json.loads(value) if str(item).strip() + ] + except (TypeError, ValueError, json.JSONDecodeError): + pass if regex and value.startswith("^") and value.endswith("$"): value = value[1:-1] # DataTables' escapeRegex() prefixes regex metacharacters with a @@ -92,6 +100,10 @@ def _row_matches_search(row, global_search, column_filters): if not search_value: continue candidate = str(row[field_name] or "") + if isinstance(search_value, list): + if candidate not in search_value: + return False + continue if exact_match: if candidate != search_value: return False @@ -315,6 +327,7 @@ def search_sample_data(request): request.GET.get(f"columns[{index}][search][value]", ""), request.GET.get(f"columns[{index}][search][regex]", "false").lower() == "true", + index == 3, ) exact_match = index in (2, 3) column_filters.append((field_name, search_value, exact_match)) From 72ab8a8274b0ada7a7a06cddbde8fe5fed3b308e Mon Sep 17 00:00:00 2001 From: svarona Date: Mon, 29 Jun 2026 13:14:27 +0200 Subject: [PATCH 03/21] Add Lineage and Institution filters refresh based on other filters --- core/templates/core/searchSample.html | 35 ++++++++++++++++++++++++--- core/views.py | 30 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index 723d7121..f7836d50 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -214,6 +214,8 @@

Sample Browser

$(document).ready(function() { var lineageOptions = JSON.parse(document.getElementById('search-sample-lineage-options').textContent); var institutionOptions = JSON.parse(document.getElementById('search-sample-institution-options').textContent); + var refreshLineageOptions = null; + var refreshInstitutionOptions = null; var sampleUrlTemplate = "{% url 'sample_display' 0 %}"; function escapeHtml(value) { @@ -242,6 +244,18 @@

Sample Browser

); return []; } + if (json.lineage_options) { + lineageOptions = json.lineage_options; + if (refreshLineageOptions) { + refreshLineageOptions(); + } + } + if (json.collecting_institution_options) { + institutionOptions = json.collecting_institution_options; + if (refreshInstitutionOptions) { + refreshInstitutionOptions(); + } + } return json.data || []; } }, @@ -282,7 +296,7 @@

Sample Browser

var column = api.column(index); var headerCell = $('.filter-row th').eq(index); // Use the empty row for filters var columnName = $('.dataTable thead tr:first th').eq(index).text().trim(); - var select = $('') + var select = $('') .appendTo($(headerCell.empty())) .on('click mousedown keydown touchstart', function (event) { event.stopPropagation(); @@ -294,9 +308,19 @@

Sample Browser

column.search($(this).val(), false, false).draw(); }); - lineageOptions.forEach(function (d) { - select.append('') - }); + function renderLineageOptions() { + var selectedLineage = select.val() || column.search(); + select.empty().append(''); + lineageOptions.forEach(function (d) { + select.append($('').val(d).text(d)); + }); + if (selectedLineage && lineageOptions.indexOf(selectedLineage) === -1) { + select.append($('').val(selectedLineage).text(selectedLineage)); + } + select.val(selectedLineage); + } + refreshLineageOptions = renderLineageOptions; + renderLineageOptions(); }); [3].forEach(function(index) { @@ -374,6 +398,9 @@

Sample Browser

optionContainer.append(label); }); } + refreshInstitutionOptions = function () { + renderInstitutionOptions(searchInput.val().toLowerCase()); + }; renderInstitutionOptions(''); searchInput.on('keyup change clear', function () { renderInstitutionOptions($(this).val().toLowerCase()); diff --git a/core/views.py b/core/views.py index 2c168e23..5e222379 100644 --- a/core/views.py +++ b/core/views.py @@ -338,6 +338,34 @@ def search_sample_data(request): if _row_matches_search(row, global_search, column_filters) ] + lineage_option_filters = [ + column_filter + for column_filter in column_filters + if column_filter[0] != "lineage" + ] + lineage_options = sorted( + { + row["lineage"] + for row in sample_rows + if row["lineage"] + and _row_matches_search(row, global_search, lineage_option_filters) + } + ) + + institution_option_filters = [ + column_filter + for column_filter in column_filters + if column_filter[0] != "collecting_institution" + ] + collecting_institution_options = sorted( + { + row["collecting_institution"] + for row in sample_rows + if row["collecting_institution"] + and _row_matches_search(row, global_search, institution_option_filters) + } + ) + order_column = request.GET.get("order[0][column]") order_direction = request.GET.get("order[0][dir]", "asc") if order_column is not None: @@ -362,6 +390,8 @@ def search_sample_data(request): "recordsTotal": len(sample_rows), "recordsFiltered": len(filtered_rows), "data": paginated_rows, + "lineage_options": lineage_options, + "collecting_institution_options": collecting_institution_options, } ) From 9eaefdc8d8195c894c18fa78e40b6a5414b359cb Mon Sep 17 00:00:00 2001 From: svarona Date: Mon, 29 Jun 2026 13:24:38 +0200 Subject: [PATCH 04/21] Added button to clear sample filter --- core/templates/core/searchSample.html | 34 +++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index f7836d50..93897cbf 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -17,6 +17,12 @@ #sample_list th:first-child input { white-space: nowrap; } + #sample_list .sample-id-search-wrapper { + position: relative; + } + #sample_list .sample-id-search-wrapper input { + padding-right: 1.8rem; + } #sample_list th:nth-child(2), #sample_list td:nth-child(2) { min-width: 135px; @@ -51,6 +57,7 @@ #sample_list .institution-search-wrapper input { padding-right: 1.8rem; } + #sample_list .sample-id-search-clear, #sample_list .institution-search-clear { position: absolute; top: 50%; @@ -64,6 +71,8 @@ width: 1rem; height: 1rem; } + #sample_list .sample-id-search-clear:hover, + #sample_list .sample-id-search-clear:focus, #sample_list .institution-search-clear:hover, #sample_list .institution-search-clear:focus { color: var(--color-primary); @@ -280,8 +289,16 @@

Sample Browser

[0, 1].forEach(function(index) { var column = api.column(index); var headerCell = $('.filter-row th').eq(index); + var inputContainer = headerCell.empty(); + if (index === 0) { + inputContainer = $('
') + .appendTo(headerCell) + .on('click mousedown keydown touchstart', function (event) { + event.stopPropagation(); + }); + } var input = $('') - .appendTo(headerCell.empty()) + .appendTo(inputContainer) .on('click mousedown keydown touchstart', function (event) { event.stopPropagation(); }) @@ -290,6 +307,19 @@

Sample Browser

column.search(this.value).draw(); } }); + if (index === 0) { + $('') + .appendTo(inputContainer) + .on('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + input.val(''); + if (column.search()) { + column.search('').draw(); + } + input.trigger('focus'); + }); + } }); [2].forEach(function(index) { @@ -310,7 +340,7 @@

Sample Browser

function renderLineageOptions() { var selectedLineage = select.val() || column.search(); - select.empty().append(''); + select.empty().append(''); lineageOptions.forEach(function (d) { select.append($('').val(d).text(d)); }); From d4629f199b8dfb7ad034dbe84a8c89b318526ddf Mon Sep 17 00:00:00 2001 From: svarona Date: Mon, 29 Jun 2026 13:40:50 +0200 Subject: [PATCH 05/21] Added a date range filter --- core/templates/core/searchSample.html | 72 +++++++++++++++++++-------- core/views.py | 24 ++++++++- 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index 93897cbf..c18c6785 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -25,12 +25,17 @@ } #sample_list th:nth-child(2), #sample_list td:nth-child(2) { - min-width: 135px; + min-width: 170px; } #sample_list th:nth-child(2) .dt-column-title, #sample_list th:nth-child(2) input { white-space: nowrap; } + #sample_list .date-range-filter { + display: flex; + flex-direction: column; + gap: 0.25rem; + } #sample_list thead tr.filter-row th { vertical-align: top; } @@ -286,17 +291,14 @@

Sample Browser

initComplete: function () { var api = this.api(); - [0, 1].forEach(function(index) { + [0].forEach(function(index) { var column = api.column(index); var headerCell = $('.filter-row th').eq(index); - var inputContainer = headerCell.empty(); - if (index === 0) { - inputContainer = $('
') - .appendTo(headerCell) - .on('click mousedown keydown touchstart', function (event) { - event.stopPropagation(); - }); - } + var inputContainer = $('
') + .appendTo(headerCell.empty()) + .on('click mousedown keydown touchstart', function (event) { + event.stopPropagation(); + }); var input = $('') .appendTo(inputContainer) .on('click mousedown keydown touchstart', function (event) { @@ -307,19 +309,45 @@

Sample Browser

column.search(this.value).draw(); } }); - if (index === 0) { - $('') - .appendTo(inputContainer) - .on('click', function (event) { - event.preventDefault(); - event.stopPropagation(); - input.val(''); - if (column.search()) { - column.search('').draw(); - } - input.trigger('focus'); + $('') + .appendTo(inputContainer) + .on('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + input.val(''); + if (column.search()) { + column.search('').draw(); + } + input.trigger('focus'); + }); + }); + + [1].forEach(function(index) { + var column = api.column(index); + var headerCell = $('.filter-row th').eq(index); + var dateContainer = $('
') + .appendTo(headerCell.empty()) + .on('click mousedown keydown touchstart', function (event) { + event.stopPropagation(); + }); + var startInput = $('') + .appendTo(dateContainer); + var endInput = $('') + .appendTo(dateContainer); + + function updateDateRangeSearch() { + var searchValue = ''; + if (startInput.val() || endInput.val()) { + searchValue = JSON.stringify({ + from: startInput.val(), + to: endInput.val() }); + } + if (column.search() !== searchValue) { + column.search(searchValue, false, false).draw(); + } } + startInput.add(endInput).on('change clear', updateDateRangeSearch); }); [2].forEach(function(index) { @@ -340,7 +368,7 @@

Sample Browser

function renderLineageOptions() { var selectedLineage = select.val() || column.search(); - select.empty().append(''); + select.empty().append(''); lineageOptions.forEach(function (d) { select.append($('').val(d).text(d)); }); diff --git a/core/views.py b/core/views.py index 5e222379..9ed1299e 100644 --- a/core/views.py +++ b/core/views.py @@ -66,7 +66,9 @@ def _get_search_sample_rows_for_user(user_obj): ] -def _normalize_datatable_search_value(value, regex=False, json_list=False): +def _normalize_datatable_search_value( + value, regex=False, json_list=False, json_object=False +): """Normalize DataTables search values, including escaped exact-match regexes.""" if not value: return "" @@ -77,6 +79,15 @@ def _normalize_datatable_search_value(value, regex=False, json_list=False): ] except (TypeError, ValueError, json.JSONDecodeError): pass + if json_object and value.startswith("{") and value.endswith("}"): + try: + return { + key: str(item).strip() + for key, item in json.loads(value).items() + if str(item).strip() + } + except (AttributeError, TypeError, ValueError, json.JSONDecodeError): + pass if regex and value.startswith("^") and value.endswith("$"): value = value[1:-1] # DataTables' escapeRegex() prefixes regex metacharacters with a @@ -104,6 +115,16 @@ def _row_matches_search(row, global_search, column_filters): if candidate not in search_value: return False continue + if isinstance(search_value, dict): + start_date = search_value.get("from") + end_date = search_value.get("to") + if not candidate: + return False + if start_date and candidate < start_date: + return False + if end_date and candidate > end_date: + return False + continue if exact_match: if candidate != search_value: return False @@ -328,6 +349,7 @@ def search_sample_data(request): request.GET.get(f"columns[{index}][search][regex]", "false").lower() == "true", index == 3, + index == 1, ) exact_match = index in (2, 3) column_filters.append((field_name, search_value, exact_match)) From 5afdaa782f991ab87d2706c7d0a27dafd6929825 Mon Sep 17 00:00:00 2001 From: svarona Date: Mon, 29 Jun 2026 14:52:12 +0200 Subject: [PATCH 06/21] added date range filter --- core/templates/core/searchSample.html | 71 +++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index c18c6785..22c8f5ad 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -10,7 +10,7 @@ } #sample_list th:first-child, #sample_list td:first-child { - min-width: 210px; + min-width: 190px; white-space: nowrap; } #sample_list th:first-child .dt-column-title, @@ -25,7 +25,7 @@ } #sample_list th:nth-child(2), #sample_list td:nth-child(2) { - min-width: 170px; + min-width: 150px; } #sample_list th:nth-child(2) .dt-column-title, #sample_list th:nth-child(2) input { @@ -34,7 +34,25 @@ #sample_list .date-range-filter { display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.35rem; + } + #sample_list .date-range-filter label { + display: block; + font-size: 0.78rem; + font-weight: 400; + line-height: 1.1; + margin-bottom: 0.15rem; + white-space: normal; + } + #sample_list .date-range-input-wrapper { + position: relative; + } + #sample_list .date-range-input-wrapper input { + box-sizing: border-box; + font-size: 0.82rem; + padding-left: 0.35rem; + padding-right: 1.45rem; + width: 100%; } #sample_list thead tr.filter-row th { vertical-align: top; @@ -76,8 +94,23 @@ width: 1rem; height: 1rem; } + #sample_list .date-range-clear { + position: absolute; + top: 50%; + right: 0.3rem; + transform: translateY(-50%); + border: 0; + background: transparent; + color: #6c757d; + line-height: 1; + padding: 0; + width: 0.9rem; + height: 0.9rem; + } #sample_list .sample-id-search-clear:hover, #sample_list .sample-id-search-clear:focus, + #sample_list .date-range-clear:hover, + #sample_list .date-range-clear:focus, #sample_list .institution-search-clear:hover, #sample_list .institution-search-clear:focus { color: var(--color-primary); @@ -330,10 +363,22 @@

Sample Browser

.on('click mousedown keydown touchstart', function (event) { event.stopPropagation(); }); + var startField = $('
') + .appendTo(dateContainer); + $('') + .appendTo(startField); + var startWrapper = $('
') + .appendTo(startField); var startInput = $('') + .appendTo(startWrapper); + var endField = $('
') .appendTo(dateContainer); + $('') + .appendTo(endField); + var endWrapper = $('
') + .appendTo(endField); var endInput = $('') - .appendTo(dateContainer); + .appendTo(endWrapper); function updateDateRangeSearch() { var searchValue = ''; @@ -347,6 +392,24 @@

Sample Browser

column.search(searchValue, false, false).draw(); } } + $('') + .appendTo(startWrapper) + .on('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + startInput.val(''); + updateDateRangeSearch(); + startInput.trigger('focus'); + }); + $('') + .appendTo(endWrapper) + .on('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + endInput.val(''); + updateDateRangeSearch(); + endInput.trigger('focus'); + }); startInput.add(endInput).on('change clear', updateDateRangeSearch); }); From 0311a91e0e12965931e52d4ab042114efb1e3e9b Mon Sep 17 00:00:00 2001 From: svarona Date: Tue, 30 Jun 2026 13:17:07 +0200 Subject: [PATCH 07/21] loosen the search of the submitting institution --- core/utils/samples.py | 8 +++++++- core/views.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/utils/samples.py b/core/utils/samples.py index 3093cdc2..1037c31f 100644 --- a/core/utils/samples.py +++ b/core/utils/samples.py @@ -703,7 +703,13 @@ def get_search_table_for_user(user_obj): return table_data if user_role == "Submitter": user_lab = core.utils.labs.get_lab_name_from_user(user_obj) - lab_entries = samples_to_search.get(user_lab, {}) + lab_entries = {} + if user_lab: + user_lab_normalized = user_lab.lower() + for submitter_name, entries in samples_to_search.items(): + if str(submitter_name or "").lower() == user_lab_normalized: + lab_entries = entries + break for info in lab_entries.values(): table_data.extend(info.get("rows", [])) else: diff --git a/core/views.py b/core/views.py index 9ed1299e..07109766 100644 --- a/core/views.py +++ b/core/views.py @@ -509,7 +509,7 @@ def intranet(request): if not matches_lab: continue else: - if value != lab_name: + if not value or not lab_name or value.lower() != lab_name.lower(): continue # Adapt YYYY-WNN to datetime format so it can be converted to date object converted_date = datetime.strptime(d["iso_yearweek"] + "-1", "%G-W%V-%u") From 3f606704d60f4683cb7f37f7507751c6a89c1f36 Mon Sep 17 00:00:00 2001 From: svarona Date: Tue, 30 Jun 2026 13:38:55 +0200 Subject: [PATCH 08/21] fixed Cauge percentage for submitting and collecting --- core/utils/bioinfo_analysis.py | 2 ++ core/views.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/core/utils/bioinfo_analysis.py b/core/utils/bioinfo_analysis.py index 511bffe7..8ff25152 100644 --- a/core/utils/bioinfo_analysis.py +++ b/core/utils/bioinfo_analysis.py @@ -41,6 +41,8 @@ def get_bio_analysis_stats_from_lab( bio_stats["analized"] = ( core.models.DateUpdateState.objects.select_related("sampleID") .filter(sampleID__pk__in=lab_samples, stateID__state__iexact="Bioinfo") + .values("sampleID") + .distinct() .count() ) bio_stats["received"] = ( diff --git a/core/views.py b/core/views.py index 07109766..93f5eb9e 100644 --- a/core/views.py +++ b/core/views.py @@ -536,9 +536,12 @@ def intranet(request): start = time.time() sample_lab_objs = core.utils.samples.get_sample_objs_per_lab(lab_name) print(f"Took {start - time.time()} seconds for sample_lab_objs") + analysis_lab_name = ( + lab_code if user_role == "Collector" and lab_code else lab_name + ) analysis_percent = ( core.utils.bioinfo_analysis.get_bio_analysis_stats_from_lab( - lab_name=(lab_code or lab_name), institution_type=lab_field + lab_name=analysis_lab_name, institution_type=lab_field ) ) print(f"Took {start - time.time()} seconds for analysis_percent") From daf2419aafcde06808ab457e093adc681997dc59 Mon Sep 17 00:00:00 2001 From: svarona Date: Tue, 30 Jun 2026 17:00:30 +0200 Subject: [PATCH 09/21] Added a "Download filtered table" button --- core/templates/core/searchSample.html | 21 ++++- core/urls.py | 5 ++ core/views.py | 112 ++++++++++++++++++-------- 3 files changed, 104 insertions(+), 34 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index 22c8f5ad..16cfbcfa 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -169,6 +169,18 @@ width: 100%; max-width: 100%; } + .sample-browser-download .sample-browser-csv-button { + background-color: rgba(var(--color-green-rgb), 0.12); + border-color: var(--color-green); + color: var(--color-green); + font-weight: 500; + } + .sample-browser-download .sample-browser-csv-button:hover, + .sample-browser-download .sample-browser-csv-button:focus { + background-color: var(--color-green); + border-color: var(--color-green); + color: var(--color-white); + } {% endblock %} @@ -276,7 +288,7 @@

Sample Browser

lengthMenu: [ [25, 50, 100, 200, -1], [25, 50, 100, 200, "All"] ], orderCellsTop: true, dom: - "<'row'<'col-md-3'l><'col-md-9'f>>" + + "<'row align-items-center'<'col-md-3'l><'col-md-4 sample-browser-download'><'col-md-5'f>>" + "<'row'<'col-sm-12'tr>>" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", responsive: true, @@ -323,6 +335,13 @@

Sample Browser

], initComplete: function () { var api = this.api(); + var csvUrl = "{% url 'search_sample_csv' %}"; + $('') + .appendTo($('.sample-browser-download')) + .on('click', function () { + var params = $.param(table.ajax.params()); + window.location = csvUrl + (params ? '?' + params : ''); + }); [0].forEach(function(index) { var column = api.column(index); diff --git a/core/urls.py b/core/urls.py index 5283bf00..7da10410 100644 --- a/core/urls.py +++ b/core/urls.py @@ -56,6 +56,11 @@ core.views.search_sample_data, name="search_sample_data", ), + path( + "searchSample/csv", + core.views.search_sample_csv, + name="search_sample_csv", + ), ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/core/views.py b/core/views.py index 93f5eb9e..622aac5f 100644 --- a/core/views.py +++ b/core/views.py @@ -1,10 +1,11 @@ # Generic imports from datetime import datetime +import csv import json from collections import defaultdict, OrderedDict import re from django.shortcuts import render, redirect -from django.http import JsonResponse +from django.http import HttpResponse, JsonResponse from django.contrib.auth.decorators import login_required from django.contrib.auth.models import Group from django.utils import timezone @@ -305,38 +306,8 @@ def search_sample(request): ) -@login_required -def search_sample_data(request): - """Return paginated sample browser data for DataTables server-side mode.""" - sample_rows = _get_search_sample_rows_for_user(request.user) - if isinstance(sample_rows, dict) and "ERROR" in sample_rows: - try: - draw = int(request.GET.get("draw", 0) or 0) - except (TypeError, ValueError): - draw = 0 - return JsonResponse( - { - "draw": draw, - "recordsTotal": 0, - "recordsFiltered": 0, - "data": [], - "error": sample_rows["ERROR"], - } - ) - - try: - draw = int(request.GET.get("draw", 0) or 0) - except (TypeError, ValueError): - draw = 0 - try: - start = max(int(request.GET.get("start", 0) or 0), 0) - except (TypeError, ValueError): - start = 0 - try: - length = int(request.GET.get("length", 25) or 25) - except (TypeError, ValueError): - length = 25 - +def _get_filtered_search_sample_data(request, sample_rows): + """Apply DataTables search and ordering parameters to sample browser rows.""" global_search = _normalize_datatable_search_value( request.GET.get("search[value]", ""), request.GET.get("search[regex]", "false").lower() == "true", @@ -401,6 +372,47 @@ def search_sample_data(request): key=lambda row: _get_sort_value(row, field_name), reverse=reverse_order ) + return filtered_rows, lineage_options, collecting_institution_options + + +@login_required +def search_sample_data(request): + """Return paginated sample browser data for DataTables server-side mode.""" + sample_rows = _get_search_sample_rows_for_user(request.user) + if isinstance(sample_rows, dict) and "ERROR" in sample_rows: + try: + draw = int(request.GET.get("draw", 0) or 0) + except (TypeError, ValueError): + draw = 0 + return JsonResponse( + { + "draw": draw, + "recordsTotal": 0, + "recordsFiltered": 0, + "data": [], + "error": sample_rows["ERROR"], + } + ) + + try: + draw = int(request.GET.get("draw", 0) or 0) + except (TypeError, ValueError): + draw = 0 + try: + start = max(int(request.GET.get("start", 0) or 0), 0) + except (TypeError, ValueError): + start = 0 + try: + length = int(request.GET.get("length", 25) or 25) + except (TypeError, ValueError): + length = 25 + + ( + filtered_rows, + lineage_options, + collecting_institution_options, + ) = _get_filtered_search_sample_data(request, sample_rows) + if length == -1: paginated_rows = filtered_rows[start:] else: @@ -418,6 +430,40 @@ def search_sample_data(request): ) +@login_required +def search_sample_csv(request): + """Download the currently filtered sample browser rows as CSV.""" + sample_rows = _get_search_sample_rows_for_user(request.user) + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="relecov_samples.csv"' + writer = csv.writer(response) + writer.writerow( + [ + "Sample Sequencing ID", + "Collection Date", + "Lineage", + "Collecting Institution", + ] + ) + + if isinstance(sample_rows, dict) and "ERROR" in sample_rows: + return response + + filtered_rows, _lineage_options, _institution_options = ( + _get_filtered_search_sample_data(request, sample_rows) + ) + for row in filtered_rows: + writer.writerow( + [ + row["sequencing_id"], + row["collection_date"], + row["lineage"], + row["collecting_institution"], + ] + ) + return response + + @login_required def metadata_visualization(request): if request.user.username != "admin": From 61d0bb12fcc5de76781d0b4bfabffc7b511e3564 Mon Sep 17 00:00:00 2001 From: svarona Date: Wed, 1 Jul 2026 12:41:32 +0200 Subject: [PATCH 10/21] Added button to download surveillance data --- core/templates/core/searchSample.html | 27 +++- core/urls.py | 5 + core/utils/samples.py | 61 ++++++-- core/views.py | 206 ++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 22 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index 16cfbcfa..ce0c28ed 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -169,14 +169,20 @@ width: 100%; max-width: 100%; } - .sample-browser-download .sample-browser-csv-button { + .sample-browser-download { + display: flex; + gap: 0.5rem; + justify-content: center; + flex-wrap: wrap; + } + .sample-browser-download .sample-browser-download-button { background-color: rgba(var(--color-green-rgb), 0.12); border-color: var(--color-green); color: var(--color-green); font-weight: 500; } - .sample-browser-download .sample-browser-csv-button:hover, - .sample-browser-download .sample-browser-csv-button:focus { + .sample-browser-download .sample-browser-download-button:hover, + .sample-browser-download .sample-browser-download-button:focus { background-color: var(--color-green); border-color: var(--color-green); color: var(--color-white); @@ -336,11 +342,20 @@

Sample Browser

initComplete: function () { var api = this.api(); var csvUrl = "{% url 'search_sample_csv' %}"; - $('') + var surveillanceUrl = "{% url 'search_sample_surveillance_data' %}"; + function downloadFilteredData(downloadUrl) { + var params = $.param(table.ajax.params()); + window.location = downloadUrl + (params ? '?' + params : ''); + } + $('') + .appendTo($('.sample-browser-download')) + .on('click', function () { + downloadFilteredData(csvUrl); + }); + $('') .appendTo($('.sample-browser-download')) .on('click', function () { - var params = $.param(table.ajax.params()); - window.location = csvUrl + (params ? '?' + params : ''); + downloadFilteredData(surveillanceUrl); }); [0].forEach(function(index) { diff --git a/core/urls.py b/core/urls.py index 7da10410..835ca520 100644 --- a/core/urls.py +++ b/core/urls.py @@ -61,6 +61,11 @@ core.views.search_sample_csv, name="search_sample_csv", ), + path( + "searchSample/surveillance-data", + core.views.search_sample_surveillance_data, + name="search_sample_surveillance_data", + ), ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/core/utils/samples.py b/core/utils/samples.py index 1037c31f..57a0fd11 100644 --- a/core/utils/samples.py +++ b/core/utils/samples.py @@ -486,6 +486,49 @@ def get_public_database_fields(schema_obj, db_type): return None +def get_iskylims_lookup_ids(sample_obj): + """Return iSkyLIMS lookup IDs for a sample, preferring unique sample id.""" + lookup_ids = [] + unique_id = (sample_obj.get_unique_id() or "").strip() + seq_id = (sample_obj.get_sequencing_sample_id() or "").strip() + if unique_id: + lookup_ids.append(unique_id) + if seq_id and seq_id not in lookup_ids: + lookup_ids.append(seq_id) + return lookup_ids + + +def get_iskylims_sample_information(sample_obj): + """Return the first iSkyLIMS sample payload available for this sample.""" + for sample_id in get_iskylims_lookup_ids(sample_obj): + iskylims_data = core.utils.rest_api.get_sample_information(sample_id) + if not iskylims_data or "ERROR" in iskylims_data: + continue + if not iskylims_data: + continue + return iskylims_data[0] + return None + + +def get_iskylims_project_values(sample_obj): + """Return iSkyLIMS project values keyed by display label.""" + iskylims_data = get_iskylims_sample_information(sample_obj) + if not iskylims_data: + return {} + project_field_display_map = core.utils.rest_api.get_sample_project_field_display_map( + iskylims_data.get("sample_project") + ) + project_values = {} + for key, value in iskylims_data.get("Project values", {}).items(): + project_values[project_field_display_map.get(key, key)] = value + return project_values + + +def get_iskylims_project_value(sample_obj, label_name): + """Return one iSkyLIMS project value by display label.""" + return get_iskylims_project_values(sample_obj).get(label_name, "") + + def get_sample_display_data(sample_id, user): """Check if user is allowed to see the data and if true collect all info from sample to display @@ -552,23 +595,10 @@ def get_sample_display_data(sample_id, user): # Lab metadata in iSkyLIMS is keyed by the platform-wide unique sample id # (the value shown as Sample Name in iSky). Keep a fallback to the historical # sequencing_sample_id so legacy records can still resolve. - lookup_ids = [] - unique_id = (sample_obj.get_unique_id() or "").strip() - seq_id = (sample_obj.get_sequencing_sample_id() or "").strip() - if unique_id: - lookup_ids.append(unique_id) - if seq_id and seq_id not in lookup_ids: - lookup_ids.append(seq_id) - - # Fetch information from iSkyLIMS - for sample_id in lookup_ids: - iskylims_data = core.utils.rest_api.get_sample_information(sample_id) - if not iskylims_data or "ERROR" in iskylims_data: - continue + iskylims_data = get_iskylims_sample_information(sample_obj) + if iskylims_data: s_data["iskylims_basic"] = [] s_data["iskylims_p_data"] = [] - # iskylims_data is a list with one element. Then get the first element - iskylims_data = iskylims_data[0] project_field_display_map = ( core.utils.rest_api.get_sample_project_field_display_map( iskylims_data.get("sample_project") @@ -586,7 +616,6 @@ def get_sample_display_data(sample_id, user): else: s_data["iskylims_basic"].append([key, i_data]) s_data["iskylims_project"] = iskylims_data.get("sample_project") - break return s_data diff --git a/core/views.py b/core/views.py index 622aac5f..513cf0e7 100644 --- a/core/views.py +++ b/core/views.py @@ -1,6 +1,7 @@ # Generic imports from datetime import datetime import csv +from io import BytesIO import json from collections import defaultdict, OrderedDict import re @@ -8,9 +9,11 @@ from django.http import HttpResponse, JsonResponse from django.contrib.auth.decorators import login_required from django.contrib.auth.models import Group +from django.db.models import Prefetch from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_POST +from openpyxl import Workbook # Local imports import core.models @@ -464,6 +467,209 @@ def search_sample_csv(request): return response +def _get_collection_iso_week(collection_date): + """Return ISO week in YYYY-WNN format from a collection date string.""" + if not collection_date: + return "" + try: + parsed_date = datetime.strptime(collection_date, "%Y-%m-%d") + except ValueError: + return "" + iso_year, iso_week, _ = parsed_date.isocalendar() + return f"{iso_year}-W{iso_week:02d}" + + +def _get_epi_season(collection_date): + """Return epidemiological season in YYYY_YYYY format.""" + if not collection_date: + return "" + try: + parsed_date = datetime.strptime(collection_date, "%Y-%m-%d") + except ValueError: + return "" + year, week, _weekday = parsed_date.isocalendar() + if week >= 40: + season_start = year + season_end = year + 1 + else: + season_start = year - 1 + season_end = year + return f"{season_start}_{season_end}" + + + +def _get_sample_lineage_value(sample, property_name): + """Return a lineage value for the requested property name.""" + if not sample: + return "" + lineage_values = getattr(sample, "surveillance_lineages", None) + if lineage_values is not None: + for lineage_value in lineage_values: + if lineage_value.lineage_fieldID.property_name == property_name: + return lineage_value.value or "" + return "" + lineage_value = sample.lineage_values.filter( + lineage_fieldID__property_name=property_name + ).last() + return lineage_value.value if lineage_value else "" + + +def _get_sample_bioinfo_value(sample, property_name): + """Return a bioinfo analysis value for the requested property name.""" + if not sample: + return "" + bioinfo_values = getattr(sample, "surveillance_bioinfo_values", None) + if bioinfo_values is not None: + for bioinfo_value in bioinfo_values: + if bioinfo_value.bioinfo_analysis_fieldID.property_name == property_name: + return bioinfo_value.value or "" + return "" + bioinfo_value = sample.bio_analysis_values.filter( + bioinfo_analysis_fieldID__property_name=property_name + ).last() + return bioinfo_value.value if bioinfo_value else "" + + +def _get_sample_public_database_value(sample, property_name): + """Return a public database value for the requested property name.""" + if not sample: + return "" + public_database_values = getattr(sample, "surveillance_public_database_values", None) + if public_database_values is not None: + for public_database_value in public_database_values: + if public_database_value.public_database_fieldID.property_name == property_name: + return public_database_value.value or "" + return "" + public_database_value = sample.publicdatabasevalues_set.filter( + public_database_fieldID__property_name=property_name + ).last() + return public_database_value.value if public_database_value else "" + + +@login_required +def search_sample_surveillance_data(request): + """Download surveillance data for the currently filtered sample browser rows.""" + sample_rows = _get_search_sample_rows_for_user(request.user) + filtered_rows = [] + if not (isinstance(sample_rows, dict) and "ERROR" in sample_rows): + filtered_rows, _lineage_options, _institution_options = ( + _get_filtered_search_sample_data(request, sample_rows) + ) + + sequencing_ids = [row["sequencing_id"] for row in filtered_rows] + lineage_prefetch = Prefetch( + "lineage_values", + queryset=core.models.LineageValues.objects.filter( + lineage_fieldID__property_name__in=[ + "lineage_assignment", + "lineage_assignment_software_version", + "lineage_assignment_database_version", + ] + ).select_related("lineage_fieldID"), + to_attr="surveillance_lineages", + ) + bioinfo_prefetch = Prefetch( + "bio_analysis_values", + queryset=core.models.BioinfoAnalysisValue.objects.filter( + bioinfo_analysis_fieldID__property_name__in=[ + "per_genome_greater_10x", + "bioinformatics_analysis_date", + "consensus_sequence_filename", + "qc_test", + ] + ).select_related("bioinfo_analysis_fieldID"), + to_attr="surveillance_bioinfo_values", + ) + public_database_prefetch = Prefetch( + "publicdatabasevalues_set", + queryset=core.models.PublicDatabaseValues.objects.filter( + public_database_fieldID__property_name="gisaid_accession_id" + ).select_related("public_database_fieldID"), + to_attr="surveillance_public_database_values", + ) + sample_lookup = { + sample.sequencing_sample_id: sample + for sample in core.models.Sample.objects.filter( + sequencing_sample_id__in=sequencing_ids + ).prefetch_related( + lineage_prefetch, bioinfo_prefetch, public_database_prefetch + ) + } + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "per_sample_data" + worksheet.append( + [ + "COLLECTING_LAB_SAMPLE_ID", + "SEQUENCING_SAMPLE_ID", + "MICROBIOLOGY_LAB_SAMPLE_ID", + "UNIQUE_SAMPLE_ID", + "GISAID_ACCESSION_ID", + "COLLECTING_INSTITUTION", + "SUBMITTING_INSTITUTION", + "SUBMITTING_INSTITUTION_ID", + "CCAA", + "PROVINCE", + "SAMPLE_COLLECTION_DATE", + "WEEK", + "SEASON", + "LINEAGE", + "PANGOLIN_SOFTWARE_VERSION", + "PANGOLIN_DATABASE_VERSION", + "ANALYSIS_DATE", + "COVERAGE_10X", + "QC_TEST", + "CONSENSUS_SEQUENCE_FILENAME", + ] + ) + for row in filtered_rows: + sample = sample_lookup.get(row["sequencing_id"]) + iskylims_project_values = core.utils.samples.get_iskylims_project_values( + sample + ) + worksheet.append( + [ + sample.collecting_lab_sample_id if sample else "", + sample.sequencing_sample_id if sample else row["sequencing_id"], + sample.microbiology_lab_sample_id if sample else "", + sample.sample_unique_id if sample else "", + _get_sample_public_database_value(sample, "gisaid_accession_id"), + sample.collecting_institution if sample else "", + sample.submitting_institution if sample else "", + iskylims_project_values.get("Submitting Institution Identifier", ""), + iskylims_project_values.get("Autonomic Community", ""), + iskylims_project_values.get("Province", ""), + row["collection_date"], + _get_collection_iso_week(row["collection_date"]), + _get_epi_season(row["collection_date"]), + _get_sample_lineage_value(sample, "lineage_assignment"), + _get_sample_lineage_value( + sample, "lineage_assignment_software_version" + ), + _get_sample_lineage_value( + sample, "lineage_assignment_database_version" + ), + _get_sample_bioinfo_value(sample, "bioinformatics_analysis_date"), + _get_sample_bioinfo_value(sample, "per_genome_greater_10x"), + _get_sample_bioinfo_value(sample, "qc_test"), + _get_sample_bioinfo_value(sample, "consensus_sequence_filename"), + ] + ) + + output = BytesIO() + workbook.save(output) + output.seek(0) + response = HttpResponse( + output.getvalue(), + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + response["Content-Disposition"] = 'attachment; filename="surveillance_data.xlsx"' + return response + + @login_required def metadata_visualization(request): if request.user.username != "admin": From 7bb9bbcadee83d3f18343acef2fb67d751b1b65e Mon Sep 17 00:00:00 2001 From: svarona Date: Wed, 1 Jul 2026 12:56:39 +0200 Subject: [PATCH 11/21] Added a time check to avoid long times of download --- core/templates/core/searchSample.html | 92 +++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index ce0c28ed..bc5d0f2e 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -173,13 +173,14 @@ display: flex; gap: 0.5rem; justify-content: center; - flex-wrap: wrap; + flex-wrap: nowrap; } .sample-browser-download .sample-browser-download-button { background-color: rgba(var(--color-green-rgb), 0.12); border-color: var(--color-green); color: var(--color-green); font-weight: 500; + white-space: nowrap; } .sample-browser-download .sample-browser-download-button:hover, .sample-browser-download .sample-browser-download-button:focus { @@ -294,7 +295,7 @@

Sample Browser

lengthMenu: [ [25, 50, 100, 200, -1], [25, 50, 100, 200, "All"] ], orderCellsTop: true, dom: - "<'row align-items-center'<'col-md-3'l><'col-md-4 sample-browser-download'><'col-md-5'f>>" + + "<'row align-items-center'<'col-md-3'l><'col-md-5 sample-browser-download'><'col-md-4'f>>" + "<'row'<'col-sm-12'tr>>" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", responsive: true, @@ -343,19 +344,98 @@

Sample Browser

var api = this.api(); var csvUrl = "{% url 'search_sample_csv' %}"; var surveillanceUrl = "{% url 'search_sample_surveillance_data' %}"; - function downloadFilteredData(downloadUrl) { + var surveillanceSecondsPerSample = 0.12; + var downloadWarningThresholdSeconds = 10; + function getFilteredSampleCount() { + return table.page.info().recordsDisplay || 0; + } + function formatSampleCount(count) { + return count.toLocaleString('en-US'); + } + function getEstimatedDownloadSeconds(count) { + return Math.ceil(count * surveillanceSecondsPerSample); + } + function formatEstimatedDuration(totalSeconds) { + if (totalSeconds < 60) { + return totalSeconds + ' seconds'; + } + var minutes = Math.floor(totalSeconds / 60); + var seconds = totalSeconds % 60; + if (seconds === 0) { + return minutes + ' min'; + } + return minutes + ' min ' + seconds + ' sec'; + } + function confirmLongDownload(count) { + var estimatedSeconds = getEstimatedDownloadSeconds(count); + if (estimatedSeconds <= downloadWarningThresholdSeconds) { + return true; + } + return window.confirm( + 'You are about to generate a file for ' + + formatSampleCount(count) + + ' filtered samples. Estimated generation time: ' + + formatEstimatedDuration(estimatedSeconds) + + '. Consider applying more filters before continuing. Continue?' + ); + } + function getDownloadFilename(response, fallbackFilename) { + var contentDisposition = response.headers.get('Content-Disposition') || ''; + var filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + return filenameMatch ? filenameMatch[1] : fallbackFilename; + } + function setDownloadButtonsLoading(button, loadingText) { + var originalText = button.text(); + $('.sample-browser-download-button').prop('disabled', true); + button.text(loadingText); + return function () { + $('.sample-browser-download-button').prop('disabled', false); + button.text(originalText); + }; + } + function startBlobDownload(blob, filename) { + var objectUrl = window.URL.createObjectURL(blob); + var downloadLink = document.createElement('a'); + downloadLink.href = objectUrl; + downloadLink.download = filename; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + window.URL.revokeObjectURL(objectUrl); + } + function downloadFilteredData(downloadUrl, button, loadingText, fallbackFilename) { + var filteredSampleCount = getFilteredSampleCount(); + if (!confirmLongDownload(filteredSampleCount)) { + return; + } + var restoreButton = setDownloadButtonsLoading(button, loadingText); var params = $.param(table.ajax.params()); - window.location = downloadUrl + (params ? '?' + params : ''); + fetch(downloadUrl + (params ? '?' + params : ''), { + credentials: 'same-origin' + }) + .then(function (response) { + if (!response.ok) { + throw new Error('Download request failed.'); + } + var filename = getDownloadFilename(response, fallbackFilename); + return response.blob().then(function (blob) { + startBlobDownload(blob, filename); + }); + }) + .catch(function () { + window.alert('The file could not be generated. Please try again or apply more filters.'); + }) + .finally(restoreButton); } $('') .appendTo($('.sample-browser-download')) .on('click', function () { - downloadFilteredData(csvUrl); + downloadFilteredData(csvUrl, $(this), 'Generating table...', 'relecov_samples.csv'); }); $('') .appendTo($('.sample-browser-download')) .on('click', function () { - downloadFilteredData(surveillanceUrl); + downloadFilteredData(surveillanceUrl, $(this), 'Generating surveillance data...', 'surveillance_data.xlsx'); }); [0].forEach(function(index) { From 0d1489a551ff9e613b8139c1b0e3fc1d2dbaffb9 Mon Sep 17 00:00:00 2001 From: svarona Date: Wed, 1 Jul 2026 12:56:56 +0200 Subject: [PATCH 12/21] re-arranged code --- core/utils/samples.py | 203 ++++++++++++++++++++++++++++++++++++++++++ core/views.py | 183 +------------------------------------ 2 files changed, 204 insertions(+), 182 deletions(-) diff --git a/core/utils/samples.py b/core/utils/samples.py index 57a0fd11..87b9eaeb 100644 --- a/core/utils/samples.py +++ b/core/utils/samples.py @@ -3,13 +3,16 @@ import os import shutil import hashlib +from datetime import datetime from collections import OrderedDict, defaultdict import pandas as pd +from openpyxl import Workbook from django.contrib.auth.models import Group, User from django.core.files.storage import FileSystemStorage from django.conf import settings from django.db.models import Q from django.db.models import Count +from django.db.models import Prefetch from django.db.models.functions import TruncDate import relecov_tools.utils from django.template.loader import render_to_string @@ -529,6 +532,206 @@ def get_iskylims_project_value(sample_obj, label_name): return get_iskylims_project_values(sample_obj).get(label_name, "") +SURVEILLANCE_LINEAGE_FIELDS = [ + "lineage_assignment", + "lineage_assignment_software_version", + "lineage_assignment_database_version", +] +SURVEILLANCE_BIOINFO_FIELDS = [ + "per_genome_greater_10x", + "bioinformatics_analysis_date", + "consensus_sequence_filename", + "qc_test", +] +SURVEILLANCE_PUBLIC_DATABASE_FIELDS = ["gisaid_accession_id"] +SURVEILLANCE_COLUMNS = [ + "COLLECTING_LAB_SAMPLE_ID", + "SEQUENCING_SAMPLE_ID", + "MICROBIOLOGY_LAB_SAMPLE_ID", + "UNIQUE_SAMPLE_ID", + "GISAID_ACCESSION_ID", + "COLLECTING_INSTITUTION", + "SUBMITTING_INSTITUTION", + "SUBMITTING_INSTITUTION_ID", + "CCAA", + "PROVINCE", + "SAMPLE_COLLECTION_DATE", + "WEEK", + "SEASON", + "LINEAGE", + "PANGOLIN_SOFTWARE_VERSION", + "PANGOLIN_DATABASE_VERSION", + "ANALYSIS_DATE", + "COVERAGE_10X", + "QC_TEST", + "CONSENSUS_SEQUENCE_FILENAME", +] + + +def get_collection_iso_week(collection_date): + """Return ISO week in YYYY-WNN format from a collection date string.""" + if not collection_date: + return "" + try: + parsed_date = datetime.strptime(collection_date, "%Y-%m-%d") + except ValueError: + return "" + iso_year, iso_week, _ = parsed_date.isocalendar() + return f"{iso_year}-W{iso_week:02d}" + + +def get_epi_season(collection_date): + """Return epidemiological season in YYYY_YYYY format.""" + if not collection_date: + return "" + try: + parsed_date = datetime.strptime(collection_date, "%Y-%m-%d") + except ValueError: + return "" + year, week, _weekday = parsed_date.isocalendar() + if week >= 40: + season_start = year + season_end = year + 1 + else: + season_start = year - 1 + season_end = year + return f"{season_start}_{season_end}" + + +def get_sample_lineage_value(sample_obj, property_name): + """Return a lineage value for the requested property name.""" + if not sample_obj: + return "" + lineage_values = getattr(sample_obj, "surveillance_lineages", None) + if lineage_values is not None: + for lineage_value in lineage_values: + if lineage_value.lineage_fieldID.property_name == property_name: + return lineage_value.value or "" + return "" + lineage_value = sample_obj.lineage_values.filter( + lineage_fieldID__property_name=property_name + ).last() + return lineage_value.value if lineage_value else "" + + +def get_sample_bioinfo_value(sample_obj, property_name): + """Return a bioinfo analysis value for the requested property name.""" + if not sample_obj: + return "" + bioinfo_values = getattr(sample_obj, "surveillance_bioinfo_values", None) + if bioinfo_values is not None: + for bioinfo_value in bioinfo_values: + if bioinfo_value.bioinfo_analysis_fieldID.property_name == property_name: + return bioinfo_value.value or "" + return "" + bioinfo_value = sample_obj.bio_analysis_values.filter( + bioinfo_analysis_fieldID__property_name=property_name + ).last() + return bioinfo_value.value if bioinfo_value else "" + + +def get_sample_public_database_value(sample_obj, property_name): + """Return a public database value for the requested property name.""" + if not sample_obj: + return "" + public_database_values = getattr( + sample_obj, "surveillance_public_database_values", None + ) + if public_database_values is not None: + for public_database_value in public_database_values: + if ( + public_database_value.public_database_fieldID.property_name + == property_name + ): + return public_database_value.value or "" + return "" + public_database_value = sample_obj.publicdatabasevalues_set.filter( + public_database_fieldID__property_name=property_name + ).last() + return public_database_value.value if public_database_value else "" + + +def get_surveillance_sample_lookup(sequencing_ids): + """Return samples keyed by sequencing sample ID with export values prefetched.""" + lineage_prefetch = Prefetch( + "lineage_values", + queryset=core.models.LineageValues.objects.filter( + lineage_fieldID__property_name__in=SURVEILLANCE_LINEAGE_FIELDS + ).select_related("lineage_fieldID"), + to_attr="surveillance_lineages", + ) + bioinfo_prefetch = Prefetch( + "bio_analysis_values", + queryset=core.models.BioinfoAnalysisValue.objects.filter( + bioinfo_analysis_fieldID__property_name__in=SURVEILLANCE_BIOINFO_FIELDS + ).select_related("bioinfo_analysis_fieldID"), + to_attr="surveillance_bioinfo_values", + ) + public_database_prefetch = Prefetch( + "publicdatabasevalues_set", + queryset=core.models.PublicDatabaseValues.objects.filter( + public_database_fieldID__property_name__in=( + SURVEILLANCE_PUBLIC_DATABASE_FIELDS + ) + ).select_related("public_database_fieldID"), + to_attr="surveillance_public_database_values", + ) + return { + sample.sequencing_sample_id: sample + for sample in core.models.Sample.objects.filter( + sequencing_sample_id__in=sequencing_ids + ).prefetch_related( + lineage_prefetch, bioinfo_prefetch, public_database_prefetch + ) + } + + +def get_surveillance_sample_row(row, sample_obj): + """Return one per-sample surveillance export row.""" + iskylims_project_values = get_iskylims_project_values(sample_obj) + collection_date = row["collection_date"] + return [ + sample_obj.collecting_lab_sample_id if sample_obj else "", + sample_obj.sequencing_sample_id if sample_obj else row["sequencing_id"], + sample_obj.microbiology_lab_sample_id if sample_obj else "", + sample_obj.sample_unique_id if sample_obj else "", + get_sample_public_database_value(sample_obj, "gisaid_accession_id"), + sample_obj.collecting_institution if sample_obj else "", + sample_obj.submitting_institution if sample_obj else "", + iskylims_project_values.get("Submitting Institution Identifier", ""), + iskylims_project_values.get("Autonomic Community", ""), + iskylims_project_values.get("Province", ""), + collection_date, + get_collection_iso_week(collection_date), + get_epi_season(collection_date), + get_sample_lineage_value(sample_obj, "lineage_assignment"), + get_sample_lineage_value( + sample_obj, "lineage_assignment_software_version" + ), + get_sample_lineage_value( + sample_obj, "lineage_assignment_database_version" + ), + get_sample_bioinfo_value(sample_obj, "bioinformatics_analysis_date"), + get_sample_bioinfo_value(sample_obj, "per_genome_greater_10x"), + get_sample_bioinfo_value(sample_obj, "qc_test"), + get_sample_bioinfo_value(sample_obj, "consensus_sequence_filename"), + ] + + +def build_surveillance_workbook(filtered_rows): + """Build the surveillance data workbook for filtered sample browser rows.""" + sequencing_ids = [row["sequencing_id"] for row in filtered_rows] + sample_lookup = get_surveillance_sample_lookup(sequencing_ids) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "per_sample_data" + worksheet.append(SURVEILLANCE_COLUMNS) + for row in filtered_rows: + sample_obj = sample_lookup.get(row["sequencing_id"]) + worksheet.append(get_surveillance_sample_row(row, sample_obj)) + return workbook + + def get_sample_display_data(sample_id, user): """Check if user is allowed to see the data and if true collect all info from sample to display diff --git a/core/views.py b/core/views.py index 513cf0e7..a6564694 100644 --- a/core/views.py +++ b/core/views.py @@ -9,11 +9,9 @@ from django.http import HttpResponse, JsonResponse from django.contrib.auth.decorators import login_required from django.contrib.auth.models import Group -from django.db.models import Prefetch from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_POST -from openpyxl import Workbook # Local imports import core.models @@ -467,85 +465,6 @@ def search_sample_csv(request): return response -def _get_collection_iso_week(collection_date): - """Return ISO week in YYYY-WNN format from a collection date string.""" - if not collection_date: - return "" - try: - parsed_date = datetime.strptime(collection_date, "%Y-%m-%d") - except ValueError: - return "" - iso_year, iso_week, _ = parsed_date.isocalendar() - return f"{iso_year}-W{iso_week:02d}" - - -def _get_epi_season(collection_date): - """Return epidemiological season in YYYY_YYYY format.""" - if not collection_date: - return "" - try: - parsed_date = datetime.strptime(collection_date, "%Y-%m-%d") - except ValueError: - return "" - year, week, _weekday = parsed_date.isocalendar() - if week >= 40: - season_start = year - season_end = year + 1 - else: - season_start = year - 1 - season_end = year - return f"{season_start}_{season_end}" - - - -def _get_sample_lineage_value(sample, property_name): - """Return a lineage value for the requested property name.""" - if not sample: - return "" - lineage_values = getattr(sample, "surveillance_lineages", None) - if lineage_values is not None: - for lineage_value in lineage_values: - if lineage_value.lineage_fieldID.property_name == property_name: - return lineage_value.value or "" - return "" - lineage_value = sample.lineage_values.filter( - lineage_fieldID__property_name=property_name - ).last() - return lineage_value.value if lineage_value else "" - - -def _get_sample_bioinfo_value(sample, property_name): - """Return a bioinfo analysis value for the requested property name.""" - if not sample: - return "" - bioinfo_values = getattr(sample, "surveillance_bioinfo_values", None) - if bioinfo_values is not None: - for bioinfo_value in bioinfo_values: - if bioinfo_value.bioinfo_analysis_fieldID.property_name == property_name: - return bioinfo_value.value or "" - return "" - bioinfo_value = sample.bio_analysis_values.filter( - bioinfo_analysis_fieldID__property_name=property_name - ).last() - return bioinfo_value.value if bioinfo_value else "" - - -def _get_sample_public_database_value(sample, property_name): - """Return a public database value for the requested property name.""" - if not sample: - return "" - public_database_values = getattr(sample, "surveillance_public_database_values", None) - if public_database_values is not None: - for public_database_value in public_database_values: - if public_database_value.public_database_fieldID.property_name == property_name: - return public_database_value.value or "" - return "" - public_database_value = sample.publicdatabasevalues_set.filter( - public_database_fieldID__property_name=property_name - ).last() - return public_database_value.value if public_database_value else "" - - @login_required def search_sample_surveillance_data(request): """Download surveillance data for the currently filtered sample browser rows.""" @@ -556,107 +475,7 @@ def search_sample_surveillance_data(request): _get_filtered_search_sample_data(request, sample_rows) ) - sequencing_ids = [row["sequencing_id"] for row in filtered_rows] - lineage_prefetch = Prefetch( - "lineage_values", - queryset=core.models.LineageValues.objects.filter( - lineage_fieldID__property_name__in=[ - "lineage_assignment", - "lineage_assignment_software_version", - "lineage_assignment_database_version", - ] - ).select_related("lineage_fieldID"), - to_attr="surveillance_lineages", - ) - bioinfo_prefetch = Prefetch( - "bio_analysis_values", - queryset=core.models.BioinfoAnalysisValue.objects.filter( - bioinfo_analysis_fieldID__property_name__in=[ - "per_genome_greater_10x", - "bioinformatics_analysis_date", - "consensus_sequence_filename", - "qc_test", - ] - ).select_related("bioinfo_analysis_fieldID"), - to_attr="surveillance_bioinfo_values", - ) - public_database_prefetch = Prefetch( - "publicdatabasevalues_set", - queryset=core.models.PublicDatabaseValues.objects.filter( - public_database_fieldID__property_name="gisaid_accession_id" - ).select_related("public_database_fieldID"), - to_attr="surveillance_public_database_values", - ) - sample_lookup = { - sample.sequencing_sample_id: sample - for sample in core.models.Sample.objects.filter( - sequencing_sample_id__in=sequencing_ids - ).prefetch_related( - lineage_prefetch, bioinfo_prefetch, public_database_prefetch - ) - } - - workbook = Workbook() - worksheet = workbook.active - worksheet.title = "per_sample_data" - worksheet.append( - [ - "COLLECTING_LAB_SAMPLE_ID", - "SEQUENCING_SAMPLE_ID", - "MICROBIOLOGY_LAB_SAMPLE_ID", - "UNIQUE_SAMPLE_ID", - "GISAID_ACCESSION_ID", - "COLLECTING_INSTITUTION", - "SUBMITTING_INSTITUTION", - "SUBMITTING_INSTITUTION_ID", - "CCAA", - "PROVINCE", - "SAMPLE_COLLECTION_DATE", - "WEEK", - "SEASON", - "LINEAGE", - "PANGOLIN_SOFTWARE_VERSION", - "PANGOLIN_DATABASE_VERSION", - "ANALYSIS_DATE", - "COVERAGE_10X", - "QC_TEST", - "CONSENSUS_SEQUENCE_FILENAME", - ] - ) - for row in filtered_rows: - sample = sample_lookup.get(row["sequencing_id"]) - iskylims_project_values = core.utils.samples.get_iskylims_project_values( - sample - ) - worksheet.append( - [ - sample.collecting_lab_sample_id if sample else "", - sample.sequencing_sample_id if sample else row["sequencing_id"], - sample.microbiology_lab_sample_id if sample else "", - sample.sample_unique_id if sample else "", - _get_sample_public_database_value(sample, "gisaid_accession_id"), - sample.collecting_institution if sample else "", - sample.submitting_institution if sample else "", - iskylims_project_values.get("Submitting Institution Identifier", ""), - iskylims_project_values.get("Autonomic Community", ""), - iskylims_project_values.get("Province", ""), - row["collection_date"], - _get_collection_iso_week(row["collection_date"]), - _get_epi_season(row["collection_date"]), - _get_sample_lineage_value(sample, "lineage_assignment"), - _get_sample_lineage_value( - sample, "lineage_assignment_software_version" - ), - _get_sample_lineage_value( - sample, "lineage_assignment_database_version" - ), - _get_sample_bioinfo_value(sample, "bioinformatics_analysis_date"), - _get_sample_bioinfo_value(sample, "per_genome_greater_10x"), - _get_sample_bioinfo_value(sample, "qc_test"), - _get_sample_bioinfo_value(sample, "consensus_sequence_filename"), - ] - ) - + workbook = core.utils.samples.build_surveillance_workbook(filtered_rows) output = BytesIO() workbook.save(output) output.seek(0) From 04091c382058d797270a955df6234297d36b8298 Mon Sep 17 00:00:00 2001 From: svarona Date: Thu, 2 Jul 2026 10:56:54 +0200 Subject: [PATCH 13/21] Added button to download variant data --- core/templates/core/searchSample.html | 57 +++++++++--- core/urls.py | 5 + core/utils/variants.py | 129 ++++++++++++++++++++++++++ core/views.py | 24 +++++ 4 files changed, 202 insertions(+), 13 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index bc5d0f2e..4e8d71e1 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -173,7 +173,11 @@ display: flex; gap: 0.5rem; justify-content: center; - flex-wrap: nowrap; + flex-wrap: wrap; + } + .sample-browser-download-break { + flex-basis: 100%; + height: 0; } .sample-browser-download .sample-browser-download-button { background-color: rgba(var(--color-green-rgb), 0.12); @@ -188,6 +192,11 @@ border-color: var(--color-green); color: var(--color-white); } + #sample_card .card-body p.sample-browser-download-note { + font-size: 1.0rem !important; + line-height: 1.2; + margin-top: -0.5rem; + } {% endblock %} @@ -245,7 +254,8 @@

Sample Browser

-

The following table shows the available samples to search

+

The following table shows the available samples to search

+

* All download buttons will download data only for the samples displayed after applying filters.

@@ -295,7 +305,7 @@

Sample Browser

lengthMenu: [ [25, 50, 100, 200, -1], [25, 50, 100, 200, "All"] ], orderCellsTop: true, dom: - "<'row align-items-center'<'col-md-3'l><'col-md-5 sample-browser-download'><'col-md-4'f>>" + + "<'row align-items-center'<'col-md-2'l><'col-md-7 sample-browser-download'><'col-md-3'f>>" + "<'row'<'col-sm-12'tr>>" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", responsive: true, @@ -344,16 +354,27 @@

Sample Browser

var api = this.api(); var csvUrl = "{% url 'search_sample_csv' %}"; var surveillanceUrl = "{% url 'search_sample_surveillance_data' %}"; - var surveillanceSecondsPerSample = 0.12; + var variantsLongTableUrl = "{% url 'search_sample_variants_long_table' %}"; var downloadWarningThresholdSeconds = 10; + var surveillanceDownloadEstimate = { + baseSeconds: 0, + secondsPerSample: 0.12 + }; + var variantsDownloadEstimate = { + baseSeconds: 7, + secondsPerSample: 0.0125 + }; function getFilteredSampleCount() { return table.page.info().recordsDisplay || 0; } function formatSampleCount(count) { return count.toLocaleString('en-US'); } - function getEstimatedDownloadSeconds(count) { - return Math.ceil(count * surveillanceSecondsPerSample); + function getEstimatedDownloadSeconds(count, estimate) { + return Math.ceil( + estimate.baseSeconds + + count * estimate.secondsPerSample + ); } function formatEstimatedDuration(totalSeconds) { if (totalSeconds < 60) { @@ -366,8 +387,11 @@

Sample Browser

} return minutes + ' min ' + seconds + ' sec'; } - function confirmLongDownload(count) { - var estimatedSeconds = getEstimatedDownloadSeconds(count); + function confirmLongDownload(count, estimate) { + if (!estimate) { + return true; + } + var estimatedSeconds = getEstimatedDownloadSeconds(count, estimate); if (estimatedSeconds <= downloadWarningThresholdSeconds) { return true; } @@ -403,9 +427,9 @@

Sample Browser

document.body.removeChild(downloadLink); window.URL.revokeObjectURL(objectUrl); } - function downloadFilteredData(downloadUrl, button, loadingText, fallbackFilename) { + function downloadFilteredData(downloadUrl, button, loadingText, fallbackFilename, estimate) { var filteredSampleCount = getFilteredSampleCount(); - if (!confirmLongDownload(filteredSampleCount)) { + if (!confirmLongDownload(filteredSampleCount, estimate)) { return; } var restoreButton = setDownloadButtonsLoading(button, loadingText); @@ -427,15 +451,22 @@

Sample Browser

}) .finally(restoreButton); } - $('') + $('') .appendTo($('.sample-browser-download')) .on('click', function () { downloadFilteredData(csvUrl, $(this), 'Generating table...', 'relecov_samples.csv'); }); - $('') + $('') + .appendTo($('.sample-browser-download')) + .on('click', function () { + downloadFilteredData(surveillanceUrl, $(this), 'Generating surveillance data...', 'surveillance_data.xlsx', surveillanceDownloadEstimate); + }); + $('') + .appendTo($('.sample-browser-download')); + $('') .appendTo($('.sample-browser-download')) .on('click', function () { - downloadFilteredData(surveillanceUrl, $(this), 'Generating surveillance data...', 'surveillance_data.xlsx'); + downloadFilteredData(variantsLongTableUrl, $(this), 'Generating variants long table...', 'variants_long_table.xlsx', variantsDownloadEstimate); }); [0].forEach(function(index) { diff --git a/core/urls.py b/core/urls.py index 835ca520..f09ff828 100644 --- a/core/urls.py +++ b/core/urls.py @@ -66,6 +66,11 @@ core.views.search_sample_surveillance_data, name="search_sample_surveillance_data", ), + path( + "searchSample/variants-long-table", + core.views.search_sample_variants_long_table, + name="search_sample_variants_long_table", + ), ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/core/utils/variants.py b/core/utils/variants.py index 25bb0718..de1be9ee 100644 --- a/core/utils/variants.py +++ b/core/utils/variants.py @@ -1,5 +1,7 @@ # Generic imports +from collections import defaultdict from django.db.models import F +from openpyxl import Workbook # Local imports import core.models @@ -97,6 +99,133 @@ def get_variant_data_from_sample(sample_id): return data +VARIANTS_LONG_TABLE_COLUMNS = [ + "sample", + "chrom", + "pos", + "ref", + "alt", + "dp", + "ref_dp", + "alt_dp", + "af", + "Gene ID", + "hgvs_c", + "hgvs_p", + "hgvs_p_1_letter", +] + + +def merge_variant_annotation_values(annotation_values): + """Merge annotation values following the SampleDisplay table style.""" + if not annotation_values: + return ["-", "-", "-", "-"] + merged_values = [] + for idx in range(len(annotation_values[0])): + column_values = [values[idx] for values in annotation_values] + unique_values = [] + for value in column_values: + if value not in unique_values: + unique_values.append(value) + merged_values.append(" - ".join(unique_values)) + return merged_values + + +def get_variant_annotation_row(variant_obj, annotation_map=None): + """Return one merged annotation row fragment for one variant.""" + if annotation_map is None: + variant_annotations = core.models.VariantAnnotation.objects.filter( + variantID_id=variant_obj + ).select_related("geneID_id") + annotations = list(variant_annotations) + else: + annotations = annotation_map.get(variant_obj.pk, []) + annotation_values = [ + [ + variant_annotation.get_geneID_id(), + variant_annotation.hgvs_c, + variant_annotation.hgvs_p, + variant_annotation.hgvs_p_1_letter, + ] + for variant_annotation in annotations + ] + return merge_variant_annotation_values(annotation_values) + + +def get_variant_annotation_map(variant_ids): + """Return variant annotations grouped by variant ID.""" + annotation_map = defaultdict(list) + variant_annotations = core.models.VariantAnnotation.objects.filter( + variantID_id__in=variant_ids + ).select_related("geneID_id") + for variant_annotation in variant_annotations: + annotation_map[variant_annotation.variantID_id_id].append(variant_annotation) + return annotation_map + + +def get_variants_long_table_rows(filtered_rows): + """Return one row per sample variant annotation for filtered sample rows.""" + sequencing_ids = [row["sequencing_id"] for row in filtered_rows] + variant_in_samples = list( + core.models.VariantInSample.objects.filter( + sampleID_id__sequencing_sample_id__in=sequencing_ids + ) + .select_related( + "sampleID_id", + "variantID_id", + "variantID_id__chromosomeID_id", + ) + .order_by("sampleID_id__sequencing_sample_id", "variantID_id__pos") + ) + variant_ids = [ + variant_in_sample.variantID_id_id + for variant_in_sample in variant_in_samples + if variant_in_sample.variantID_id_id + ] + annotation_map = get_variant_annotation_map(variant_ids) + rows = [] + seen_sample_variants = set() + for variant_in_sample in variant_in_samples: + variant_obj = variant_in_sample.get_variantID_obj() + if variant_obj is None: + continue + sample_variant_key = ( + variant_in_sample.sampleID_id_id, + variant_in_sample.variantID_id_id, + ) + if sample_variant_key in seen_sample_variants: + continue + seen_sample_variants.add(sample_variant_key) + chromosome_obj = variant_obj.chromosomeID_id + variant_prefix = [ + variant_in_sample.sampleID_id.get_sequencing_sample_id(), + chromosome_obj.get_chromosome_name() if chromosome_obj else "", + variant_obj.get_pos(), + variant_obj.get_ref(), + variant_obj.get_alt(), + variant_in_sample.get_dp(), + variant_in_sample.get_ref_dp(), + variant_in_sample.get_alt_dp(), + variant_in_sample.get_af(), + ] + annotation_row = get_variant_annotation_row( + variant_obj, annotation_map=annotation_map + ) + rows.append(variant_prefix + annotation_row) + return rows + + +def build_variants_long_table_workbook(filtered_rows): + """Build the variants long table workbook for filtered sample browser rows.""" + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "variants_long_table" + worksheet.append(VARIANTS_LONG_TABLE_COLUMNS) + for row in get_variants_long_table_rows(filtered_rows): + worksheet.append(row) + return workbook + + def get_variant_graphic_from_sample(sample_id): """Collect the variant information to send to create the plotly graphic""" v_data = {"x": [], "y": [], "v_id": []} diff --git a/core/views.py b/core/views.py index a6564694..7d098700 100644 --- a/core/views.py +++ b/core/views.py @@ -489,6 +489,30 @@ def search_sample_surveillance_data(request): return response +@login_required +def search_sample_variants_long_table(request): + """Download variants long table data for current sample browser filters.""" + sample_rows = _get_search_sample_rows_for_user(request.user) + filtered_rows = [] + if not (isinstance(sample_rows, dict) and "ERROR" in sample_rows): + filtered_rows, _lineage_options, _institution_options = ( + _get_filtered_search_sample_data(request, sample_rows) + ) + + workbook = core.utils.variants.build_variants_long_table_workbook(filtered_rows) + output = BytesIO() + workbook.save(output) + output.seek(0) + response = HttpResponse( + output.getvalue(), + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + response["Content-Disposition"] = 'attachment; filename="variants_long_table.xlsx"' + return response + + @login_required def metadata_visualization(request): if request.user.username != "admin": From 811f48a32dc1ece371994d50c4c12b5e4d2aa98e Mon Sep 17 00:00:00 2001 From: svarona Date: Thu, 2 Jul 2026 11:30:39 +0200 Subject: [PATCH 14/21] Fixed buttons and table for smaller screens --- core/templates/core/searchSample.html | 33 ++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/core/templates/core/searchSample.html b/core/templates/core/searchSample.html index 4e8d71e1..9386a9dc 100644 --- a/core/templates/core/searchSample.html +++ b/core/templates/core/searchSample.html @@ -4,8 +4,13 @@ {% block extra_css %}