From dd801a838885b5ffcc32827fc99c01e8841a2d17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:27:12 +0000 Subject: [PATCH 01/20] Initial plan From b51de054ce44178c849585fb9b9f40bb3b9814fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:57:51 +0000 Subject: [PATCH 02/20] Add delete sensor data filtering by source/start/until + UI delete data panel Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/9d622b0b-5a18-4b92-b717-4c2562cb1471 Co-authored-by: joshuaunity <45713692+joshuaunity@users.noreply.github.com> --- flexmeasures/api/v3_0/sensors.py | 61 ++++++- .../test_sensors_delete_data_fresh_db.py | 152 ++++++++++++++++++ .../ui/templates/includes/graphs.html | 1 + flexmeasures/ui/templates/sensors/index.html | 133 +++++++++++++++ 4 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 flexmeasures/api/v3_0/tests/test_sensors_delete_data_fresh_db.py diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 62a427321..f98b23a82 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -59,7 +59,7 @@ DurationField, PlanningDurationField, ) -from flexmeasures.data.schemas import AssetIdField +from flexmeasures.data.schemas import AssetIdField, SourceIdField from flexmeasures.api.common.schemas.search import SearchFilterField from flexmeasures.data.schemas.scheduling import GetScheduleSchema from flexmeasures.data.schemas.units import UnitField @@ -1367,15 +1367,33 @@ def delete(self, id: int, sensor: Sensor): @route("//data", methods=["DELETE"]) @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path") + @use_kwargs( + { + "source": SourceIdField(load_default=None), + "start": AwareDateTimeField(load_default=None), + "until": AwareDateTimeField(load_default=None), + }, + location="json", + ) @permission_required_for_context("delete", ctx_arg_name="sensor") @as_json - def delete_data(self, id: int, sensor: Sensor): + def delete_data( + self, + id: int, + sensor: Sensor, + source=None, + start=None, + until=None, + ): """ .. :quickref: Sensors; Delete sensor data --- delete: summary: Delete sensor data - description: This endpoint deletes all data for a sensor. + description: > + This endpoint deletes data for a sensor. + Optionally, filter by source, start time and/or until time. + A missing source means all sources are deleted. security: - ApiKeyAuth: [] parameters: @@ -1384,6 +1402,24 @@ def delete_data(self, id: int, sensor: Sensor): description: ID of the sensor to delete data for. required: true schema: SensorId + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + source: + type: integer + description: ID of the data source to delete data for. If not provided, data from all sources is deleted. + start: + type: string + format: date-time + description: Only delete data with event start at or after this datetime (ISO 8601). + until: + type: string + format: date-time + description: Only delete data with event start before this datetime (ISO 8601). responses: 204: description: SENSOR_DATA_DELETED @@ -1398,10 +1434,25 @@ def delete_data(self, id: int, sensor: Sensor): tags: - Sensors """ - db.session.execute(delete(TimedBelief).filter_by(sensor_id=sensor.id)) + query = delete(TimedBelief).where(TimedBelief.sensor_id == sensor.id) + if source is not None: + query = query.where(TimedBelief.source_id == source.id) + if start is not None: + query = query.where(TimedBelief.event_start >= start) + if until is not None: + query = query.where(TimedBelief.event_start < until) + db.session.execute(query) + + audit_message = f"Deleted data for sensor '{sensor.name}': {sensor.id}" + if source is not None: + audit_message += f", source: {source.id}" + if start is not None: + audit_message += f", from: {start}" + if until is not None: + audit_message += f", until: {until}" AssetAuditLog.add_record( sensor.generic_asset, - f"Deleted data for sensor '{sensor.name}': {sensor.id}", + audit_message, ) db.session.commit() diff --git a/flexmeasures/api/v3_0/tests/test_sensors_delete_data_fresh_db.py b/flexmeasures/api/v3_0/tests/test_sensors_delete_data_fresh_db.py new file mode 100644 index 000000000..49c804c62 --- /dev/null +++ b/flexmeasures/api/v3_0/tests/test_sensors_delete_data_fresh_db.py @@ -0,0 +1,152 @@ +"""Tests for DELETE /api/v3_0/sensors//data with source, start and until filters. + +These tests use fresh_db (function-scoped) to ensure data isolation between tests, +since each test mutates the sensor data. +""" + +from __future__ import annotations + +import pytest + +from flask import url_for +from sqlalchemy import select + +from flexmeasures.data.models.time_series import TimedBelief +from flexmeasures import Sensor +from flexmeasures.api.v3_0.tests.utils import check_audit_log_event + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_delete_sensor_data_by_source( + client, setup_api_fresh_test_data, requesting_user, fresh_db +): + """Deleting sensor data with a source filter only removes beliefs from that source.""" + existing_sensor = setup_api_fresh_test_data["some gas sensor"] + existing_sensor_id = existing_sensor.id + + # Collect distinct source ids for this sensor + all_beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id) + ).all() + assert len(all_beliefs) > 0 + source_ids = list({b.source_id for b in all_beliefs}) + assert len(source_ids) >= 2, "Need at least two sources for this test" + + # Pick one source to delete + source_id_to_delete = source_ids[0] + + # Delete sensor data for that source only + delete_data_response = client.delete( + url_for("SensorAPI:delete_data", id=existing_sensor_id), + json={"source": source_id_to_delete}, + ) + assert delete_data_response.status_code == 204 + + remaining_beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id) + ).all() + + # Beliefs from the deleted source should be gone + deleted_source_beliefs = [ + b for b in remaining_beliefs if b.source_id == source_id_to_delete + ] + assert deleted_source_beliefs == [] + + # Beliefs from other sources should remain + other_beliefs = [b for b in remaining_beliefs if b.source_id != source_id_to_delete] + assert len(other_beliefs) > 0 + + deleted_sensor = fresh_db.session.get(Sensor, existing_sensor_id) + assert deleted_sensor is not None, "Sensor itself should not be deleted" + + check_audit_log_event( + db=fresh_db, + event=f"Deleted data for sensor '{existing_sensor.name}': {existing_sensor.id}, source: {source_id_to_delete}", + user=requesting_user, + asset=existing_sensor.generic_asset, + ) + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_delete_sensor_data_by_start( + client, setup_api_fresh_test_data, requesting_user, fresh_db +): + """Deleting sensor data with a start filter only removes beliefs at or after that time.""" + existing_sensor = setup_api_fresh_test_data["some gas sensor"] + existing_sensor_id = existing_sensor.id + + all_beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id) + ).all() + assert len(all_beliefs) >= 2 + + # Use the second distinct event_start as the cutoff: beliefs at or after it should be deleted + event_starts = sorted({b.event_start for b in all_beliefs}) + cutoff = event_starts[1] + + delete_data_response = client.delete( + url_for("SensorAPI:delete_data", id=existing_sensor_id), + json={"start": cutoff.isoformat()}, + ) + assert delete_data_response.status_code == 204 + + remaining_beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id) + ).all() + + # All remaining beliefs should have event_start < cutoff + for b in remaining_beliefs: + assert b.event_start < cutoff + + deleted_sensor = fresh_db.session.get(Sensor, existing_sensor_id) + assert deleted_sensor is not None, "Sensor itself should not be deleted" + + check_audit_log_event( + db=fresh_db, + event=f"Deleted data for sensor '{existing_sensor.name}': {existing_sensor.id}, from: {cutoff}", + user=requesting_user, + asset=existing_sensor.generic_asset, + ) + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_delete_sensor_data_by_until( + client, setup_api_fresh_test_data, requesting_user, fresh_db +): + """Deleting sensor data with an until filter only removes beliefs before that time.""" + existing_sensor = setup_api_fresh_test_data["some gas sensor"] + existing_sensor_id = existing_sensor.id + + all_beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id) + ).all() + assert len(all_beliefs) >= 2 + + # Use the last distinct event_start as the until cutoff: + # beliefs strictly before it should be deleted + event_starts = sorted({b.event_start for b in all_beliefs}) + cutoff = event_starts[-1] + + delete_data_response = client.delete( + url_for("SensorAPI:delete_data", id=existing_sensor_id), + json={"until": cutoff.isoformat()}, + ) + assert delete_data_response.status_code == 204 + + remaining_beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id) + ).all() + + # All remaining beliefs should have event_start >= cutoff + for b in remaining_beliefs: + assert b.event_start >= cutoff + + deleted_sensor = fresh_db.session.get(Sensor, existing_sensor_id) + assert deleted_sensor is not None, "Sensor itself should not be deleted" + + check_audit_log_event( + db=fresh_db, + event=f"Deleted data for sensor '{existing_sensor.name}': {existing_sensor.id}, until: {cutoff}", + user=requesting_user, + asset=existing_sensor.generic_asset, + ) diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 30470f250..68fb574bd 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -282,6 +282,7 @@ } document.addEventListener('sensorsToShowUpdated', reloadChartData); document.addEventListener('newDataAvailable', reloadChartData); + document.addEventListener('dataDeleted', reloadChartData); {% if event_starts_after and event_ends_before %} $("#spinner").show(); diff --git a/flexmeasures/ui/templates/sensors/index.html b/flexmeasures/ui/templates/sensors/index.html index bc5979143..94a91e1ce 100644 --- a/flexmeasures/ui/templates/sensors/index.html +++ b/flexmeasures/ui/templates/sensors/index.html @@ -196,6 +196,40 @@

Upload {{ sensor.name }} data

{% endif %} + + {% if user_can_delete_sensor %} +
+
Delete data
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {% endif %} -
- - +
+ Select all data +
+ + +
@@ -668,6 +671,23 @@
Statistics
syncDeleteInputsWithGraphRange(e.detail.startDate, e.detail.endDate); }); + // "Select all data" link: filled from the overall first/last event times across all sources. + // The link is revealed when the stats are available (sensorDataRangeAvailable event). + const selectAllDataLink = document.getElementById('selectAllDataLink'); + let allDataStart = null; + let allDataEnd = null; + document.addEventListener('sensorDataRangeAvailable', function(e) { + allDataStart = e.detail.firstEventStart; + allDataEnd = e.detail.lastEventEnd; + selectAllDataLink.classList.remove('d-none'); + }); + if (selectAllDataLink) { + selectAllDataLink.addEventListener('click', function(e) { + e.preventDefault(); + syncDeleteInputsWithGraphRange(allDataStart, allDataEnd); + }); + } + async function deleteData(sourceValue, startValue, untilValue) { const params = {}; if (sourceValue) { From d58c51301bab609be39664e86daacfc368b9036b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:58:28 +0000 Subject: [PATCH 12/20] fix: add sensor resolution to 'Select all data' end time; refresh stats after deletion Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/48518fa7-f5ad-4c7b-951e-b71437604fad Co-authored-by: nhoening <1042336+nhoening@users.noreply.github.com> --- flexmeasures/ui/templates/includes/graphs.html | 12 ++++++++++++ flexmeasures/ui/templates/sensors/index.html | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index c8db10206..5823a4a2d 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -283,6 +283,18 @@ document.addEventListener('sensorsToShowUpdated', reloadChartData); document.addEventListener('newDataAvailable', reloadChartData); document.addEventListener('dataDeleted', reloadChartData); + {% if active_page == "sensors" %} + document.addEventListener('dataDeleted', function() { + const toggleStats = document.getElementById('toggleStatsCheckbox'); + if (toggleStats) { + if (toggleStats.checked) { + loadSensorStats({{ sensor.id }}, storeStartDate.toISOString(), storeEndDate.toISOString()); + } else { + loadSensorStats({{ sensor.id }}); + } + } + }); + {% endif %} {% if event_starts_after and event_ends_before %} $("#spinner").show(); diff --git a/flexmeasures/ui/templates/sensors/index.html b/flexmeasures/ui/templates/sensors/index.html index b6c0fdd63..fdcb2925a 100644 --- a/flexmeasures/ui/templates/sensors/index.html +++ b/flexmeasures/ui/templates/sensors/index.html @@ -673,12 +673,14 @@
Statistics
// "Select all data" link: filled from the overall first/last event times across all sources. // The link is revealed when the stats are available (sensorDataRangeAvailable event). + // "Last event end" in the stats is the start of the last event; add sensor resolution to get actual end. const selectAllDataLink = document.getElementById('selectAllDataLink'); + const sensorResolutionMs = {{ sensor.event_resolution.total_seconds() | int }} * 1000; let allDataStart = null; let allDataEnd = null; document.addEventListener('sensorDataRangeAvailable', function(e) { allDataStart = e.detail.firstEventStart; - allDataEnd = e.detail.lastEventEnd; + allDataEnd = new Date(e.detail.lastEventEnd.getTime() + sensorResolutionMs); selectAllDataLink.classList.remove('d-none'); }); if (selectAllDataLink) { From 98571094c882d1c8b9f5da27b58631acc8ce20c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:53:37 +0000 Subject: [PATCH 13/20] fix: remove double-counted resolution, add no-cache to stats fetch, rename syncDeleteInputsWithRange Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/275aec67-9859-4358-8d2a-30f88b6a8ca3 Co-authored-by: nhoening <1042336+nhoening@users.noreply.github.com> --- flexmeasures/ui/static/js/flexmeasures.js | 2 +- flexmeasures/ui/templates/sensors/index.html | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flexmeasures/ui/static/js/flexmeasures.js b/flexmeasures/ui/static/js/flexmeasures.js index 395062e5c..cf7b4b7fd 100644 --- a/flexmeasures/ui/static/js/flexmeasures.js +++ b/flexmeasures/ui/static/js/flexmeasures.js @@ -671,7 +671,7 @@ function loadSensorStats(sensor_id, event_start_time="", event_end_time="") { fetchError.classList.add('d-none'); tableBody.innerHTML = ''; - fetch('/api/v3_0/sensors/' + sensor_id + '/stats' + queryParams) + fetch('/api/v3_0/sensors/' + sensor_id + '/stats' + queryParams, { cache: 'no-cache' }) .then(response => response.json()) .then(data => { // Remove 'status' sourceKey diff --git a/flexmeasures/ui/templates/sensors/index.html b/flexmeasures/ui/templates/sensors/index.html index fdcb2925a..c918ba301 100644 --- a/flexmeasures/ui/templates/sensors/index.html +++ b/flexmeasures/ui/templates/sensors/index.html @@ -649,7 +649,7 @@
Statistics
return `${date.getFullYear()}-${_pad(date.getMonth()+1)}-${_pad(date.getDate())}T${_pad(date.getHours())}:${_pad(date.getMinutes())}`; } - function syncDeleteInputsWithGraphRange(start, end) { + function syncDeleteInputsWithRange(start, end) { if (start) { startInputEl.value = toDatetimeLocalString(start); } if (end) { untilInputEl.value = toDatetimeLocalString(end); } } @@ -661,32 +661,32 @@
Statistics
try { var _start = new Date('{{ event_starts_after }}'); var _end = {% if event_ends_before %}new Date('{{ event_ends_before }}'){% else %}null{% endif %}; - syncDeleteInputsWithGraphRange(_start, _end); + syncDeleteInputsWithRange(_start, _end); } catch (e) { /* ignore invalid date strings */ } })(); {% endif %} // Keep delete data inputs in sync when the user changes the graph date picker document.addEventListener('graphDateRangeChanged', function(e) { - syncDeleteInputsWithGraphRange(e.detail.startDate, e.detail.endDate); + syncDeleteInputsWithRange(e.detail.startDate, e.detail.endDate); }); // "Select all data" link: filled from the overall first/last event times across all sources. // The link is revealed when the stats are available (sensorDataRangeAvailable event). - // "Last event end" in the stats is the start of the last event; add sensor resolution to get actual end. + // "Last event end" from the stats API already equals max_event_start + sensor.event_resolution, + // so it is the actual end of the last event and can be used directly. const selectAllDataLink = document.getElementById('selectAllDataLink'); - const sensorResolutionMs = {{ sensor.event_resolution.total_seconds() | int }} * 1000; let allDataStart = null; let allDataEnd = null; document.addEventListener('sensorDataRangeAvailable', function(e) { allDataStart = e.detail.firstEventStart; - allDataEnd = new Date(e.detail.lastEventEnd.getTime() + sensorResolutionMs); + allDataEnd = e.detail.lastEventEnd; selectAllDataLink.classList.remove('d-none'); }); if (selectAllDataLink) { selectAllDataLink.addEventListener('click', function(e) { e.preventDefault(); - syncDeleteInputsWithGraphRange(allDataStart, allDataEnd); + syncDeleteInputsWithRange(allDataStart, allDataEnd); }); } From 344f5eddad5a991a8f23b4a2f62068c5745bf922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:05:21 +0000 Subject: [PATCH 14/20] fix: add Cache-Control: no-store to stats endpoint response Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/b4e73567-719a-4811-8db0-bebd948003fb Co-authored-by: nhoening <1042336+nhoening@users.noreply.github.com> --- flexmeasures/api/v3_0/sensors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index f98b23a82..d5ca110b8 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1542,6 +1542,7 @@ def get_stats( return ( get_sensor_stats(sensor, event_start_time, event_end_time, sort_keys), 200, + {"Cache-Control": "no-store"}, ) @route("//status", methods=["GET"]) From c00fd0c15a1984829377ba27bef573a5528243f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Tue, 14 Apr 2026 10:35:02 +0200 Subject: [PATCH 15/20] explicit cache buster, so new and deleted data gets fresh stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- flexmeasures/api/v3_0/sensors.py | 11 ++++-- flexmeasures/data/services/sensors.py | 4 +- flexmeasures/ui/static/js/flexmeasures.js | 10 +++-- .../ui/templates/includes/graphs.html | 38 +++++++++++-------- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index d5ca110b8..1e05d8cf5 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1465,6 +1465,7 @@ def delete_data( "sort_keys": fields.Boolean(data_key="sort", load_default=True), "event_start_time": fields.Str(load_default=None), "event_end_time": fields.Str(load_default=None), + "fresh": fields.Boolean(load_default=False), }, location="query", ) @@ -1477,6 +1478,7 @@ def get_stats( event_start_time: str, event_end_time: str, sort_keys: bool, + fresh: bool, ): """ .. :quickref: Sensors; Get sensor stats @@ -1508,6 +1510,11 @@ def get_stats( description: Whether to sort the stats by keys. schema: type: boolean + - in: query + name: fresh + description: Whether to compute fresh data, bypassing any cached results. + schema: + type: boolean responses: 200: description: PROCESSED @@ -1538,11 +1545,9 @@ def get_stats( tags: - Sensors """ - return ( - get_sensor_stats(sensor, event_start_time, event_end_time, sort_keys), + get_sensor_stats(sensor, event_start_time, event_end_time, sort_keys, from_cache=not fresh), 200, - {"Cache-Control": "no-store"}, ) @route("//status", methods=["GET"]) diff --git a/flexmeasures/data/services/sensors.py b/flexmeasures/data/services/sensors.py index 82a32391a..7cb6ec7a0 100644 --- a/flexmeasures/data/services/sensors.py +++ b/flexmeasures/data/services/sensors.py @@ -580,7 +580,7 @@ def _get_sensor_stats( def get_sensor_stats( - sensor: Sensor, event_start_time: str, event_end_time: str, sort_keys: bool = True + sensor: Sensor, event_start_time: str, event_end_time: str, sort_keys: bool = True, from_cache: bool = True ) -> dict: """Get stats for a sensor. @@ -591,7 +591,7 @@ def get_sensor_stats( bucket = round(time.time() / _SENSOR_STATS_TTL) key = (sensor.id, event_end_time, event_start_time, sort_keys, bucket) - if key in _sensor_stats_cache: + if from_cache and key in _sensor_stats_cache: return _sensor_stats_cache[key] result = _get_sensor_stats(sensor, event_end_time, event_start_time, sort_keys) diff --git a/flexmeasures/ui/static/js/flexmeasures.js b/flexmeasures/ui/static/js/flexmeasures.js index cf7b4b7fd..d0a3408e2 100644 --- a/flexmeasures/ui/static/js/flexmeasures.js +++ b/flexmeasures/ui/static/js/flexmeasures.js @@ -649,7 +649,7 @@ function updateStatsTable(stats, tableBody) { }); } -function loadSensorStats(sensor_id, event_start_time="", event_end_time="") { +function loadSensorStats(sensor_id, event_start_time="", event_end_time="", fresh=false) { const spinner = document.getElementById('spinner-run-simulation'); const dropdownContainer = document.getElementById('sourceKeyDropdownContainer'); const tableBody = document.getElementById('statsTableBody'); @@ -664,14 +664,18 @@ function loadSensorStats(sensor_id, event_start_time="", event_end_time="") { if (toggleStatsCheckbox.checked) { queryParams = `?sort=false&event_start_time=${event_start_time}&event_end_time=${event_end_time}` } - + //add a cache buster to ensure we get the latest data after an upload + if (fresh === true) { + queryParams += `&fresh=true`; + } + // Enable all the default behaviors on every API call. dropdownMenu.innerHTML = ''; noDataWarning.classList.add('d-none'); fetchError.classList.add('d-none'); tableBody.innerHTML = ''; - fetch('/api/v3_0/sensors/' + sensor_id + '/stats' + queryParams, { cache: 'no-cache' }) + fetch('/api/v3_0/sensors/' + sensor_id + '/stats' + queryParams) .then(response => response.json()) .then(data => { // Remove 'status' sourceKey diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 5823a4a2d..4ced02e44 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -234,11 +234,11 @@ document.getElementById('toggleStatsCheckbox').addEventListener('change', function () { if (this.checked) { loadSensorStats({{ sensor.id }}, storeStartDate.toISOString(), storeEndDate.toISOString()); - } - else { - loadSensorStats({{ sensor.id }}); - } - }); + } + else { + loadSensorStats({{ sensor.id }}, "", "", true); + } + }); {% endif %} // Keypress Listener @@ -280,19 +280,25 @@ data: fetchedInitialData }; } - document.addEventListener('sensorsToShowUpdated', reloadChartData); - document.addEventListener('newDataAvailable', reloadChartData); - document.addEventListener('dataDeleted', reloadChartData); - {% if active_page == "sensors" %} - document.addEventListener('dataDeleted', function() { + async function reloadSensorStats() { const toggleStats = document.getElementById('toggleStatsCheckbox'); if (toggleStats) { if (toggleStats.checked) { - loadSensorStats({{ sensor.id }}, storeStartDate.toISOString(), storeEndDate.toISOString()); + loadSensorStats({{ sensor.id }}, storeStartDate.toISOString(), storeEndDate.toISOString(), true); } else { - loadSensorStats({{ sensor.id }}); + loadSensorStats({{ sensor.id }}, "", "", true); } } + } + document.addEventListener('sensorsToShowUpdated', reloadChartData); + {% if active_page == "sensors" %} + document.addEventListener('newDataAvailable', function() { + reloadChartData(); + reloadSensorStats(); + }); + document.addEventListener('dataDeleted', function() { + reloadChartData(); + reloadSensorStats(); }); {% endif %} @@ -468,10 +474,10 @@ const toggleStats = document.getElementById('toggleStatsCheckbox'); if (toggleStats.checked) { loadSensorStats({{ sensor.id }}, storeStartDate.toISOString(), storeEndDate.toISOString()); - } - else { - loadSensorStats({{ sensor.id }}); - } + } + else { + loadSensorStats({{ sensor.id }}, "", ""); + } {% endif %} stopReplay() From 0200334a4aed4f1bfe2118360b2318761c07c32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Tue, 14 Apr 2026 11:06:35 +0200 Subject: [PATCH 16/20] black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- flexmeasures/api/v3_0/sensors.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 1e05d8cf5..6dbe80367 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1546,7 +1546,13 @@ def get_stats( - Sensors """ return ( - get_sensor_stats(sensor, event_start_time, event_end_time, sort_keys, from_cache=not fresh), + get_sensor_stats( + sensor, + event_start_time, + event_end_time, + sort_keys, + from_cache=not fresh, + ), 200, ) From 08ec702bb3651d9473bf94e3e8c4f9fc86ceca0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Tue, 14 Apr 2026 11:13:32 +0200 Subject: [PATCH 17/20] small api specs update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- flexmeasures/api/v3_0/sensors.py | 4 ++-- flexmeasures/ui/static/openapi-specs.json | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 6dbe80367..2727a11da 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1507,12 +1507,12 @@ def get_stats( format: date-time - in: query name: sort_keys - description: Whether to sort the stats by keys. + description: Whether to sort the stats by keys (defaults to true). schema: type: boolean - in: query name: fresh - description: Whether to compute fresh data, bypassing any cached results. + description: Whether to compute fresh data, bypassing any cached results (defaults to false). schema: type: boolean responses: diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 98eea21e0..e3e562b0d 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.31.0" + "version": "0.31.1" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -831,7 +831,15 @@ { "in": "query", "name": "sort_keys", - "description": "Whether to sort the stats by keys.", + "description": "Whether to sort the stats by keys (defaults to true).", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "fresh", + "description": "Whether to compute fresh data, bypassing any cached results (defaults to false).", "schema": { "type": "boolean" } From 5774afe6ee34ec290d8502c80cc11857983755bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Tue, 14 Apr 2026 11:18:11 +0200 Subject: [PATCH 18/20] black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- flexmeasures/data/services/sensors.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/services/sensors.py b/flexmeasures/data/services/sensors.py index 7cb6ec7a0..a0c5a88ba 100644 --- a/flexmeasures/data/services/sensors.py +++ b/flexmeasures/data/services/sensors.py @@ -580,7 +580,11 @@ def _get_sensor_stats( def get_sensor_stats( - sensor: Sensor, event_start_time: str, event_end_time: str, sort_keys: bool = True, from_cache: bool = True + sensor: Sensor, + event_start_time: str, + event_end_time: str, + sort_keys: bool = True, + from_cache: bool = True, ) -> dict: """Get stats for a sensor. From 96bbb71754c49e46be016d93f77310ad4b4b6eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Tue, 14 Apr 2026 11:33:51 +0200 Subject: [PATCH 19/20] fix failing test - new function only should live on sensor page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- flexmeasures/ui/templates/includes/graphs.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 4ced02e44..62164b790 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -280,6 +280,8 @@ data: fetchedInitialData }; } + document.addEventListener('sensorsToShowUpdated', reloadChartData); + {% if active_page == "sensors" %} async function reloadSensorStats() { const toggleStats = document.getElementById('toggleStatsCheckbox'); if (toggleStats) { @@ -290,8 +292,6 @@ } } } - document.addEventListener('sensorsToShowUpdated', reloadChartData); - {% if active_page == "sensors" %} document.addEventListener('newDataAvailable', function() { reloadChartData(); reloadSensorStats(); From 4b204d1cf8e20430172a100361192d2ca25b9865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Tue, 14 Apr 2026 12:23:15 +0200 Subject: [PATCH 20/20] revert undocumented change in not showing all toasts (btn-close fix stays) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- flexmeasures/ui/templates/includes/toasts.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/flexmeasures/ui/templates/includes/toasts.html b/flexmeasures/ui/templates/includes/toasts.html index ed8d251f8..d934f5fa0 100644 --- a/flexmeasures/ui/templates/includes/toasts.html +++ b/flexmeasures/ui/templates/includes/toasts.html @@ -31,11 +31,14 @@ }); } - function showNewToast(toastElement) { - const toastInstance = new bootstrap.Toast(toastElement); - toastInstance.show(); + function showAllToasts() { + const toastElements = document.querySelectorAll(".toast"); + toastElements.forEach((toast) => { + const toastInstance = new bootstrap.Toast(toast); + toastInstance.show(); + }); } - + function maybeHideCloseToastBtn() { const remainingToasts = toastStack.querySelectorAll(".toast"); if (remainingToasts.length === 0) { @@ -122,7 +125,7 @@ toastStack.insertAdjacentElement("afterbegin", toast); closeToastBtn.style.display = "block"; - showNewToast(toast); + showAllToasts(); // Cleanup listeners const handleClose = () => {