From df1b6ecedab60acd795c8228fa36a40ee38e1852 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Wed, 12 Mar 2025 19:20:03 +0200 Subject: [PATCH 01/12] Added grey ovals to display geo granularity and temporal granularity. Expanded text about number of shown indicator sets & indicators --- src/fixtures/severity_pyramid_rungs.json | 4 +- src/signal_sets/models.py | 2 +- src/signal_sets/views.py | 6 +- src/signals/admin.py | 1 + src/staticfiles/admin/css/autocomplete.css | 4 - src/staticfiles/admin/css/base.css | 47 +++------- src/staticfiles/admin/css/changelists.css | 7 +- src/staticfiles/admin/css/dark_mode.css | 10 +-- src/staticfiles/admin/css/forms.css | 88 ++++++++++++------- src/staticfiles/admin/css/login.css | 2 +- src/staticfiles/admin/css/responsive.css | 19 ++-- src/staticfiles/admin/css/responsive_rtl.css | 37 ++------ src/staticfiles/admin/css/rtl.css | 29 ++++-- src/staticfiles/admin/css/widgets.css | 12 ++- src/staticfiles/admin/img/README.txt | 2 +- src/staticfiles/admin/img/calendar-icons.svg | 69 +++------------ src/staticfiles/admin/img/icon-addlink.svg | 2 +- src/staticfiles/admin/img/icon-changelink.svg | 2 +- src/staticfiles/admin/js/SelectFilter2.js | 2 +- src/staticfiles/admin/js/actions.js | 2 +- .../admin/js/admin/RelatedObjectLookups.js | 4 +- src/staticfiles/admin/js/popup_response.js | 1 + src/staticfiles/admin/js/theme.js | 83 +++++++++-------- src/templates/signal_sets/signal_sets.html | 13 ++- 24 files changed, 197 insertions(+), 251 deletions(-) diff --git a/src/fixtures/severity_pyramid_rungs.json b/src/fixtures/severity_pyramid_rungs.json index 163b91d..36568e9 100644 --- a/src/fixtures/severity_pyramid_rungs.json +++ b/src/fixtures/severity_pyramid_rungs.json @@ -126,8 +126,8 @@ "fields": { "created": "2024-08-15T09:57:49.327Z", "modified": "2024-08-15T13:07:46.136Z", - "name": "Case", - "display_name": "Case", + "name": "Ascertained (Case)", + "display_name": "Ascertained (Case)", "used_in": "signal_sets" } }, diff --git a/src/signal_sets/models.py b/src/signal_sets/models.py index aded6b7..d23e7b5 100644 --- a/src/signal_sets/models.py +++ b/src/signal_sets/models.py @@ -248,4 +248,4 @@ class Meta: @property def get_available_geographies(self): - return ", ".join([geo.display_name for geo in self.available_geographies.all()]) + return [geo.display_name for geo in self.available_geographies.all()] diff --git a/src/signal_sets/views.py b/src/signal_sets/views.py index b9fae1c..1051307 100644 --- a/src/signal_sets/views.py +++ b/src/signal_sets/views.py @@ -86,9 +86,9 @@ def get_url_params(self): url_params_str = f"{url_params_str}&{param_name}={param_value}" return url_params_dict, url_params_str - def get_related_signals(self, queryset): + def get_related_signals(self, queryset, signal_set_ids): related_signals = [] - for signal in queryset.prefetch_related( + for signal in queryset.filter(signal_set__id__in=signal_set_ids).prefetch_related( "signal_set", "source", "severity_pyramid_rung" ): related_signals.append( @@ -124,7 +124,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context["filter"] = filter context["signal_sets"] = filter.qs context["related_signals"] = json.dumps( - self.get_related_signals(filter.signals_qs) + self.get_related_signals(filter.signals_qs, filter.qs.values_list("id", flat=True)) ) context["available_geographies"] = Geography.objects.filter(used_in="signals") context["geographic_granularities"] = [ diff --git a/src/signals/admin.py b/src/signals/admin.py index 13102a2..552020e 100644 --- a/src/signals/admin.py +++ b/src/signals/admin.py @@ -103,6 +103,7 @@ class SeverityPyramidRungAdmin(admin.ModelAdmin): "display_name", "used_in", ) + exclude = ("id",) search_fields: tuple[Literal["name"]] = ("name",) diff --git a/src/staticfiles/admin/css/autocomplete.css b/src/staticfiles/admin/css/autocomplete.css index 7478c2c..69c94e7 100644 --- a/src/staticfiles/admin/css/autocomplete.css +++ b/src/staticfiles/admin/css/autocomplete.css @@ -273,7 +273,3 @@ select.admin-autocomplete { display: block; padding: 6px; } - -.errors .select2-selection { - border: 1px solid var(--error-fg); -} diff --git a/src/staticfiles/admin/css/base.css b/src/staticfiles/admin/css/base.css index ac28326..44f2fc8 100644 --- a/src/staticfiles/admin/css/base.css +++ b/src/staticfiles/admin/css/base.css @@ -13,7 +13,6 @@ html[data-theme="light"], --body-fg: #333; --body-bg: #fff; --body-quiet-color: #666; - --body-medium-color: #444; --body-loud-color: #000; --header-color: #ffc; @@ -85,8 +84,6 @@ html[data-theme="light"], "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - - color-scheme: light; } html, body { @@ -150,6 +147,7 @@ h1 { margin: 0 0 20px; font-weight: 300; font-size: 1.25rem; + color: var(--body-quiet-color); } h2 { @@ -165,7 +163,7 @@ h2.subhead { h3 { font-size: 0.875rem; margin: .8em 0 .3em 0; - color: var(--body-medium-color); + color: var(--body-quiet-color); font-weight: bold; } @@ -173,7 +171,6 @@ h4 { font-size: 0.75rem; margin: 1em 0 .8em 0; padding-bottom: 3px; - color: var(--body-medium-color); } h5 { @@ -220,10 +217,6 @@ fieldset { border-top: 1px solid var(--hairline-color); } -details summary { - cursor: pointer; -} - blockquote { font-size: 0.6875rem; color: #777; @@ -320,7 +313,7 @@ td, th { } th { - font-weight: 500; + font-weight: 600; text-align: left; } @@ -341,7 +334,7 @@ tfoot td { } thead th.required { - font-weight: bold; + color: var(--body-loud-color); } tr.alt { @@ -489,13 +482,8 @@ textarea { vertical-align: top; } -/* -Minifiers remove the default (text) "type" attribute from "input" HTML tags. -Add input:not([type]) to make the CSS stylesheet work the same. -*/ -input:not([type]), input[type=text], input[type=password], input[type=email], -input[type=url], input[type=number], input[type=tel], textarea, select, -.vTextField { +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { border: 1px solid var(--border-color); border-radius: 4px; padding: 5px 6px; @@ -504,13 +492,9 @@ input[type=url], input[type=number], input[type=tel], textarea, select, background-color: var(--body-bg); } -/* -Minifiers remove the default (text) "type" attribute from "input" HTML tags. -Add input:not([type]) to make the CSS stylesheet work the same. -*/ -input:not([type]):focus, input[type=text]:focus, input[type=password]:focus, -input[type=email]:focus, input[type=url]:focus, input[type=number]:focus, -input[type=tel]:focus, textarea:focus, select:focus, .vTextField:focus { +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { border-color: var(--body-quiet-color); } @@ -894,10 +878,9 @@ a.deletelink:focus, a.deletelink:hover { margin-right: -300px; } -@media (forced-colors: active) { - #content-related { - border: 1px solid; - } +#footer { + clear: both; + padding: 10px; } /* COLUMN TYPES */ @@ -945,12 +928,6 @@ a.deletelink:focus, a.deletelink:hover { text-decoration: underline; } -@media (forced-colors: active) { - #header { - border-bottom: 1px solid; - } -} - #branding { display: flex; } diff --git a/src/staticfiles/admin/css/changelists.css b/src/staticfiles/admin/css/changelists.css index 005b776..573c389 100644 --- a/src/staticfiles/admin/css/changelists.css +++ b/src/staticfiles/admin/css/changelists.css @@ -139,12 +139,6 @@ margin: 0 0 0 30px; } -@media (forced-colors: active) { - #changelist-filter { - border: 1px solid; - } -} - #changelist-filter h2 { font-size: 0.875rem; text-transform: uppercase; @@ -159,6 +153,7 @@ font-weight: 400; padding: 0 15px; margin-bottom: 10px; + cursor: pointer; } #changelist-filter details summary > * { diff --git a/src/staticfiles/admin/css/dark_mode.css b/src/staticfiles/admin/css/dark_mode.css index 7e12a81..c49b6bc 100644 --- a/src/staticfiles/admin/css/dark_mode.css +++ b/src/staticfiles/admin/css/dark_mode.css @@ -5,8 +5,7 @@ --body-fg: #eeeeee; --body-bg: #121212; - --body-quiet-color: #d0d0d0; - --body-medium-color: #e0e0e0; + --body-quiet-color: #e0e0e0; --body-loud-color: #ffffff; --breadcrumbs-link-fg: #e0e0e0; @@ -30,8 +29,6 @@ --close-button-bg: #333333; --close-button-hover-bg: #666666; - - color-scheme: dark; } } @@ -42,8 +39,7 @@ html[data-theme="dark"] { --body-fg: #eeeeee; --body-bg: #121212; - --body-quiet-color: #d0d0d0; - --body-medium-color: #e0e0e0; + --body-quiet-color: #e0e0e0; --body-loud-color: #ffffff; --breadcrumbs-link-fg: #e0e0e0; @@ -67,8 +63,6 @@ html[data-theme="dark"] { --close-button-bg: #333333; --close-button-hover-bg: #666666; - - color-scheme: dark; } /* THEME SWITCH */ diff --git a/src/staticfiles/admin/css/forms.css b/src/staticfiles/admin/css/forms.css index 4f49b61..9a8dad0 100644 --- a/src/staticfiles/admin/css/forms.css +++ b/src/staticfiles/admin/css/forms.css @@ -44,6 +44,7 @@ label { .required label, label.required { font-weight: bold; + color: var(--body-fg); } /* RADIO BUTTONS */ @@ -75,20 +76,6 @@ form ul.inline li { padding-right: 7px; } -/* FIELDSETS */ - -fieldset .fieldset-heading, -fieldset .inline-heading, -:not(.inline-related) .collapse summary { - border: 1px solid var(--header-bg); - margin: 0; - padding: 8px; - font-weight: 400; - font-size: 0.8125rem; - background: var(--header-bg); - color: var(--header-link-color); -} - /* ALIGNED FIELDSETS */ .aligned label { @@ -97,12 +84,14 @@ fieldset .inline-heading, min-width: 160px; width: 160px; word-wrap: break-word; + line-height: 1; } .aligned label:not(.vCheckboxLabel):after { content: ''; display: inline-block; vertical-align: middle; + height: 1.625rem; } .aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { @@ -169,10 +158,6 @@ form .aligned select + div.help { padding-left: 10px; } -form .aligned select option:checked { - background-color: var(--selected-row); -} - form .aligned ul li { list-style: none; } @@ -183,7 +168,11 @@ form .aligned table p { } .aligned .vCheckboxLabel { - padding: 1px 0 0 5px; + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; } .aligned .vCheckboxLabel + p.help, @@ -205,8 +194,14 @@ fieldset .fieldBox { width: 200px; } -form .wide p.help, +form .wide p, form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, form .wide div.help { padding-left: 50px; } @@ -220,16 +215,35 @@ form div.help ul { width: 450px; } -/* COLLAPSIBLE FIELDSETS */ +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} -.collapse summary .fieldset-heading, -.collapse summary .inline-heading { +fieldset.collapsed .collapse-toggle { background: transparent; - border: none; - color: currentColor; display: inline; - margin: 0; - padding: 0; + color: var(--link-fg); } /* MONOSPACE TEXTAREAS */ @@ -381,16 +395,14 @@ body.popup .submit-row { position: relative; } -.inline-related h4, -.inline-related:not(.tabular) .collapse summary { +.inline-related h3 { margin: 0; - color: var(--body-medium-color); + color: var(--body-quiet-color); padding: 5px; font-size: 0.8125rem; background: var(--darkened-bg); - border: 1px solid var(--hairline-color); - border-left-color: var(--darkened-bg); - border-right-color: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); } .inline-related h3 span.delete { @@ -409,6 +421,16 @@ body.popup .submit-row { width: 100%; } +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + .inline-group .tabular fieldset.module { border: none; } diff --git a/src/staticfiles/admin/css/login.css b/src/staticfiles/admin/css/login.css index 805a34b..389772f 100644 --- a/src/staticfiles/admin/css/login.css +++ b/src/staticfiles/admin/css/login.css @@ -21,7 +21,7 @@ } .login #content { - padding: 20px; + padding: 20px 20px 0; } .login #container { diff --git a/src/staticfiles/admin/css/responsive.css b/src/staticfiles/admin/css/responsive.css index 932e824..bb53945 100644 --- a/src/staticfiles/admin/css/responsive.css +++ b/src/staticfiles/admin/css/responsive.css @@ -171,14 +171,9 @@ input[type="submit"], button { /* Forms */ label { - font-size: 1rem; + font-size: 0.875rem; } - /* - Minifiers remove the default (text) "type" attribute from "input" HTML - tags. Add input:not([type]) to make the CSS stylesheet work the same. - */ - .form-row input:not([type]), .form-row input[type=text], .form-row input[type=password], .form-row input[type=email], @@ -192,7 +187,7 @@ input[type="submit"], button { margin: 0; padding: 6px 8px; min-height: 2.25rem; - font-size: 1rem; + font-size: 0.875rem; } .form-row select { @@ -451,10 +446,14 @@ input[type="submit"], button { @media (max-width: 767px) { /* Layout */ - #header, #content { + #header, #content, #footer { padding: 15px; } + #footer:empty { + padding: 0; + } + div.breadcrumbs { padding: 10px 15px; } @@ -565,6 +564,10 @@ input[type="submit"], button { padding-top: 15px; } + fieldset.collapsed .form-row { + display: none; + } + .aligned label { width: 100%; min-width: auto; diff --git a/src/staticfiles/admin/css/responsive_rtl.css b/src/staticfiles/admin/css/responsive_rtl.css index 33b5784..31dc8ff 100644 --- a/src/staticfiles/admin/css/responsive_rtl.css +++ b/src/staticfiles/admin/css/responsive_rtl.css @@ -35,6 +35,11 @@ background-position: calc(100% - 8px) 9px; } + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + [dir="rtl"] .selector .selector-filter label { margin-right: 0; margin-left: 8px; @@ -53,22 +58,6 @@ padding-left: 0; padding-right: 16px; } - - [dir="rtl"] .selector-add { - background-position: 0 -80px; - } - - [dir="rtl"] .selector-remove { - background-position: 0 -120px; - } - - [dir="rtl"] .active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -100px; - } - - [dir="rtl"] .active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -140px; - } } /* MOBILE */ @@ -92,20 +81,4 @@ [dir="rtl"] .aligned .vCheckboxLabel { padding: 1px 5px 0 0; } - - [dir="rtl"] .selector-remove { - background-position: 0 0; - } - - [dir="rtl"] .active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -20px; - } - - [dir="rtl"] .selector-add { - background-position: 0 -40px; - } - - [dir="rtl"] .active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -60px; - } } diff --git a/src/staticfiles/admin/css/rtl.css b/src/staticfiles/admin/css/rtl.css index b8f60e0..9027c7e 100644 --- a/src/staticfiles/admin/css/rtl.css +++ b/src/staticfiles/admin/css/rtl.css @@ -151,7 +151,6 @@ form ul.inline li { form .aligned p.help, form .aligned div.help { - margin-left: 0; margin-right: 160px; padding-right: 10px; } @@ -165,13 +164,19 @@ form .aligned p.time div.help.timezonewarning { padding-right: 0; } -form .wide p.help, -form .wide ul.errorlist, -form .wide div.help { +form .wide p.help, form .wide div.help { padding-left: 0; padding-right: 50px; } +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + .submit-row { text-align: right; } @@ -197,7 +202,12 @@ fieldset .fieldBox { top: 0; left: auto; right: 10px; - background: url(../img/calendar-icons.svg) 0 -15px no-repeat; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; } .calendarnav-next { @@ -207,6 +217,11 @@ fieldset .fieldBox { background: url(../img/calendar-icons.svg) 0 0 no-repeat; } +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + .calendar caption, .calendarbox h2 { text-align: center; } @@ -282,10 +297,6 @@ form .form-row p.datetime { margin-right: 2px; } -.inline-group .tabular td.original p { - right: 0; -} - .selector .selector-chooser { margin: 0; } diff --git a/src/staticfiles/admin/css/widgets.css b/src/staticfiles/admin/css/widgets.css index cc64811..d3d4732 100644 --- a/src/staticfiles/admin/css/widgets.css +++ b/src/staticfiles/admin/css/widgets.css @@ -519,9 +519,19 @@ span.clearable-file-input label { background: url(../img/calendar-icons.svg) 0 0 no-repeat; } +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + .calendarnav-next { right: 10px; - background: url(../img/calendar-icons.svg) 0 -15px no-repeat; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; } .calendar-cancel { diff --git a/src/staticfiles/admin/img/README.txt b/src/staticfiles/admin/img/README.txt index bf81f35..4eb2e49 100644 --- a/src/staticfiles/admin/img/README.txt +++ b/src/staticfiles/admin/img/README.txt @@ -1,4 +1,4 @@ -All icons are taken from Font Awesome (https://fontawesome.com/) project. +All icons are taken from Font Awesome (http://fontawesome.io/) project. The Font Awesome font is licensed under the SIL OFL 1.1: - https://scripts.sil.org/OFL diff --git a/src/staticfiles/admin/img/calendar-icons.svg b/src/staticfiles/admin/img/calendar-icons.svg index 04c0274..dbf21c3 100644 --- a/src/staticfiles/admin/img/calendar-icons.svg +++ b/src/staticfiles/admin/img/calendar-icons.svg @@ -1,63 +1,14 @@ - - - - - - + + + + - - + + - - + + + + diff --git a/src/staticfiles/admin/img/icon-addlink.svg b/src/staticfiles/admin/img/icon-addlink.svg index 8d5c6a3..e004fb1 100644 --- a/src/staticfiles/admin/img/icon-addlink.svg +++ b/src/staticfiles/admin/img/icon-addlink.svg @@ -1,3 +1,3 @@ - + diff --git a/src/staticfiles/admin/img/icon-changelink.svg b/src/staticfiles/admin/img/icon-changelink.svg index 592b093..bbb137a 100644 --- a/src/staticfiles/admin/img/icon-changelink.svg +++ b/src/staticfiles/admin/img/icon-changelink.svg @@ -1,3 +1,3 @@ - + diff --git a/src/staticfiles/admin/js/SelectFilter2.js b/src/staticfiles/admin/js/SelectFilter2.js index 6957412..fc59eba 100644 --- a/src/staticfiles/admin/js/SelectFilter2.js +++ b/src/staticfiles/admin/js/SelectFilter2.js @@ -1,4 +1,4 @@ -/*global SelectBox, gettext, ngettext, interpolate, quickElement, SelectFilter*/ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ /* SelectFilter2 - Turns a multiple-select box into a filter interface. diff --git a/src/staticfiles/admin/js/actions.js b/src/staticfiles/admin/js/actions.js index 04b25e9..6a2ae91 100644 --- a/src/staticfiles/admin/js/actions.js +++ b/src/staticfiles/admin/js/actions.js @@ -1,4 +1,4 @@ -/*global gettext, interpolate, ngettext, Actions*/ +/*global gettext, interpolate, ngettext*/ 'use strict'; { function show(selector) { diff --git a/src/staticfiles/admin/js/admin/RelatedObjectLookups.js b/src/staticfiles/admin/js/admin/RelatedObjectLookups.js index bc3acce..32e3f5b 100644 --- a/src/staticfiles/admin/js/admin/RelatedObjectLookups.js +++ b/src/staticfiles/admin/js/admin/RelatedObjectLookups.js @@ -96,8 +96,8 @@ // Extract the model from the popup url '...//add/' or // '...///change/' depending the action (add or change). const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; - // Select elements with a specific model reference and context of "available-source". - const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] [data-context="available-source"]`); + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); selectsRelated.forEach(function(select) { if (currentSelect === select) { diff --git a/src/staticfiles/admin/js/popup_response.js b/src/staticfiles/admin/js/popup_response.js index fecf0f4..2b1d3dd 100644 --- a/src/staticfiles/admin/js/popup_response.js +++ b/src/staticfiles/admin/js/popup_response.js @@ -1,3 +1,4 @@ +/*global opener */ 'use strict'; { const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); diff --git a/src/staticfiles/admin/js/theme.js b/src/staticfiles/admin/js/theme.js index e79d375..794cd15 100644 --- a/src/staticfiles/admin/js/theme.js +++ b/src/staticfiles/admin/js/theme.js @@ -1,51 +1,56 @@ 'use strict'; { - function setTheme(mode) { - if (mode !== "light" && mode !== "dark" && mode !== "auto") { - console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); - mode = "auto"; + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); } - document.documentElement.dataset.theme = mode; - localStorage.setItem("theme", mode); - } - function cycleTheme() { - const currentTheme = localStorage.getItem("theme") || "auto"; - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - if (prefersDark) { - // Auto (dark) -> Light -> Dark - if (currentTheme === "auto") { - setTheme("light"); - } else if (currentTheme === "light") { - setTheme("dark"); - } else { - setTheme("auto"); - } - } else { - // Auto (light) -> Dark -> Light - if (currentTheme === "auto") { - setTheme("dark"); - } else if (currentTheme === "dark") { - setTheme("light"); + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } } else { - setTheme("auto"); + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } } } - } - function initTheme() { - // set theme defined in localStorage if there is one, or fallback to auto mode - const currentTheme = localStorage.getItem("theme"); - currentTheme ? setTheme(currentTheme) : setTheme("auto"); - } + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } - window.addEventListener('load', function(_) { - const buttons = document.getElementsByClassName("theme-toggle"); - Array.from(buttons).forEach((btn) => { - btn.addEventListener("click", cycleTheme); - }); - }); + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } - initTheme(); + setupTheme(); + }); } diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index 64e51b0..16717ae 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -567,7 +567,11 @@

{{ signal_set.geographic_scope }} - {{ signal_set.get_available_geographies }} + {% for available_geography in signal_set.get_available_geographies %} + + {{ available_geography }} + + {% endfor %} {{ signal_set.temporal_scope_start }} @@ -576,7 +580,9 @@

{{ signal_set.temporal_scope_end }} - {{ signal_set.temporal_granularity }} + + {{ signal_set.temporal_granularity }} + {{ signal_set.reporting_cadence }} @@ -738,6 +744,7 @@

const geoValues = {{ geographic_granularities|safe }}; var relatedSignals = JSON.parse(JSON.stringify({{ related_signals|safe }})); + console.log(relatedSignals.length) var urlParams = JSON.parse(JSON.stringify({{ url_params_dict|safe }})); @@ -758,7 +765,7 @@ initSelect2('geographic_value', geoValues); table.columns.adjust() - $("#totalRowsNumber").text(`Showing ${table.page.info().recordsTotal} Indicator ${pluralize(table.page.info().recordsTotal, 'Set')}`); + $("#totalRowsNumber").text(`Showing ${table.page.info().recordsTotal} indicator ${pluralize(table.page.info().recordsTotal, 'set')} containing ${relatedSignals.length} individual ${pluralize(relatedSignals.length, 'indicator')}`) }) // Add event listener for opening and closing details From abf37f681fcd8e9648caa27532a0bb52170cdf13 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Wed, 19 Mar 2025 21:23:28 +0200 Subject: [PATCH 02/12] Added display order number to the severity pyramid rungs, fixed pathogens import for signal sets --- src/datasources/resources.py | 13 ++++++- src/fixtures/severity_pyramid_rungs.json | 34 ++++++++++++------- src/signal_sets/forms.py | 2 +- src/signal_sets/resources.py | 5 +-- src/signals/admin.py | 1 + ...everitypyramidrung_display_order_number.py | 18 ++++++++++ src/signals/models.py | 6 ++++ 7 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 src/signals/migrations/0017_severitypyramidrung_display_order_number.py diff --git a/src/datasources/resources.py b/src/datasources/resources.py index 15a337c..07aa6bd 100644 --- a/src/datasources/resources.py +++ b/src/datasources/resources.py @@ -27,11 +27,21 @@ def process_links(row, dua_column_name="DUA", link_column_name="Link"): links.append(link.id) else: for match in matches: - link, _ = Link.objects.get_or_create(url=match[1], defaults={'link_type': match[0], }) + link, _ = Link.objects.get_or_create( + url=match[1], + defaults={ + "link_type": match[0], + }, + ) links.append(link.id) row["Links"] = links +def process_datasource_name(row): + if row["Name"]: + row["Name"] = row["Name"].capitalize() + + def process_datasources(row): datasource, _ = DataSource.objects.get_or_create( name=row["DB Source"], @@ -67,5 +77,6 @@ class Meta: skip_unchanged = True def before_import_row(self, row, **kwargs): + process_datasource_name(row) process_links(row) process_datasources(row) diff --git a/src/fixtures/severity_pyramid_rungs.json b/src/fixtures/severity_pyramid_rungs.json index 36568e9..cff74c7 100644 --- a/src/fixtures/severity_pyramid_rungs.json +++ b/src/fixtures/severity_pyramid_rungs.json @@ -82,9 +82,10 @@ "fields": { "created": "2024-08-15T09:57:49.327Z", "modified": "2024-08-15T13:07:46.136Z", - "name": "Population", - "display_name": "Population", - "used_in": "signal_sets" + "name": "Entire Population", + "display_name": "Entire Population", + "used_in": "signal_sets", + "display_order_number": 1 } }, { @@ -95,7 +96,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Vaccinated", "display_name": "Vaccinated", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 2 } }, { @@ -106,7 +108,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Infected", "display_name": "Infected", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 3 } }, { @@ -117,7 +120,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Tested", "display_name": "Tested", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 4 } }, { @@ -128,7 +132,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Ascertained (Case)", "display_name": "Ascertained (Case)", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 5 } }, { @@ -139,7 +144,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Symptomatic", "display_name": "Symptomatic", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 6 } }, { @@ -150,7 +156,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Outpatient / ED", "display_name": "Outpatient / ED", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 7 } }, { @@ -161,7 +168,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Hospitalized", "display_name": "Hospitalized", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 8 } }, { @@ -172,7 +180,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "ICU", "display_name": "ICU", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 9 } }, { @@ -183,7 +192,8 @@ "modified": "2024-08-15T13:07:46.136Z", "name": "Deceased", "display_name": "Deceased", - "used_in": "signal_sets" + "used_in": "signal_sets", + "display_order_number": 10 } } ] diff --git a/src/signal_sets/forms.py b/src/signal_sets/forms.py index 8c767cc..2dd9a6b 100644 --- a/src/signal_sets/forms.py +++ b/src/signal_sets/forms.py @@ -35,7 +35,7 @@ class SignalSetFilterForm(forms.ModelForm): queryset=SeverityPyramidRung.objects.filter( # id__in=SignalSet.objects.values_list("severity_pyramid_rungs", flat="True") used_in="signal_sets" - ), + ).order_by("display_order_number"), widget=forms.CheckboxSelectMultiple(), ) diff --git a/src/signal_sets/resources.py b/src/signal_sets/resources.py index 2eb437a..6390bfd 100644 --- a/src/signal_sets/resources.py +++ b/src/signal_sets/resources.py @@ -221,8 +221,10 @@ def skip_row(self, instance, original, row, import_validation_errors=None): def after_import_row(self, row, row_result, **kwargs): try: signal_set_obj = SignalSet.objects.get(id=row_result.object_id) + signal_set_obj.pathogens.clear() + signal_set_obj.severity_pyramid_rungs.clear() + signal_set_obj.available_geographies.clear() for pathogen in row["Pathogen(s)/Syndrome(s)"].split(","): - signal_set_obj.pathogens.clear() pathogen = Pathogen.objects.get(name=pathogen, used_in="signal_sets") signal_set_obj.pathogens.add(pathogen) for severity_pyramid_rung in row["Surveillance Categories"].split(","): @@ -231,7 +233,6 @@ def after_import_row(self, row, row_result, **kwargs): used_in="signal_sets" ).first() signal_set_obj.severity_pyramid_rungs.add(severity_pyramid_rung) - for available_geography in row["Geographic Granularity - Delphi"].split(","): available_geography = Geography.objects.get(name=available_geography, used_in="signal_sets") signal_set_obj.available_geographies.add(available_geography) diff --git a/src/signals/admin.py b/src/signals/admin.py index 552020e..09c0cf7 100644 --- a/src/signals/admin.py +++ b/src/signals/admin.py @@ -102,6 +102,7 @@ class SeverityPyramidRungAdmin(admin.ModelAdmin): "name", "display_name", "used_in", + "display_order_number", ) exclude = ("id",) search_fields: tuple[Literal["name"]] = ("name",) diff --git a/src/signals/migrations/0017_severitypyramidrung_display_order_number.py b/src/signals/migrations/0017_severitypyramidrung_display_order_number.py new file mode 100644 index 0000000..3b60130 --- /dev/null +++ b/src/signals/migrations/0017_severitypyramidrung_display_order_number.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.7 on 2025-03-12 17:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0016_signal_source_view'), + ] + + operations = [ + migrations.AddField( + model_name='severitypyramidrung', + name='display_order_number', + field=models.IntegerField(help_text='Display order number of the severity pyramid rung.', null=True, verbose_name='display order number'), + ), + ] diff --git a/src/signals/models.py b/src/signals/models.py index f9f7070..64f7d16 100644 --- a/src/signals/models.py +++ b/src/signals/models.py @@ -81,6 +81,12 @@ class SeverityPyramidRung(TimeStampedModel): default="signals", ) + display_order_number: models.IntegerField = models.IntegerField( + verbose_name=_("display order number"), + help_text=_("Display order number of the severity pyramid rung."), + null=True, + ) + class Meta: verbose_name_plural: str = "Severity Pyramid Rungs" unique_together: list[str] = ["name", "used_in"] From ef48f18004036c7c713f3ed1dc357602fb3da52f Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Tue, 25 Mar 2025 16:08:37 +0200 Subject: [PATCH 03/12] Fixed issue with reseting filters before form is submit & reset filters when back button was clicked --- src/templates/signal_sets/signal_sets.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index 16717ae..dcb01a7 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -744,7 +744,6 @@ const geoValues = {{ geographic_granularities|safe }}; var relatedSignals = JSON.parse(JSON.stringify({{ related_signals|safe }})); - console.log(relatedSignals.length) var urlParams = JSON.parse(JSON.stringify({{ url_params_dict|safe }})); @@ -785,10 +784,9 @@ }); - // Add event listener for filtering. - window.addEventListener('beforeunload', () => { - document.getElementById('filterSignalSetsForm').reset(); - }); + // Added these two lines to disable bfcache (https://web.dev/articles/bfcache). + window.addEventListener('unload', function(){}); + window.addEventListener('beforeunload', function(){}); document.getElementsByName('modes').forEach((el) => { el.addEventListener('change', (event) => { From 528d040543b87ed0e3a99b18ad00f228f736a83d Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Tue, 25 Mar 2025 16:29:11 +0200 Subject: [PATCH 04/12] Replaced bfcache handling with better solution --- src/templates/signal_sets/signal_sets.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index dcb01a7..a7fb44c 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -785,8 +785,13 @@ // Added these two lines to disable bfcache (https://web.dev/articles/bfcache). - window.addEventListener('unload', function(){}); - window.addEventListener('beforeunload', function(){}); + window.addEventListener('pageshow', (event) => { + if (event.persisted) { + location.reload() + } + }); + {% comment %} window.addEventListener('unload', function(){}); + window.addEventListener('beforeunload', function(){}); {% endcomment %} document.getElementsByName('modes').forEach((el) => { el.addEventListener('change', (event) => { From 1013274f53c328483ad4ba8011bbc7fe5ae7f73c Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Tue, 25 Mar 2025 17:38:39 +0200 Subject: [PATCH 05/12] Removed commented code --- src/templates/signal_sets/signal_sets.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index a7fb44c..579f388 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -790,8 +790,6 @@ location.reload() } }); - {% comment %} window.addEventListener('unload', function(){}); - window.addEventListener('beforeunload', function(){}); {% endcomment %} document.getElementsByName('modes').forEach((el) => { el.addEventListener('change', (event) => { From 6fdba6e7605d1f1b2df4c9167da5f25d6f1fd9f2 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Tue, 25 Mar 2025 17:42:29 +0200 Subject: [PATCH 06/12] Added warning message if no locations were selected and user tries to submit form --- src/assets/js/signal_sets.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/assets/js/signal_sets.js b/src/assets/js/signal_sets.js index 676490e..ac6ad37 100644 --- a/src/assets/js/signal_sets.js +++ b/src/assets/js/signal_sets.js @@ -448,8 +448,15 @@ $('#geographic_value').on('select2:select', function (e) { function submitMode(event) { event.preventDefault(); + var geographicValues = $('#geographic_value').select2('data'); + if (geographicValues.length === 0) { + appendAlert("Please select at least one geographic location", "warning") + return; + } + if (currentMode === 'epivis') { + // appendAlert(warningMessage, "warning") plotData(); } else if (currentMode === 'export') { exportData(); From 3929271ca3842e9200fa979e7eeefa70e772ad04 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Tue, 25 Mar 2025 19:15:27 +0200 Subject: [PATCH 07/12] 1. If Location Search was used on the main page, the Locations used should be copied as default to the "Selected Indicators" page. --- src/assets/js/signal_sets.js | 70 ++++++++++++++-------- src/templates/signal_sets/signal_sets.html | 8 ++- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/src/assets/js/signal_sets.js b/src/assets/js/signal_sets.js index ac6ad37..c926b42 100644 --- a/src/assets/js/signal_sets.js +++ b/src/assets/js/signal_sets.js @@ -15,27 +15,32 @@ function showWarningAlert(warningMessage, slideUpTime = 2000) { }); } -function checkGeoCoverage(geoType, geoValue) { - var notCoveredSignals = []; - $.ajax({ - url: "epidata/covidcast/geo_coverage/", - type: 'GET', - async: false, - data: { - 'geo': `${geoType}:${geoValue}` - }, - success: function (result) { - checkedSignalMembers.forEach(signal => { - var covered = result["epidata"].some( - e => (e.source === signal.data_source && e.signal === signal.signal) - ) - if (!covered) { - notCoveredSignals.push(signal); - } - }) - } - }) - return notCoveredSignals; +async function checkGeoCoverage(geoType, geoValue) { + const notCoveredSignals = []; + + try { + const result = await $.ajax({ + url: "epidata/covidcast/geo_coverage/", + type: 'GET', + data: { + 'geo': `${geoType}:${geoValue}` + } + }); + + checkedSignalMembers.forEach(signal => { + const covered = result["epidata"].some( + e => (e.source === signal.data_source && e.signal === signal.signal) + ); + if (!covered) { + notCoveredSignals.push(signal); + } + }); + + return notCoveredSignals; + } catch (error) { + console.error('Error fetching geo coverage:', error); + return notCoveredSignals; + } } @@ -123,6 +128,18 @@ function addSelectedSignal(element) { } } +$("#showSelectedSignalsButton").click(function() { + alertPlaceholder.innerHTML = ""; + $('#geographic_value').select2("data").forEach(geo => { + checkGeoCoverage(geo.geoType, geo.id).then((notCoveredSignals) => { + if (notCoveredSignals.length > 0) { + showNotCoveredGeoWarningMessage(notCoveredSignals, geo.text); + } + }) + + }); +}); + // Add an event listener to each 'bulk-select' element let bulkSelectDivs = document.querySelectorAll('.bulk-select'); bulkSelectDivs.forEach(div => { @@ -439,10 +456,12 @@ function showNotCoveredGeoWarningMessage(notCoveredSignals, geoValue) { $('#geographic_value').on('select2:select', function (e) { var geo = e.params.data; - var notCoveredSignals = checkGeoCoverage(geo.geoType, geo.id) - if (notCoveredSignals.length > 0) { - showNotCoveredGeoWarningMessage(notCoveredSignals, geo.text); + checkGeoCoverage(geo.geoType, geo.id).then((notCoveredSignals) => { + if (notCoveredSignals.length > 0) { + showNotCoveredGeoWarningMessage(notCoveredSignals, geo.text); + } } + ); }); @@ -454,9 +473,8 @@ function submitMode(event) { appendAlert("Please select at least one geographic location", "warning") return; } - + if (currentMode === 'epivis') { - // appendAlert(warningMessage, "warning") plotData(); } else if (currentMode === 'export') { exportData(); diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index 579f388..92b4b9c 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -758,10 +758,14 @@ } }) initSelect2('location_search', locationSearchValues); + initSelect2('geographic_value', geoValues); if (urlParams.location_search != "") { - $("#location_search").val(urlParams.location_search).trigger("change"); + var locationSearch = urlParams.location_search; + $("#location_search").val(locationSearch).trigger("change"); + var locationIds = locationSearch.map((item) => item.split(":")[1]); + $('#geographic_value').val(locationIds).trigger('change'); } - initSelect2('geographic_value', geoValues); + table.columns.adjust() $("#totalRowsNumber").text(`Showing ${table.page.info().recordsTotal} indicator ${pluralize(table.page.info().recordsTotal, 'set')} containing ${relatedSignals.length} individual ${pluralize(relatedSignals.length, 'indicator')}`) From 4b154aa792d3df588d6808faad70f06c46ecbbb6 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Thu, 27 Mar 2025 00:06:10 +0200 Subject: [PATCH 08/12] Added links to the bottom of the left panel --- src/templates/signal_sets/signal_sets.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index 92b4b9c..68ac88f 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -278,6 +278,18 @@

Clear filters +
+ From fdc424f26db2236e52217bbeec59628d16dbf4bb Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Fri, 28 Mar 2025 00:16:53 +0200 Subject: [PATCH 09/12] Fixed Signals import --- src/signals/admin.py | 32 ++- .../migrations/0018_otherendointsignal.py | 26 +++ src/signals/models.py | 7 + src/signals/resources.py | 213 +++++++++++++++++- 4 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 src/signals/migrations/0018_otherendointsignal.py diff --git a/src/signals/admin.py b/src/signals/admin.py index 09c0cf7..531d8fa 100644 --- a/src/signals/admin.py +++ b/src/signals/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from signals.resources import SignalResource, SignalBaseResource +from signals.resources import SignalResource, SignalBaseResource, OtherEndpointSignalResource from signals.models import ( @@ -14,6 +14,7 @@ Pathogen, SeverityPyramidRung, Signal, + OtherEndointSignal, SignalType, SignalGeography, GeographyUnit, @@ -150,6 +151,35 @@ class SignalAdmin(ImportExportModelAdmin): resource_classes: list[type[SignalResource]] = [SignalResource, SignalBaseResource] +@admin.register(OtherEndointSignal) +class OtherEndpointsSignalAdmin(ImportExportModelAdmin): + """ + Admin interface for managing signal objects. + """ + + list_display: tuple[ + Literal["name"], + Literal["signal_type"], + Literal["format_type"], + Literal["category"], + Literal["geographic_scope"], + ] = ("name", "signal_type", "format_type", "category", "geographic_scope") + search_fields: tuple[ + Literal["name"], + Literal["signal_type__name"], + Literal["format_type__name"], + Literal["category__name"], + Literal["geographic_scope__name"], + ] = ( + "name", + "signal_type__name", + "format_type__name", + "category__name", + "geographic_scope__name", + ) + resource_classes: list[type[SignalResource]] = [OtherEndpointSignalResource] + + @admin.register(SignalGeography) class SignalGeographyAdmin(admin.ModelAdmin): """ diff --git a/src/signals/migrations/0018_otherendointsignal.py b/src/signals/migrations/0018_otherendointsignal.py new file mode 100644 index 0000000..3542808 --- /dev/null +++ b/src/signals/migrations/0018_otherendointsignal.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.7 on 2025-03-27 18:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0017_severitypyramidrung_display_order_number'), + ] + + operations = [ + migrations.CreateModel( + name='OtherEndointSignal', + fields=[ + ], + options={ + 'verbose_name': 'Other Endpoint Signal', + 'verbose_name_plural': 'Other Endpoint Signals', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('signals.signal',), + ), + ] diff --git a/src/signals/models.py b/src/signals/models.py index 64f7d16..b55ca0b 100644 --- a/src/signals/models.py +++ b/src/signals/models.py @@ -575,6 +575,13 @@ def get_display_name(self): return self.name +class OtherEndointSignal(Signal): + class Meta: + proxy = True + verbose_name = "Other Endpoint Signal" + verbose_name_plural = "Other Endpoint Signals" + + class SignalsDbView(models.Model): id = models.BigIntegerField(primary_key=True) name = models.CharField(max_length=255) diff --git a/src/signals/resources.py b/src/signals/resources.py index 267286d..f2bd827 100644 --- a/src/signals/resources.py +++ b/src/signals/resources.py @@ -155,9 +155,14 @@ def process_available_geographies(row) -> None: "display_order_number": max_display_order_number + 1, }, ) - signal = Signal.objects.get( - name=row["Indicator"], source=row["Source Subdivision"] - ) + try: + signal = Signal.objects.get( + name=row["Indicator"], source=row["Source Subdivision"] + ) + except KeyError: + signal = Signal.objects.get( + name=row["Signal"], source=row["Source Subdivision"] + ) signal_geography, _ = SignalGeography.objects.get_or_create( geography=geography_instance, signal=signal ) @@ -167,12 +172,12 @@ def process_available_geographies(row) -> None: def process_base(row) -> None: - if row["Indicator BaseName"]: + if row["Signal BaseName"]: source: SourceSubdivision = SourceSubdivision.objects.get( name=row["Source Subdivision"] ) base_signal: Signal = Signal.objects.get( - name=row["Indicator BaseName"], source=source + name=row["Signal BaseName"], source=source ) row["base"] = base_signal.id @@ -180,7 +185,7 @@ def process_base(row) -> None: class ModelResource(resources.ModelResource): def get_field_names(self): names = [] - for field in self.get_fields(): + for field in list(self.fields.values()): names.append(self.get_field_name(field)) return names @@ -213,7 +218,7 @@ class SignalBaseResource(ModelResource): Resource class for importing Signals base. """ - name = Field(attribute="name", column_name="Indicator") + name = Field(attribute="name", column_name="Signal") display_name = Field(attribute="display_name", column_name="Name") base = Field( attribute="base", @@ -241,6 +246,200 @@ class SignalResource(ModelResource): Resource class for importing and exporting Signal models """ + name = Field(attribute="name", column_name="Signal") + display_name = Field(attribute="display_name", column_name="Name") + member_name = Field(attribute="member_name", column_name="Member API Name") + member_short_name = Field( + attribute="member_short_name", column_name="Member Short Name" + ) + member_description = Field( + attribute="member_description", column_name="Member Description" + ) + pathogen = Field( + attribute="pathogen", + column_name="Pathogen/\nDisease Area", + widget=widgets.ManyToManyWidget(Pathogen, field="name", separator=","), + ) + signal_type = Field( + attribute="signal_type", + column_name="Indicator Type", + widget=widgets.ForeignKeyWidget(SignalType, field="name"), + ) + active = Field(attribute="active", column_name="Active") + description = Field(attribute="description", column_name="Description") + short_description = Field( + attribute="short_description", column_name="Short Description" + ) + format_type = Field( + attribute="format_type", + column_name="Format", + widget=widgets.ForeignKeyWidget(FormatType, field="name"), + ) + time_type = Field(attribute="time_type", column_name="Time Type") + time_label = Field(attribute="time_label", column_name="Time Label") + reporting_cadence = Field( + attribute="reporting_cadence", column_name="Reporting Cadence" + ) + typical_reporting_lag = Field( + attribute="typical_reporting_lag", column_name="Typical Reporting Lag" + ) + typical_revision_cadence = Field( + attribute="typical_revision_cadence", column_name="Typical Revision Cadence" + ) + demographic_scope = Field( + attribute="demographic_scope", column_name="Population" + ) + severity_pyramid_rung = Field( + attribute="severity_pyramid_rung", + column_name="Surveillance Categories", + widget=widgets.ForeignKeyWidget(SeverityPyramidRung), + ) + category = Field( + attribute="category", + column_name="Category", + widget=widgets.ForeignKeyWidget(Category, "name"), + ) + geographic_scope = Field( + attribute="geographic_scope", + column_name="Geographic Coverage", + widget=widgets.ForeignKeyWidget(GeographicScope), + ) + available_geographies = Field( + attribute="available_geography", + column_name="Geographic Levels", + widget=widgets.ManyToManyWidget(Geography, field="name", separator=","), + ) + temporal_scope_start = Field( + attribute="temporal_scope_start", column_name="Temporal Scope Start" + ) + temporal_scope_start_note = Field( + attribute="temporal_scope_start_note", column_name="Temporal Scope Start Note" + ) + temporal_scope_end = Field( + attribute="temporal_scope_end", column_name="Temporal Scope End" + ) + temporal_scope_end_note = Field( + attribute="temporal_scope_end_note", column_name="Temporal Scope End Note" + ) + is_smoothed = Field(attribute="is_smoothed", column_name="Is Smoothed") + is_weighted = Field(attribute="is_weighted", column_name="Is Weighted") + is_cumulative = Field(attribute="is_cumulative", column_name="Is Cumulative") + has_stderr = Field(attribute="has_stderr", column_name="Has StdErr") + has_sample_size = Field(attribute="has_sample_size", column_name="Has Sample Size") + high_values_are = Field(attribute="high_values_are", column_name="High Values Are") + source = Field( + attribute="source", + column_name="Source Subdivision", + widget=widgets.ForeignKeyWidget(SourceSubdivision, field="name"), + ) + data_censoring = Field(attribute="data_censoring", column_name="Data Censoring") + missingness = Field(attribute="missingness", column_name="Missingness") + organization_access_list = Field( + attribute="organization_access_list", column_name="Who may access this indicator?" + ) + organization_sharing_list = Field( + attribute="organization_sharing_list", + column_name="Who may be told about this indicator?", + ) + license = Field(attribute="license", column_name="Data Use Terms") + restrictions = Field(attribute="restrictions", column_name="Use Restrictions") + signal_set = Field( + attribute="signal_set", + column_name="Indicator Set", + widget=widgets.ForeignKeyWidget(SignalSet, field="name"), + ) + + class Meta: + model = Signal + fields: list[str] = [ + "name", + "display_name", + "member_name", + "member_short_name", + "member_description", + "pathogen", + "signal_type", + "active", + "description", + "short_description", + "time_label", + "reporting_cadence", + "typical_reporting_lag", + "typical_revision_cadence", + "demographic_scope", + "category", + "geographic_scope", + "available_geographies", + "temporal_scope_start", + "temporal_scope_start_note", + "temporal_scope_end", + "temporal_scope_end_note", + "is_smoothed", + "is_weighted", + "is_cumulative", + "has_stderr", + "has_sample_size", + "high_values_are", + "source", + "data_censoring", + "missingness", + "organization_access_list", + "organization_sharing_list", + "license", + "restrictions", + "time_type", + "signal_set", + "format_type", + "severity_pyramid_rung", + ] + import_id_fields: list[str] = ["name", "source"] + store_instance = True + skip_unchanged = True + + def before_import_row(self, row, **kwargs) -> None: + fix_boolean_fields(row) + process_pathogen(row) + process_signal_type(row) + process_format_type(row) + process_severity_pyramid_rungs(row) + process_category(row) + process_geographic_scope(row) + process_source(row) + process_links(row, dua_column_name="Link to DUA", link_column_name="Link") + if not row.get("Indicator Set"): + row["Indicator Set"] = None + if not row.get("Source Subdivision"): + row["Source Subdivision"] = None + + def skip_row(self, instance, original, row, import_validation_errors=None): + if not row["Include in indicator app"]: + try: + signal = Signal.objects.get( + name=row["Signal"], source=row["Source Subdivision"] + ) + signal.delete() + except Signal.DoesNotExist: + pass + return True + + def after_import_row(self, row, row_result, **kwargs): + try: + signal_obj = Signal.objects.get(id=row_result.object_id) + for link in row["Links"]: + signal_obj.related_links.add(link) + process_available_geographies(row) + signal_obj.severity_pyramid_rung = SeverityPyramidRung.objects.get(id=row["Surveillance Categories"]) + signal_obj.format_type = row["Format"] + signal_obj.save() + except Signal.DoesNotExist as e: + print(f"Signal.DoesNotExist: {e}") + + +class OtherEndpointSignalResource(ModelResource): + """ + Resource class for importing and exporting Signal models + """ + name = Field(attribute="name", column_name="Indicator") display_name = Field(attribute="display_name", column_name="Name") member_name = Field(attribute="member_name", column_name="Member API Name") From d29ac85723136779f47c9ae46ece65b4290b4ca2 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Fri, 28 Mar 2025 13:11:34 +0200 Subject: [PATCH 10/12] Fixed renamed column name --- src/signal_sets/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/signal_sets/resources.py b/src/signal_sets/resources.py index 6390bfd..77e60aa 100644 --- a/src/signal_sets/resources.py +++ b/src/signal_sets/resources.py @@ -152,7 +152,7 @@ class SignalSetResource(resources.ModelResource): ) censoring = Field(attribute="censoring", column_name="Censoring") missingness = Field(attribute="missingness", column_name="Missingness") - dua_required = Field(attribute="dua_required", column_name="DUA required?") + dua_required = Field(attribute="dua_required", column_name="DUA Required?") license = Field(attribute="license", column_name="Data Use Terms") dataset_location = Field( attribute="dataset_location", column_name="Dataset Location" From 9734ad6e83eda4ce7c3d6885bdd3e66047f0df59 Mon Sep 17 00:00:00 2001 From: Dmytro Trotsko Date: Wed, 2 Apr 2025 21:39:57 +0300 Subject: [PATCH 11/12] Added plot data support for ILINet indicator set --- src/assets/js/indicatorHandler.js | 263 +++++++++++++++++++++ src/assets/js/signal_sets.js | 72 ++---- src/templates/signal_sets/signal_sets.html | 6 +- 3 files changed, 289 insertions(+), 52 deletions(-) create mode 100644 src/assets/js/indicatorHandler.js diff --git a/src/assets/js/indicatorHandler.js b/src/assets/js/indicatorHandler.js new file mode 100644 index 0000000..ea84e54 --- /dev/null +++ b/src/assets/js/indicatorHandler.js @@ -0,0 +1,263 @@ +class IndicatorHandler{ + constructor() { + this.indicators = {}; + } + + fluviewIndicatorsMapping = { + "wili": "%wILI", + "ili": "%ILI", + } + + fluSurvRegions = [ + { value: 'network_all', label: 'Entire Network' }, + { value: 'network_eip', label: 'EIP Netowrk' }, + { value: 'network_ihsp', label: 'IHSP Network' }, + { value: 'CA', label: 'CA' }, + { value: 'CO', label: 'CO' }, + { value: 'CT', label: 'CT' }, + { value: 'GA', label: 'GA' }, + { value: 'IA', label: 'IA' }, + { value: 'ID', label: 'ID' }, + { value: 'MD', label: 'MD' }, + { value: 'MI', label: 'MI' }, + { value: 'MN', label: 'MN' }, + { value: 'NM', label: 'NM' }, + { value: 'NY_albany', label: 'NY (Albany)' }, + { value: 'NY_rochester', label: 'NY (Rochester)' }, + { value: 'OH', label: 'OH' }, + { value: 'OK', label: 'OK' }, + { value: 'OR', label: 'OR' }, + { value: 'RI', label: 'RI' }, + { value: 'SD', label: 'SD' }, + { value: 'TN', label: 'TN' }, + { value: 'UT', label: 'UT' }, + ] + + fluviewRegions = [ + { id: 'nat', text: 'U.S. National' }, + { id: 'hhs1', text: 'HHS Region 1' }, + { id: 'hhs2', text: 'HHS Region 2' }, + { id: 'hhs3', text: 'HHS Region 3' }, + { id: 'hhs4', text: 'HHS Region 4' }, + { id: 'hhs5', text: 'HHS Region 5' }, + { id: 'hhs6', text: 'HHS Region 6' }, + { id: 'hhs7', text: 'HHS Region 7' }, + { id: 'hhs8', text: 'HHS Region 8' }, + { id: 'hhs9', text: 'HHS Region 9' }, + { id: 'hhs10', text: 'HHS Region 10' }, + { id: 'cen1', text: 'Census Region 1' }, + { id: 'cen2', text: 'Census Region 2' }, + { id: 'cen3', text: 'Census Region 3' }, + { id: 'cen4', text: 'Census Region 4' }, + { id: 'cen5', text: 'Census Region 5' }, + { id: 'cen6', text: 'Census Region 6' }, + { id: 'cen7', text: 'Census Region 7' }, + { id: 'cen8', text: 'Census Region 8' }, + { id: 'cen9', text: 'Census Region 9' }, + { id: 'AK', text: 'AK' }, + { id: 'AL', text: 'AL' }, + { id: 'AR', text: 'AR' }, + { id: 'AZ', text: 'AZ' }, + { id: 'CA', text: 'CA' }, + { id: 'CO', text: 'CO' }, + { id: 'CT', text: 'CT' }, + { id: 'DC', text: 'DC' }, + { id: 'DE', text: 'DE' }, + { id: 'FL', text: 'FL' }, + { id: 'GA', text: 'GA' }, + { id: 'HI', text: 'HI' }, + { id: 'IA', text: 'IA' }, + { id: 'ID', text: 'ID' }, + { id: 'IL', text: 'IL' }, + { id: 'IN', text: 'IN' }, + { id: 'KS', text: 'KS' }, + { id: 'KY', text: 'KY' }, + { id: 'LA', text: 'LA' }, + { id: 'MA', text: 'MA' }, + { id: 'MD', text: 'MD' }, + { id: 'ME', text: 'ME' }, + { id: 'MI', text: 'MI' }, + { id: 'MN', text: 'MN' }, + { id: 'MO', text: 'MO' }, + { id: 'MS', text: 'MS' }, + { id: 'MT', text: 'MT' }, + { id: 'NC', text: 'NC' }, + { id: 'ND', text: 'ND' }, + { id: 'NE', text: 'NE' }, + { id: 'NH', text: 'NH' }, + { id: 'NJ', text: 'NJ' }, + { id: 'NM', text: 'NM' }, + { id: 'NV', text: 'NV' }, + { id: 'NY', text: 'NY' }, + { id: 'OH', text: 'OH' }, + { id: 'OK', text: 'OK' }, + { id: 'OR', text: 'OR' }, + { id: 'PA', text: 'PA' }, + { id: 'RI', text: 'RI' }, + { id: 'SC', text: 'SC' }, + { id: 'SD', text: 'SD' }, + { id: 'TN', text: 'TN' }, + { id: 'TX', text: 'TX' }, + { id: 'UT', text: 'UT' }, + { id: 'VA', text: 'VA' }, + { id: 'VT', text: 'VT' }, + { id: 'WA', text: 'WA' }, + { id: 'WI', text: 'WI' }, + { id: 'WV', text: 'WV' }, + { id: 'WY', text: 'WY' }, + { id: 'ny_minus_jfk', text: 'NY (minus NYC)' }, + { id: 'as', text: 'American Samoa' }, + { id: 'mp', text: 'Mariana Islands' }, + { id: 'gu', text: 'Guam' }, + { id: 'pr', text: 'Puerto Rico' }, + { id: 'vi', text: 'Virgin Islands' }, + { id: 'ord', text: 'Chicago' }, + { id: 'lax', text: 'Los Angeles' }, + { id: 'jfk', text: 'New York City' }, + ] + + checkForCovidcastIndicators() { + return this.indicators.some((indicator) => { + return indicator["_endpoint"] === "covidcast"; + }); + } + + showFluviewRegions() { + var fluviewRegionSelect = ` +
+
+ +
+
+ +
+
` + $("#otherEndpointLocations").append(fluviewRegionSelect) + $("#fluviewRegions").select2({ + placeholder: "Select ILINet Location(s)", + data: this.fluviewRegions, + allowClear: true, + width: '100%', + }); + } + + generateEpivisCustomTitle(indicator, geoValue) { + var epivisCustomTitle; + if (indicator["member_short_name"]) { + epivisCustomTitle = `${indicator["signal_set_short_name"]}:${indicator["member_short_name"]} : ${geoValue}` + } else { + epivisCustomTitle = `${indicator["signal_set_short_name"]} : ${geoValue}` + } + return epivisCustomTitle; + } + + plotData(){ + var dataSets = {}; + var covidCastGeographicValues = $('#geographic_value').select2('data'); + var fluviewRegions = $('#fluviewRegions').select2('data'); + console.log(fluviewRegions) + + this.indicators.forEach((indicator) => { + if (indicator["_endpoint"] === "covidcast") { + covidCastGeographicValues.forEach((geoValue) => { + var geographicValue = (typeof geoValue.id === 'string') ? geoValue.id.toLowerCase() : geoValue.id; + var geographicType = geoValue.geoType; + dataSets[`${indicator["signal"]}_${geographicValue}`] = { + color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + title: "value", + params: { + _endpoint: indicator["_endpoint"], + data_source: indicator["data_source"], + signal: indicator["signal"], + time_type: indicator["time_type"], + geo_type: geographicType, + geo_value: geographicValue, + custom_title: this.generateEpivisCustomTitle(indicator, geoValue.text) + } + } + }) + } else if (indicator["_endpoint"] === "fluview") { + fluviewRegions.forEach((region) => { + dataSets[`${indicator["signal"]}_${indicator["_endpoint"]}_${region.id}`] = { + color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + title: this.fluviewIndicatorsMapping[indicator["signal"]] || indicator["signal"], + params: { + _endpoint: indicator["_endpoint"], + regions: region.id, + custom_title: this.generateEpivisCustomTitle(indicator, region.text) + } + } + }) + } + // else if (indicator["_endpoint"] === "flusurv") { + // // TODO: Add support for flusurv. Need to figure out how to get the geographic value for flusurv. + // // For now, we will just use the static geographic value. + // dataSets[`${indicator["signal"]}_${indicator["_endpoint"]}`] = { + // color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + // title: indicator["signal"], + // params: { + // _endpoint: indicator["_endpoint"], + // locations: "network_all", + // custom_title: this.generateEpivisCustomTitle(indicator, "Entire Network") + // } + // } + // } else if (indicator["_endpoint"] === "gft") { + // // TODO: Add support for gft. Need to figure out how to get the geographic value for gft. + // // For now, we will just use the static geographic value. + // dataSets[`${indicator["signal"]}_${indicator["_endpoint"]}`] = { + // color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + // title: indicator["signal"], + // params: { + // _endpoint: indicator["_endpoint"], + // locations: "nat", + // custom_title: this.generateEpivisCustomTitle(indicator, "U.S. National") + // } + // } + // } else if (indicator["_endpoint"] === "wiki") { + // dataSets[`${indicator["signal"]}_${indicator["_endpoint"]}`] = { + // color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + // title: indicator["signal"], + // params: { + // _endpoint: indicator["_endpoint"], + // articles: "fatigue_(medical)", + // language: "en", + // resolution: "daily", + // custom_title: this.generateEpivisCustomTitle(indicator, "U.S. National") + // } + // } + // } else if (indicator["_endpoint"] === "cdc") { + // dataSets[`${indicator["signal"]}_${indicator["_endpoint"]}`] = { + // color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + // title: indicator["signal"], + // params: { + // _endpoint: indicator["_endpoint"], + // auth: "390da13640f61", + // locations: "nat", + // custom_title: this.generateEpivisCustomTitle(indicator, "U.S. National") + // } + // } + // } else if(indicator["_endpoint"] === "sensors") { + // dataSets[`${indicator["signal"]}_${indicator["_endpoint"]}`] = { + // color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), + // title: indicator["signal"], + // params: { + // _endpoint: indicator["_endpoint"], + // auth: "390da13640f61", + // names: "wiki", + // locations: "nat", + // custom_title: this.generateEpivisCustomTitle(indicator, "U.S. National") + // } + // } + // } + }); + var requestParams = []; + for (var key in dataSets) { + requestParams.push(dataSets[key]); + } + + var urlParamsEncoded = btoa(`{"datasets":${JSON.stringify(requestParams)}}`); + + var linkToEpivis = `${epiVisUrl}#${urlParamsEncoded}` + window.open(linkToEpivis, '_blank').focus(); + } +} \ No newline at end of file diff --git a/src/assets/js/signal_sets.js b/src/assets/js/signal_sets.js index c926b42..62f63e2 100644 --- a/src/assets/js/signal_sets.js +++ b/src/assets/js/signal_sets.js @@ -1,3 +1,5 @@ +const indicatorHandler = new IndicatorHandler(); + function initSelect2(elementId, data) { $(`#${elementId}`).select2({ data: data, @@ -27,7 +29,7 @@ async function checkGeoCoverage(geoType, geoValue) { } }); - checkedSignalMembers.forEach(signal => { + checkedSignalMembers.filter(signal => signal["_endpoint"] === "covidcast").forEach(signal => { const covered = result["epidata"].some( e => (e.source === signal.data_source && e.signal === signal.signal) ); @@ -43,50 +45,6 @@ async function checkGeoCoverage(geoType, geoValue) { } } - - -function plotData() { - var dataSets = {}; - var geographicValues = $('#geographic_value').select2('data'); - checkedSignalMembers.forEach((signal) => { - geographicValues.forEach((geoValue) => { - var geographicValue = (typeof geoValue.id === 'string') ? geoValue.id.toLowerCase() : geoValue.id; - var geographicType = geoValue.geoType; - var epivisCustomTitle; - if (signal["member_short_name"]) { - epivisCustomTitle = `${signal["signal_set_short_name"]}:${signal["member_short_name"]} : ${geoValue.text}` - } else { - epivisCustomTitle = `${signal["signal_set_short_name"]} : ${geoValue.text}` - } - dataSets[`${signal["signal"]}_${geographicValue}`] = { - color: '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'), - title: "value", - params: { - _endpoint: signal["_endpoint"], - data_source: signal["data_source"], - signal: signal["signal"], - time_type: signal["time_type"], - geo_type: geographicType, - geo_value: geographicValue, - custom_title: epivisCustomTitle - } - } - }) - - }); - - var requestParams = []; - for (var key in dataSets) { - requestParams.push(dataSets[key]); - } - - var urlParamsEncoded = btoa(`{"datasets":${JSON.stringify(requestParams)}}`); - - var linkToEpivis = `${epiVisUrl}#${urlParamsEncoded}` - window.open(linkToEpivis, '_blank').focus(); -} - - // Function to update the modal content function updateSelectedSignals(dataSource, signalDisplayName, signalSet, signal) { var selectedSignalsList = document.getElementById('selectedSignalsList'); @@ -120,6 +78,7 @@ function addSelectedSignal(element) { document.getElementById(`${element.dataset.datasource}_${element.dataset.signal}`).remove(); } + indicatorHandler.indicators = checkedSignalMembers; if (checkedSignalMembers.length > 0) { $("#showSelectedSignalsButton").show(); @@ -130,14 +89,24 @@ function addSelectedSignal(element) { $("#showSelectedSignalsButton").click(function() { alertPlaceholder.innerHTML = ""; + if (!indicatorHandler.checkForCovidcastIndicators()) { + $("#geographic_value").prop("disabled", true); + } $('#geographic_value').select2("data").forEach(geo => { checkGeoCoverage(geo.geoType, geo.id).then((notCoveredSignals) => { if (notCoveredSignals.length > 0) { showNotCoveredGeoWarningMessage(notCoveredSignals, geo.text); } }) - }); + var otherEndpointLocationsWarning = `` + $("#differentLocationNote").html(otherEndpointLocationsWarning) + indicatorHandler.showFluviewRegions(); + }); // Add an event listener to each 'bulk-select' element @@ -468,14 +437,15 @@ $('#geographic_value').on('select2:select', function (e) { function submitMode(event) { event.preventDefault(); var geographicValues = $('#geographic_value').select2('data'); - - if (geographicValues.length === 0) { - appendAlert("Please select at least one geographic location", "warning") - return; + if (indicatorHandler.checkForCovidcastIndicators()) { + if (geographicValues.length === 0) { + appendAlert("Please select at least one geographic location", "warning") + return; + } } if (currentMode === 'epivis') { - plotData(); + indicatorHandler.plotData(); } else if (currentMode === 'export') { exportData(); } else { diff --git a/src/templates/signal_sets/signal_sets.html b/src/templates/signal_sets/signal_sets.html index 68ac88f..d00a952 100644 --- a/src/templates/signal_sets/signal_sets.html +++ b/src/templates/signal_sets/signal_sets.html @@ -726,6 +726,10 @@ +
+
+
+