diff --git a/src/assets/js/indicatorHandler.js b/src/assets/js/indicatorHandler.js
new file mode 100644
index 0000000..fc2da5e
--- /dev/null
+++ b/src/assets/js/indicatorHandler.js
@@ -0,0 +1,413 @@
+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";
+ });
+ }
+
+ getCovidcastIndicators() {
+ var covidcastIndicators = [];
+ this.indicators.forEach((indicator) => {
+ if (indicator["_endpoint"] === "covidcast") {
+ covidcastIndicators.push(indicator);
+ }
+ });
+ return covidcastIndicators;
+ }
+
+ getFluviewIndicators() {
+ var fluviewIndicators = [];
+ this.indicators.forEach((indicator) => {
+ if (indicator["_endpoint"] === "fluview") {
+ fluviewIndicators.push(indicator);
+ }
+ }
+ );
+ return fluviewIndicators;
+ }
+
+ getFromToDate(startDate, endDate, timeType) {
+ if (timeType === "week") {
+ $.ajax({
+ url: "get_epiweek/",
+ type: 'POST',
+ async: false,
+ data: {
+ csrfmiddlewaretoken: csrf_token,
+ start_date: startDate,
+ end_date: endDate,
+ },
+ success: function (result) {
+ startDate = result.start_date;
+ endDate = result.end_date;
+ }
+ })
+ }
+ return [startDate, endDate];
+ }
+
+
+ sendAsyncAjaxRequest(url, data) {
+ var request = $.ajax({
+ url: url,
+ type: "GET",
+ data: data,
+ })
+ return request;
+ }
+
+ showFluviewRegions() {
+ var fluviewRegionSelect = `
+
+
+ ILINet Location(s):
+
+
+
+
+
`
+ if ($("#otherEndpointLocations").length) {
+ $("#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');
+
+ 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();
+ }
+
+ exportData() {
+ var manualDataExport = "To download data, please click on the link or copy/paste command into your terminal: \n\n"
+ var exportUrl;
+
+ this.getCovidcastIndicators().forEach((indicator) => {
+ var startDate = document.getElementById('start_date').value;
+ var endDate = document.getElementById('end_date').value;
+ const [dateFrom, dateTo] = this.getFromToDate(startDate, endDate, indicator["time_type"]);
+
+ var covidCastGeographicValues = $('#geographic_value').select2('data');
+ covidCastGeographicValues = Object.groupBy(covidCastGeographicValues, ({ geoType }) => [geoType]);
+ var covidcastGeoTypes = Object.keys(covidCastGeographicValues);
+ covidcastGeoTypes.forEach((geoType) => {
+ var geoValues = covidCastGeographicValues[geoType].map((el) => (typeof el.id === "string") ? el.id.toLowerCase() : el.id).join(",");
+ exportUrl = `https://api.delphi.cmu.edu/epidata/covidcast/csv?signal=${indicator["data_source"]}:${indicator["signal"]}&start_day=${dateFrom}&end_day=${dateTo}&geo_type=${geoType}&geo_values=${geoValues}`;
+ manualDataExport += `wget --content-disposition ${exportUrl} \n`;
+ })
+ })
+
+ if (this.getFluviewIndicators().length > 0) {
+ var startDate = document.getElementById('start_date').value;
+ var endDate = document.getElementById('end_date').value;
+
+ const [dateFrom, dateTo] = this.getFromToDate(startDate, endDate, "week");
+
+ var fluviewRegions = $('#fluviewRegions').select2('data').map((region) => region.id);
+ fluviewRegions = fluviewRegions.join(",");
+ exportUrl = `https://api.delphi.cmu.edu/epidata/fluview/?regions=${fluviewRegions}&epiweeks=${dateFrom}-${dateTo}&format=csv`
+ manualDataExport += `wget --content-disposition ${exportUrl} \n`;
+ }
+
+ $('#modeSubmitResult').html(manualDataExport);
+ }
+
+ previewData(){
+ $('#loader').show();
+ var requests = [];
+ var previewExample = [];
+ var startDate = document.getElementById('start_date').value;
+ var endDate = document.getElementById('end_date').value;
+
+ if (this.checkForCovidcastIndicators()) {
+ var geographicValues = $('#geographic_value').select2('data');
+ geographicValues = Object.groupBy(geographicValues, ({ geoType }) => [geoType])
+ var geoTypes = Object.keys(geographicValues);
+ this.getCovidcastIndicators().forEach((indicator) => {
+ const [dateFrom, dateTo] = this.getFromToDate(startDate, endDate, indicator["time_type"]);
+ var timeValues = indicator["time_type"] === "week" ? `${dateFrom}-${dateTo}` : `${dateFrom}--${dateTo}`;
+ geoTypes.forEach((geoType) => {
+ var geoValues = geographicValues[geoType].map((el) => (typeof el.id === "string") ? el.id.toLowerCase() : el.id).join(",");
+ var data = {
+ "time_type": indicator["time_type"],
+ "time_values": timeValues,
+ "data_source": indicator["data_source"],
+ "signal": indicator["signal"],
+ "geo_type": geoType,
+ "geo_values": geoValues
+ }
+ requests.push(this.sendAsyncAjaxRequest("epidata/covidcast/", data))
+ })
+ })
+ }
+
+ if (this.getFluviewIndicators().length > 0) {
+ const [dateFrom, dateTo] = this.getFromToDate(startDate, endDate, "week");
+ var fluviewRegions = $('#fluviewRegions').select2('data').map((region) => region.id);
+ fluviewRegions = fluviewRegions.join(",");
+ var data = {
+ "regions": fluviewRegions,
+ "epiweeks": `${dateFrom}-${dateTo}`,
+ }
+
+ requests.push(this.sendAsyncAjaxRequest("epidata/fluview/", data))
+ }
+
+ $.when.apply($, requests).then((...responses) => {
+ if (requests.length === 1) {
+ if (responses[0]["epidata"].length != 0) {
+ previewExample.push({ epidata: responses[0]["epidata"][0], result: responses["result"], message: responses["message"] })
+ } else {
+ previewExample.push(responses[0]);
+ }
+ } else {
+ responses.forEach((response) => {
+ if (response[0]["epidata"].length != 0) {
+ previewExample.push({ epidata: response[0]["epidata"][0], result: response[0]["result"], message: response[0]["message"] })
+ } else {
+ previewExample.push(response[0]["epidata"]);
+ }
+ })
+ }
+ $('#loader').hide();
+ $('#modeSubmitResult').html(JSON.stringify(previewExample, null, 2));
+
+ })
+ }
+
+}
\ No newline at end of file
diff --git a/src/assets/js/signal_sets.js b/src/assets/js/signal_sets.js
index 676490e..3120909 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,
@@ -15,73 +17,34 @@ 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;
-}
-
-
-
-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}`
+async function checkGeoCoverage(geoType, geoValue) {
+ const notCoveredSignals = [];
+
+ try {
+ const result = await $.ajax({
+ url: "epidata/covidcast/geo_coverage/",
+ type: 'GET',
+ data: {
+ 'geo': `${geoType}:${geoValue}`
}
- 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
- }
+ });
+
+ checkedSignalMembers.filter(signal => signal["_endpoint"] === "covidcast").forEach(signal => {
+ const covered = result["epidata"].some(
+ e => (e.source === signal.data_source && e.signal === signal.signal)
+ );
+ if (!covered) {
+ notCoveredSignals.push(signal);
}
- })
+ });
- });
-
- var requestParams = [];
- for (var key in dataSets) {
- requestParams.push(dataSets[key]);
+ return notCoveredSignals;
+ } catch (error) {
+ console.error('Error fetching geo coverage:', error);
+ return notCoveredSignals;
}
-
- 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');
@@ -115,6 +78,7 @@ function addSelectedSignal(element) {
document.getElementById(`${element.dataset.datasource}_${element.dataset.signal}`).remove();
}
+ indicatorHandler.indicators = checkedSignalMembers;
if (checkedSignalMembers.length > 0) {
$("#showSelectedSignalsButton").show();
@@ -123,6 +87,33 @@ function addSelectedSignal(element) {
}
}
+$("#showSelectedSignalsButton").click(function() {
+ alertPlaceholder.innerHTML = "";
+ if (!indicatorHandler.checkForCovidcastIndicators()) {
+ $("#geographic_value").prop("disabled", true);
+ } else {
+ $("#geographic_value").prop("disabled", false);
+ }
+ $('#geographic_value').select2("data").forEach(geo => {
+ checkGeoCoverage(geo.geoType, geo.id).then((notCoveredSignals) => {
+ if (notCoveredSignals.length > 0) {
+ showNotCoveredGeoWarningMessage(notCoveredSignals, geo.text);
+ }
+ })
+ });
+ var otherEndpointLocationsWarning = `` +
+ `
Please, note that some indicator sets may require to select location(s) that is/are different from location above. `
+ nonCovidcastSignalSets = [...new Set(checkedSignalMembers.filter(signal => signal["_endpoint"] != "covidcast").map((signal) => signal["signal_set"]))];
+ otherEndpointLocationsWarning += `Different location is required for following signal set(s): ${nonCovidcastSignalSets.join(", ")}`
+ otherEndpointLocationsWarning += `
`
+ if (indicatorHandler.getFluviewIndicators().length > 0) {
+ $("#differentLocationNote").html(otherEndpointLocationsWarning)
+ if (document.getElementsByName("fluviewRegions").length === 0) {
+ indicatorHandler.showFluviewRegions();
+ }
+ }
+});
+
// Add an event listener to each 'bulk-select' element
let bulkSelectDivs = document.querySelectorAll('.bulk-select');
bulkSelectDivs.forEach(div => {
@@ -254,116 +245,6 @@ function format (signalSetId, relatedSignals, signalSetDescription) {
return data;
}
-
-function exportData() {
- var geographicValues = $('#geographic_value').select2('data');
- geographicValues = Object.groupBy(geographicValues, ({ geoType }) => [geoType])
- var geoTypes = Object.keys(geographicValues);
-
- var startDate = document.getElementById('start_date').value;
- var endDate = document.getElementById('end_date').value;
-
- var manualDataExport = "To download data, please click on the link or copy/paste command into your terminal: \n\n"
- var requests = [];
-
- checkedSignalMembers.forEach((signal) => {
- geoTypes.forEach((geoType) => {
- var geoValues = geographicValues[geoType].map((el) => (typeof el.id === 'string') ? el.id.toLowerCase() : el.id).join(",");
- if (signal["time_type"] === "week") {
- var request = $.ajax({
- url: "get_epiweek/",
- type: 'POST',
- async: true,
- data: {
- csrfmiddlewaretoken: csrf_token,
- start_date: startDate,
- end_date: endDate,
- },
- success: function (result) {
- var exportUrl = `https://api.delphi.cmu.edu/epidata/covidcast/csv?signal=${signal["data_source"]}:${signal["signal"]}&start_day=${result.start_date}&end_day=${result.end_date}&geo_type=${geoType}&geo_values=${geoValues}`;
- manualDataExport += `wget --content-disposition ${exportUrl} \n`
- }
- })
- requests.push(request);
- } else {
- var exportUrl = `https://api.delphi.cmu.edu/epidata/covidcast/csv?signal=${signal["data_source"]}:${signal["signal"]}&start_day=${startDate}&end_day=${endDate}&geo_type=${geoType}&geo_values=${geoValues}`;
- manualDataExport += `wget --content-disposition ${exportUrl} \n`
- }
- });
- });
- $.when.apply($, requests).then(function() {
- $('#modeSubmitResult').html(manualDataExport);
- })
-
-}
-
-function previewData() {
- var geographicValues = $('#geographic_value').select2('data');
- geographicValues = Object.groupBy(geographicValues, ({ geoType }) => [geoType])
- var geoTypes = Object.keys(geographicValues);
- var previewExample = [];
- var requests = [];
-
- var startDate = document.getElementById("start_date").value;
- var endDate = document.getElementById("end_date").value;
-
- checkedSignalMembers.forEach((signal) => {
- var timeValues;
-
- if (signal["time_type"] === "week") {
- $.ajax({
- url: "get_epiweek/",
- type: 'POST',
- async: false,
- data: {
- csrfmiddlewaretoken: csrf_token,
- start_date: startDate,
- end_date: endDate,
- },
- success: function (result) {
- timeValues = `${result.start_date}-${result.end_date}`;
- }
- })
- };
-
- var requestSent = false;
- if (!requestSent) {
- geoTypes.forEach((geoType) => {
- var geoValues = geographicValues[geoType].map((el) => (typeof el.id === 'string') ? el.id.toLowerCase() : el.id).join(",");
- $('#loader').show();
- timeValues = signal["time_type"] === "week" ? timeValues : `${startDate}--${endDate}`;
- var request = $.ajax({
- url: "epidata/covidcast/",
- type: 'GET',
- async: true,
- data: {
- 'time_type': signal["time_type"],
- 'time_values': timeValues,
- 'data_source': signal["data_source"],
- 'signal': signal["signal"],
- 'geo_type': geoType,
- 'geo_values': geoValues
- },
- success: function (result) {
- if (result["epidata"].length != 0) {
- previewExample.push({epidata: result["epidata"][0], result: result["result"], message: result["message"]})
- } else {
- previewExample.push({epidata: result["epidata"], result: result["result"], message: result["message"]})
- }
- }
- })
- requests.push(request);
- })
- }
- })
- $.when.apply($, requests).then(function() {
- $('#loader').hide();
- $('#modeSubmitResult').html(JSON.stringify(previewExample, null, 2));
- requestSent = true;
- })
-}
-
-
// Plot/Export/Preview data block
var currentMode = 'epivis';
@@ -439,22 +320,31 @@ 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);
+ }
}
+ );
});
function submitMode(event) {
event.preventDefault();
+ var geographicValues = $('#geographic_value').select2('data');
+ 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();
+ indicatorHandler.exportData();
} else {
- previewData();
+ indicatorHandler.previewData();
}
}
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 163b91d..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
}
},
{
@@ -126,9 +130,10 @@
"fields": {
"created": "2024-08-15T09:57:49.327Z",
"modified": "2024-08-15T13:07:46.136Z",
- "name": "Case",
- "display_name": "Case",
- "used_in": "signal_sets"
+ "name": "Ascertained (Case)",
+ "display_name": "Ascertained (Case)",
+ "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/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/resources.py b/src/signal_sets/resources.py
index 2eb437a..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"
@@ -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/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..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,
@@ -102,7 +103,9 @@ class SeverityPyramidRungAdmin(admin.ModelAdmin):
"name",
"display_name",
"used_in",
+ "display_order_number",
)
+ exclude = ("id",)
search_fields: tuple[Literal["name"]] = ("name",)
@@ -148,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/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/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 f9f7070..b55ca0b 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"]
@@ -569,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")
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..d00a952 100644
--- a/src/templates/signal_sets/signal_sets.html
+++ b/src/templates/signal_sets/signal_sets.html
@@ -278,6 +278,18 @@